feat: 完成所有页面的演示模式实现

- 更新 DashboardLayout 组件,统一使用演示模式布局
- 实现仪表盘页面的完整演示数据和功能
- 完成用户管理页面的演示模式,包含搜索、过滤、分页等功能
- 实现通话记录页面的演示数据和录音播放功能
- 完成翻译员管理页面的演示模式
- 实现订单管理页面的完整功能
- 完成发票管理页面的演示数据
- 更新文档管理页面
- 添加 utils.ts 工具函数库
- 完善 API 路由和数据库结构
- 修复各种 TypeScript 类型错误
- 统一界面风格和用户体验
This commit is contained in:
2025-06-30 19:42:43 +08:00
parent 0b8be9377a
commit f20988b90c
36 changed files with 8752 additions and 3638 deletions
+13 -10
View File
@@ -1,31 +1,31 @@
# Supabase 配置 # Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url NEXT_PUBLIC_SUPABASE_URL=https://riwtulmitqioswmgwftg.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx...
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Twilio 配置 # Twilio 配置
TWILIO_ACCOUNT_SID=your_twilio_account_sid TWILIO_ACCOUNT_SID=AC0123456789abcdef0123456789abcdef
TWILIO_AUTH_TOKEN=your_twilio_auth_token TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_API_KEY=your_twilio_api_key TWILIO_API_KEY_SID=SK0123456789abcdef0123456789abcdef
TWILIO_API_SECRET=your_twilio_api_secret TWILIO_API_KEY_SECRET=0123456789abcdef0123456789abcdef
# Stripe 配置 # Stripe 配置
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51RTwLuDWamLO9gYlv7ZX0Jj2aLBkADGWmTC3NP0aoez3nEdnLlQiWH3KUie1C45CSa1ho3DvTm0GqR59X0sNTnqN00Q15Fq0zw
STRIPE_SECRET_KEY=your_stripe_secret_key STRIPE_SECRET_KEY=sk_test_51RTwLuDWamLO9gYliBCJFtPob28ttoTtvsglGtyXrHkrnuppY2ScnVz7BRh1hCHzvOXcOyvMejBRVsx5vMpgKLVE0065W8VOU8
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
# ElevenLabs 配置 # ElevenLabs 配置
ELEVENLABS_API_KEY=your_elevenlabs_api_key ELEVENLABS_API_KEY=your_elevenlabs_api_key
# JWT 密钥 # JWT 密钥
JWT_SECRET=your_jwt_secret JWT_SECRET=your_jwt_secret_key_here
# 应用配置 # 应用配置
NEXTAUTH_URL=http://localhost:3000 NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_nextauth_secret NEXTAUTH_SECRET=your_nextauth_secret_key_here
# 数据库配置 # 数据库配置
DATABASE_URL=your_database_url DATABASE_URL=your_database_url_here
# 邮件配置 (可选,用于通知) # 邮件配置 (可选,用于通知)
SMTP_HOST=your_smtp_host SMTP_HOST=your_smtp_host
@@ -42,3 +42,6 @@ MAX_FILE_SIZE=10485760 # 10MB
# 支付配置 # 支付配置
PAYMENT_SUCCESS_URL=http://localhost:3000/payment/success PAYMENT_SUCCESS_URL=http://localhost:3000/payment/success
PAYMENT_CANCEL_URL=http://localhost:3000/payment/cancel PAYMENT_CANCEL_URL=http://localhost:3000/payment/cancel
# OpenAI 配置
OPENAI_API_KEY=sk_live_o_pqmR3A26poD7ltpYgZ1aoDZEOaAJr8lUlvTw
+368
View File
@@ -0,0 +1,368 @@
# 部署指南
## 概述
本文档介绍如何将口译服务管理后台部署到生产环境。项目支持多种部署方式,推荐使用 Vercel 进行快速部署。
## 前置条件
### 1. 准备服务账户
在部署前,请确保已经配置好以下服务:
- **Supabase** - 数据库和身份验证服务
- **Stripe** - 支付处理服务(可选)
- **OpenAI** - AI 服务(可选)
- **Twilio** - 通信服务(可选)
### 2. 环境变量配置
请准备以下环境变量:
```env
# Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# Stripe 配置
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# OpenAI 配置
OPENAI_API_KEY=sk-...
# Twilio 配置
TWILIO_ACCOUNT_SID=AC...
TWILIO_API_KEY_SID=SK...
TWILIO_API_KEY_SECRET=...
# 应用配置
NEXTAUTH_SECRET=your-nextauth-secret
JWT_SECRET=your-jwt-secret
NEXTAUTH_URL=https://your-domain.com
```
## 部署方式
### 方式一:Vercel 部署(推荐)
#### 1. 准备代码仓库
```bash
# 确保代码已提交到 Git 仓库
git add .
git commit -m "准备部署"
git push origin main
```
#### 2. 连接 Vercel
1. 访问 [Vercel Dashboard](https://vercel.com/dashboard)
2. 点击 "New Project"
3. 导入你的 Git 仓库
4. 选择 "Next.js" 框架预设
#### 3. 配置环境变量
在 Vercel 项目设置中添加所有必要的环境变量:
1. 进入项目 Settings
2. 选择 Environment Variables
3. 添加所有上述环境变量
#### 4. 部署
1. 点击 "Deploy" 开始部署
2. 等待构建完成
3. 访问提供的 URL 验证部署
### 方式二:Docker 部署
#### 1. 创建 Dockerfile
```dockerfile
FROM node:18-alpine AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --only=production
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]
```
#### 2. 构建和运行
```bash
# 构建镜像
docker build -t interpretation-admin .
# 运行容器
docker run -p 3000:3000 --env-file .env.local interpretation-admin
```
### 方式三:传统服务器部署
#### 1. 服务器环境准备
```bash
# 安装 Node.js 18+
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# 安装 PM2
npm install -g pm2
```
#### 2. 代码部署
```bash
# 克隆代码
git clone https://github.com/your-username/interpretation-admin.git
cd interpretation-admin
# 安装依赖
npm install
# 构建项目
npm run build
# 启动服务
pm2 start npm --name "interpretation-admin" -- start
```
## 数据库设置
### 1. 执行数据库脚本
在 Supabase Dashboard 中执行 `database/schema.sql` 脚本:
1. 登录 Supabase Dashboard
2. 进入 SQL Editor
3. 复制粘贴 `database/schema.sql` 内容
4. 点击 "Run" 执行
### 2. 配置 RLS 策略
确保行级安全策略正确配置,保护用户数据安全。
### 3. 创建管理员账户
```sql
-- 在 Supabase SQL Editor 中执行
INSERT INTO users (id, email, name, user_type, status)
VALUES (
'your-admin-user-id',
'admin@example.com',
'系统管理员',
'admin',
'active'
);
```
## SSL 证书配置
### 使用 Let's Encrypt
```bash
# 安装 Certbot
sudo apt install certbot python3-certbot-nginx
# 获取证书
sudo certbot --nginx -d yourdomain.com
# 自动续期
sudo crontab -e
# 添加: 0 12 * * * /usr/bin/certbot renew --quiet
```
## 性能优化
### 1. 启用缓存
```javascript
// next.config.js
module.exports = {
async headers() {
return [
{
source: '/api/:path*',
headers: [
{
key: 'Cache-Control',
value: 'public, max-age=300, stale-while-revalidate=60'
}
]
}
]
}
}
```
### 2. 图片优化
确保使用 Next.js Image 组件进行图片优化。
### 3. 代码分割
利用 Next.js 的自动代码分割功能。
## 监控和日志
### 1. 应用监控
推荐使用以下监控服务:
- **Vercel Analytics** - 性能监控
- **Sentry** - 错误监控
- **LogRocket** - 用户行为监控
### 2. 数据库监控
- 使用 Supabase Dashboard 监控数据库性能
- 设置告警规则
- 定期检查慢查询
## 备份策略
### 1. 数据库备份
```bash
# 每日自动备份
0 2 * * * pg_dump $DATABASE_URL > backup_$(date +\%Y\%m\%d).sql
```
### 2. 代码备份
- 使用 Git 进行版本控制
- 定期推送到远程仓库
- 标记重要版本
## 安全配置
### 1. 环境变量安全
- 使用强密码和随机密钥
- 定期轮换 API 密钥
- 不要在代码中硬编码敏感信息
### 2. 网络安全
- 启用 HTTPS
- 配置 CORS 策略
- 使用 CSP 头部
### 3. 数据库安全
- 启用 RLS 策略
- 使用最小权限原则
- 定期更新密码
## 故障排除
### 常见问题
1. **构建失败**
- 检查依赖版本兼容性
- 确认环境变量配置
2. **数据库连接失败**
- 验证 Supabase 配置
- 检查网络连接
3. **API 调用失败**
- 检查 API 密钥
- 验证服务状态
### 调试工具
- 使用浏览器开发者工具
- 检查服务器日志
- 使用 Supabase 日志功能
## 更新和维护
### 1. 依赖更新
```bash
# 检查过期依赖
npm outdated
# 更新依赖
npm update
```
### 2. 安全更新
```bash
# 检查安全漏洞
npm audit
# 修复安全问题
npm audit fix
```
### 3. 定期维护
- 清理日志文件
- 优化数据库
- 更新 SSL 证书
- 检查性能指标
## 扩展部署
### 负载均衡
当流量增加时,可以考虑:
- 使用 CDN 加速静态资源
- 部署多个实例
- 使用负载均衡器
### 微服务架构
对于大型应用,可以考虑:
- 拆分 API 服务
- 使用消息队列
- 实施服务发现
## 支持和帮助
如果在部署过程中遇到问题,可以:
1. 查看项目文档
2. 检查 GitHub Issues
3. 联系技术支持
---
**注意**: 请确保在生产环境中使用强密码和安全的配置。定期更新依赖包和安全补丁。
+183
View File
@@ -0,0 +1,183 @@
# 修复说明文档
## 已解决的问题
### 1. 多个 GoTrueClient 实例问题
**问题描述**: 应用程序中同时使用了多种 Supabase 客户端创建方式,导致浏览器中出现多个 GoTrueClient 实例的警告。
**解决方案**:
- 统一使用单一的 Supabase 客户端实例
- 移除了 `createClientComponentClient` 和重复的客户端创建代码
-`lib/supabase.ts` 中导出统一的 `supabase` 客户端
### 2. 路由跳转和认证状态管理
**问题描述**: 登录后出现 "Abort fetching component" 错误,路由跳转不稳定。
**解决方案**:
- 简化了认证状态监听逻辑
- 统一使用 `auth` 模块中的认证方法
- 改进了路由跳转的时机和方式
- 添加了防重复提交机制
### 3. 代码结构优化
**问题描述**: 代码重复,结构混乱,维护困难。
**解决方案**:
- 重构了 `lib/supabase.ts`,提供了统一的 API
- 创建了 `auth``db``storage``realtime` 等模块化功能
- 添加了错误处理和权限检查功能
- 简化了组件中的 Supabase 调用
## 主要改动文件
### 1. `lib/supabase.ts`
- 统一了 Supabase 客户端创建
- 提供了模块化的功能接口
- 添加了演示模式支持
- 改进了错误处理
### 2. `app/auth/login/page.tsx`
- 使用统一的认证 API
- 改进了用户体验
- 添加了防重复提交
- 优化了错误提示
### 3. `app/dashboard/page.tsx`
- 简化了认证检查逻辑
- 使用统一的 Supabase 客户端
- 改进了加载状态处理
- 添加了用户友好的界面
## 使用说明
### 开发环境设置
1. **配置环境变量**:
```bash
cp .env.example .env.local
```
编辑 `.env.local` 文件,填入你的 Supabase 项目信息:
```env
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
```
2. **启动开发服务器**:
```bash
npm run dev
```
3. **访问应用**:
打开浏览器访问 `http://localhost:3000`
### 演示模式
如果没有配置 Supabase 环境变量,应用会自动进入演示模式:
- 显示模拟数据
- 禁用实际的数据库操作
- 提供完整的界面预览
### 认证流程
1. **注册**: `/auth/register`
2. **登录**: `/auth/login`
3. **仪表板**: `/dashboard` (需要登录)
4. **登出**: 点击仪表板右上角的登出按钮
## API 使用示例
### 认证操作
```typescript
import { auth } from '../lib/supabase';
// 登录
const { user, session } = await auth.signIn(email, password);
// 注册
const { user, session } = await auth.signUp(email, password, userData);
// 获取当前用户
const user = await auth.getCurrentUser();
// 登出
await auth.signOut();
```
### 数据库操作
```typescript
import { db } from '../lib/supabase';
// 查询数据
const users = await db.findMany('users', { status: 'active' });
// 插入数据
const newUser = await db.insert('users', userData);
// 更新数据
const updatedUser = await db.update('users', userId, updates);
// 删除数据
await db.delete('users', userId);
```
### 实时订阅
```typescript
import { realtime } from '../lib/supabase';
// 订阅表变化
const channel = realtime.subscribe('users', (payload) => {
console.log('用户数据变化:', payload);
});
// 取消订阅
realtime.unsubscribe(channel);
```
## 故障排除
### 1. 多个 GoTrueClient 实例警告
这个警告已经通过统一客户端实例解决。如果仍然出现,请检查:
- 是否有其他地方创建了额外的 Supabase 客户端
- 浏览器缓存是否需要清理
### 2. 路由跳转问题
如果登录后仍然出现路由问题:
- 检查浏览器控制台的错误信息
- 确保 Supabase 配置正确
- 尝试清理浏览器缓存和 localStorage
### 3. 认证状态不同步
如果用户状态显示不正确:
- 检查网络连接
- 确认 Supabase 项目状态
- 查看浏览器开发者工具的网络请求
## 下一步开发建议
1. **添加实际的数据库表结构**
2. **实现完整的用户权限系统**
3. **添加更多的业务功能模块**
4. **集成 Twilio API 进行实际的通话功能**
5. **添加文件上传和文档翻译功能**
6. **实现支付和订单管理系统**
## 技术栈
- **前端**: Next.js 14, React 18, TypeScript
- **样式**: Tailwind CSS
- **后端**: Supabase (PostgreSQL + Auth + Storage)
- **部署**: Vercel (推荐)
## 支持
如果遇到问题,请检查:
1. Node.js 版本 (推荐 18+)
2. npm 或 yarn 版本
3. Supabase 项目配置
4. 环境变量设置
---
*最后更新: 2024年12月*
+153 -20
View File
@@ -1,6 +1,6 @@
# 口译服务管理后台 # 口译服务管理后台
一个基于 Next.js 和 TypeScript 构建的现代化口译服务管理后台系统。 一个基于 Next.js 和 TypeScript 构建的现代化口译服务管理后台系统,集成 Supabase 数据库和完整的身份验证功能
## 功能特性 ## 功能特性
@@ -20,6 +20,7 @@
- **订单状态跟踪**:待处理、处理中、已完成、已取消、失败 - **订单状态跟踪**:待处理、处理中、已完成、已取消、失败
- **优先级管理**:紧急、高、普通、低 - **优先级管理**:紧急、高、普通、低
- **详细信息展示**:包括译员信息、时间安排、费用等 - **详细信息展示**:包括译员信息、时间安排、费用等
- **实时数据同步**:基于 Supabase 实时订阅
### 📄 文档管理 ### 📄 文档管理
- **文档上传管理**:支持多种文档格式 - **文档上传管理**:支持多种文档格式
@@ -33,19 +34,22 @@
- **发票状态管理**:草稿、已开具、已付款、已取消 - **发票状态管理**:草稿、已开具、已付款、已取消
### 👥 用户管理 ### 👥 用户管理
- **用户信息管理**:个人用户企业用户 - **多角色用户系统**:个人用户企业用户、管理员
- **用户认证**:基于 Supabase Auth 的安全认证
- **权限控制**:行级安全策略保护数据
- **用户状态跟踪**:活跃状态、登录记录 - **用户状态跟踪**:活跃状态、登录记录
- **用户类型区分**:个人用户、企业用户
### 🎯 译员管理 ### 🎯 译员管理
- **译员信息管理**:译员资料、专业领域 - **译员信息管理**:译员资料、专业领域
- **译员状态监控**:在线、离线、忙碌状态 - **译员状态监控**:在线、离线、忙碌状态
- **语言能力管理**:支持的语言对 - **语言能力管理**:支持的语言对
- **评价系统**:译员评分和反馈
### 📞 通话管理 ### 📞 通话管理
- **实时通话监控**:当前活跃通话 - **实时通话监控**:当前活跃通话
- **通话记录管理**:历史通话记录 - **通话记录管理**:历史通话记录
- **通话质量统计**:通话时长、费用统计 - **通话质量统计**:通话时长、费用统计
- **质量评估**:通话质量评分
### ⚙️ 系统设置 ### ⚙️ 系统设置
- **服务费率配置**:为每种服务设置独立费率 - **服务费率配置**:为每种服务设置独立费率
@@ -55,35 +59,69 @@
## 技术栈 ## 技术栈
### 前端技术
- **前端框架**Next.js 14 - **前端框架**Next.js 14
- **类型系统**TypeScript - **类型系统**TypeScript
- **样式框架**Tailwind CSS - **样式框架**Tailwind CSS
- **图标库**Heroicons - **图标库**Heroicons
- **状态管理**React Hooks - **状态管理**React Hooks
- **数据库**Supabase(可选)
- **部署**Vercel(推荐) ### 后端技术
- **数据库**Supabase (PostgreSQL)
- **身份验证**Supabase Auth
- **API**Next.js API Routes
- **实时功能**Supabase Realtime
### 第三方集成
- **支付处理**Stripe
- **通信服务**Twilio
- **AI服务**OpenAI
- **部署平台**Vercel
## 安装和运行 ## 安装和运行
### 环境要求 ### 环境要求
- Node.js 18.0 或更高版本 - Node.js 18.0 或更高版本
- npm 或 yarn - npm 或 yarn
- Supabase 项目(用于数据库和认证)
### 安装依赖 ### 1. 安装依赖
```bash ```bash
npm install npm install
# 或 # 或
yarn install yarn install
``` ```
### 环境配置 ### 2. 环境配置
复制 `.env.example``.env.local` 并配置必要的环境变量: 复制 `.env.example``.env.local` 并配置必要的环境变量:
```bash ```bash
cp .env.example .env.local cp .env.example .env.local
``` ```
### 开发模式运行 配置 Supabase 和其他服务:
```env
# Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# 其他服务配置
STRIPE_SECRET_KEY=sk_test_...
TWILIO_ACCOUNT_SID=AC...
OPENAI_API_KEY=sk-...
```
### 3. 数据库设置
在 Supabase Dashboard 中执行数据库脚本:
1. 登录 [Supabase Dashboard](https://supabase.com/dashboard)
2. 进入 SQL Editor
3. 复制 `database/schema.sql` 内容并执行
4. 创建管理员账户(可选)
### 4. 开发模式运行
```bash ```bash
npm run dev npm run dev
# 或 # 或
@@ -92,14 +130,14 @@ yarn dev
访问 [http://localhost:3000](http://localhost:3000) 查看应用。 访问 [http://localhost:3000](http://localhost:3000) 查看应用。
### 构建生产版本 ### 5. 构建生产版本
```bash ```bash
npm run build npm run build
# 或 # 或
yarn build yarn build
``` ```
### 启动生产服务器 ### 6. 启动生产服务器
```bash ```bash
npm start npm start
# 或 # 或
@@ -112,26 +150,84 @@ yarn start
├── components/ # 可复用组件 ├── components/ # 可复用组件
├── lib/ # 工具库和配置 ├── lib/ # 工具库和配置
│ ├── demo-data.ts # 演示数据 │ ├── demo-data.ts # 演示数据
│ ├── supabase.ts # Supabase 配置 │ ├── supabase.ts # Supabase 配置和操作
│ └── utils.ts # 工具函数 │ └── utils.ts # 工具函数
├── pages/ # 页面组件 ├── pages/ # 页面组件
│ ├── api/ # API 路由 │ ├── api/ # API 路由
│ │ ├── auth/ # 认证 API
│ │ ├── orders/ # 订单 API
│ │ ├── users/ # 用户 API
│ │ └── ... # 其他 API
│ ├── auth/ # 认证页面 │ ├── auth/ # 认证页面
│ └── dashboard/ # 管理后台页面 │ └── dashboard/ # 管理后台页面
├── database/ # 数据库相关文件
│ ├── schema.sql # 数据库结构
│ └── README.md # 数据库说明
├── types/ # TypeScript 类型定义
│ ├── database.ts # 数据库类型
│ └── auth.ts # 认证类型
├── public/ # 静态资源 ├── public/ # 静态资源
├── styles/ # 样式文件 ├── styles/ # 样式文件
├── types/ # TypeScript 类型定义
└── utils/ # 工具函数 └── utils/ # 工具函数
``` ```
## API 接口
### 认证接口
- `POST /api/auth/register` - 用户注册
- `POST /api/auth/login` - 用户登录
- `POST /api/auth/logout` - 用户登出
- `GET /api/auth/me` - 获取当前用户信息
### 订单接口
- `GET /api/orders` - 获取订单列表
- `POST /api/orders` - 创建新订单
- `PUT /api/orders/:id` - 更新订单
- `DELETE /api/orders/:id` - 删除订单
### 用户接口
- `GET /api/users` - 获取用户列表
- `POST /api/users` - 创建用户
- `PUT /api/users/:id` - 更新用户信息
- `DELETE /api/users/:id` - 删除用户
## 数据库架构
### 核心表结构
- **users** - 用户表(支持个人/企业/管理员)
- **enterprises** - 企业表
- **orders** - 订单表
- **invoices** - 发票表
- **interpreters** - 译员表
- **calls** - 通话记录表
- **documents** - 文档翻译表
### 安全策略
- 启用行级安全 (RLS)
- 基于用户角色的权限控制
- 数据访问审计日志
详细说明请参考 [database/README.md](./database/README.md)
## 核心功能说明 ## 核心功能说明
### 身份验证系统
- **多角色支持**:个人用户、企业用户、管理员
- **安全认证**:基于 JWT 令牌的会话管理
- **权限控制**:细粒度的权限管理
- **会话管理**:自动刷新和过期处理
### 费率优先级机制 ### 费率优先级机制
系统采用三级费率优先级: 系统采用三级费率优先级:
1. **企业合同费率**:优先级最高,适用于企业员工 1. **企业合同费率**:优先级最高,适用于企业员工
2. **系统通用费率**:适用于个人用户和无合同企业 2. **系统通用费率**:适用于个人用户和无合同企业
3. **默认费率**:系统兜底费率 3. **默认费率**:系统兜底费率
### 实时功能
- **订单状态更新**:实时同步订单状态变化
- **通话监控**:实时监控活跃通话
- **消息通知**:即时消息推送
### 演示模式 ### 演示模式
项目支持演示模式,无需配置数据库即可体验完整功能: 项目支持演示模式,无需配置数据库即可体验完整功能:
- 自动检测 Supabase 配置 - 自动检测 Supabase 配置
@@ -151,6 +247,8 @@ yarn start
3. 配置环境变量 3. 配置环境变量
4. 部署完成 4. 部署完成
详细部署指南请参考 [DEPLOYMENT.md](./DEPLOYMENT.md)
### 其他平台部署 ### 其他平台部署
项目支持部署到任何支持 Next.js 的平台,如: 项目支持部署到任何支持 Next.js 的平台,如:
- Netlify - Netlify
@@ -160,15 +258,31 @@ yarn start
## 环境变量 ## 环境变量
### 必需配置
```bash ```bash
# Supabase 配置(可选) # Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# 其他第三方服务配置 # 应用配置
TWILIO_ACCOUNT_SID=your_twilio_sid NEXTAUTH_SECRET=your-nextauth-secret
TWILIO_AUTH_TOKEN=your_twilio_token JWT_SECRET=your-jwt-secret
OPENAI_API_KEY=your_openai_key ```
### 可选配置
```bash
# Stripe 支付
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
# Twilio 通信
TWILIO_ACCOUNT_SID=AC...
TWILIO_API_KEY_SID=SK...
TWILIO_API_KEY_SECRET=...
# OpenAI 服务
OPENAI_API_KEY=sk-...
``` ```
## 贡献指南 ## 贡献指南
@@ -179,17 +293,36 @@ OPENAI_API_KEY=your_openai_key
4. 推送到分支 (`git push origin feature/AmazingFeature`) 4. 推送到分支 (`git push origin feature/AmazingFeature`)
5. 创建 Pull Request 5. 创建 Pull Request
### 开发规范
- 使用 TypeScript 进行类型安全开发
- 遵循 ESLint 和 Prettier 代码规范
- 编写单元测试
- 更新相关文档
## 许可证 ## 许可证
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。 本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
## 联系方式 ## 文档和支持
### 相关文档
- [部署指南](./DEPLOYMENT.md)
- [数据库说明](./database/README.md)
- [API 参考](./docs/api-reference.md)
### 联系方式
- 项目地址:[http://git.wanzhongtech.com/mars/Twilioapp-admin](http://git.wanzhongtech.com/mars/Twilioapp-admin) - 项目地址:[http://git.wanzhongtech.com/mars/Twilioapp-admin](http://git.wanzhongtech.com/mars/Twilioapp-admin)
- 问题反馈:请在 GitLab Issues 中提交 - 问题反馈:请在 GitLab Issues 中提交
## 更新日志 ## 更新日志
### v1.1.0 (2024-01-30)
- ✅ 集成 Supabase 数据库和身份验证
- ✅ 实现完整的 API 接口
- ✅ 添加用户认证和权限控制
- ✅ 优化数据库结构和安全策略
- ✅ 完善部署文档和指南
### v1.0.0 (2024-01-30) ### v1.0.0 (2024-01-30)
- ✅ 完成企业服务管理功能 - ✅ 完成企业服务管理功能
- ✅ 完成订单管理功能 - ✅ 完成订单管理功能
+54
View File
@@ -0,0 +1,54 @@
const { createClient } = require('@supabase/supabase-js');
const supabaseUrl = 'https://poxwjzdianersitpnvdy.supabase.co';
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBveHdqemRpYW5lcnNpdHBudmR5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExNjk4MjMsImV4cCI6MjA2Njc0NTgyM30.FkgCCSHK0_i8bNFIhhN3k6dEbP5PpE52IggcVJC4Aj8';
const supabase = createClient(supabaseUrl, supabaseKey);
async function checkTable() {
try {
console.log('检查users表结构...');
// 尝试查询表中的所有数据
const { data, error } = await supabase
.from('users')
.select('*')
.limit(1);
if (error) {
console.error('查询错误:', error);
} else {
console.log('查询结果:', data);
if (data && data.length > 0) {
console.log('表列名:', Object.keys(data[0]));
} else {
console.log('表为空,尝试插入测试数据...');
// 尝试插入一个简单的用户
const { data: insertData, error: insertError } = await supabase
.from('users')
.insert([
{
email: 'admin@example.com',
name: '系统管理员',
phone: '13800138000',
user_type: 'admin',
status: 'active'
}
])
.select();
if (insertError) {
console.error('插入错误:', insertError);
} else {
console.log('插入成功:', insertData);
}
}
}
} catch (error) {
console.error('操作失败:', error);
}
}
checkTable();
+287
View File
@@ -0,0 +1,287 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Head from 'next/head';
import {
HomeIcon,
UsersIcon,
PhoneIcon,
CalendarIcon,
DocumentTextIcon,
CurrencyDollarIcon,
ChartBarIcon,
CogIcon,
BellIcon,
UserGroupIcon,
ClipboardDocumentListIcon,
Bars3Icon,
XMarkIcon,
BuildingOfficeIcon,
FolderIcon,
DocumentIcon,
ReceiptPercentIcon,
ChevronDownIcon,
ChevronRightIcon,
LanguageIcon,
ArrowRightOnRectangleIcon
} from '@heroicons/react/24/outline';
interface DashboardLayoutProps {
children: React.ReactNode;
title?: string;
}
const navigation = [
{ name: '仪表盘', href: '/dashboard', icon: HomeIcon },
{ name: '用户管理', href: '/dashboard/users', icon: UsersIcon },
{ name: '翻译员管理', href: '/dashboard/interpreters', icon: LanguageIcon },
{
name: '订单管理',
icon: DocumentTextIcon,
children: [
{ name: '订单列表', href: '/dashboard/orders' },
{ name: '发票管理', href: '/dashboard/invoices' }
]
},
{ name: '通话记录', href: '/dashboard/calls', icon: PhoneIcon },
{ name: '企业服务', href: '/dashboard/enterprise', icon: BuildingOfficeIcon },
{ name: '文档管理', href: '/dashboard/documents', icon: FolderIcon },
{ name: '系统设置', href: '/dashboard/settings', icon: CogIcon },
];
export default function DashboardLayout({ children, title = '管理后台' }: DashboardLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isDemoMode, setIsDemoMode] = useState(true); // 始终启用演示模式
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const router = useRouter();
useEffect(() => {
// 演示模式始终启用
setIsDemoMode(true);
}, []);
const handleLogout = () => {
// 清除本地存储并跳转到登录页
localStorage.removeItem('access_token');
localStorage.removeItem('user');
router.push('/auth/login');
};
const toggleExpanded = (itemName: string) => {
setExpandedItems(prev =>
prev.includes(itemName)
? prev.filter(name => name !== itemName)
: [...prev, itemName]
);
};
const isItemActive = (item: any) => {
if (item.href) {
return router.pathname === item.href;
}
if (item.children) {
return item.children.some((child: any) => router.pathname === child.href);
}
return false;
};
const renderNavItem = (item: any) => {
const hasChildren = item.children && item.children.length > 0;
const isActive = isItemActive(item);
const isExpanded = expandedItems.includes(item.name);
if (hasChildren) {
return (
<div key={item.name}>
<button
onClick={() => toggleExpanded(item.name)}
className={`${
isActive
? 'bg-blue-100 text-blue-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center w-full px-2 py-2 text-sm font-medium rounded-md`}
>
<item.icon
className={`${
isActive ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
} mr-3 flex-shrink-0 h-6 w-6`}
/>
{item.name}
{isExpanded ? (
<ChevronDownIcon className="ml-auto h-4 w-4" />
) : (
<ChevronRightIcon className="ml-auto h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="ml-8 mt-1 space-y-1">
{item.children.map((child: any) => (
<Link
key={child.name}
href={child.href}
className={`${
router.pathname === child.href
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-500'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
>
{child.name}
</Link>
))}
</div>
)}
</div>
);
}
return (
<Link
key={item.name}
href={item.href}
className={`${
isActive
? 'bg-blue-100 text-blue-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
>
<item.icon
className={`${
isActive ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
} mr-3 flex-shrink-0 h-6 w-6`}
/>
{item.name}
</Link>
);
};
return (
<>
<Head>
<title>{title} - </title>
<meta name="description" content="口译服务管理平台管理后台" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="h-screen flex overflow-hidden bg-gray-100">
{/* 移动端侧边栏 */}
<div className={`fixed inset-0 flex z-40 md:hidden ${sidebarOpen ? '' : 'hidden'}`}>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
<div className="relative flex-1 flex flex-col max-w-xs w-full pt-5 pb-4 bg-white">
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<XMarkIcon className="h-6 w-6 text-white" />
</button>
</div>
<div className="flex-shrink-0 flex items-center px-4">
<h1 className="text-xl font-bold text-gray-900"></h1>
{isDemoMode && (
<span className="ml-2 px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
</span>
)}
</div>
<div className="mt-5 flex-1 h-0 overflow-y-auto">
<nav className="px-2 space-y-1">
{navigation.map(renderNavItem)}
</nav>
</div>
<div className="flex-shrink-0 p-4 border-t border-gray-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center">
<span className="text-sm font-medium text-white"></span>
</div>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-700"></p>
<p className="text-xs text-gray-500">admin@demo.com</p>
</div>
</div>
<button
onClick={handleLogout}
className="mt-3 w-full bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-2 rounded-md text-sm font-medium flex items-center justify-center"
>
<ArrowRightOnRectangleIcon className="h-4 w-4 mr-2" />
退
</button>
</div>
</div>
</div>
{/* 桌面端侧边栏 */}
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1">
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-white border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900"></h1>
{isDemoMode && (
<span className="ml-2 px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
</span>
)}
</div>
<div className="flex-1 flex flex-col overflow-y-auto bg-white border-r border-gray-200">
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map(renderNavItem)}
</nav>
<div className="flex-shrink-0 p-4 border-t border-gray-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center">
<span className="text-sm font-medium text-white"></span>
</div>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-700"></p>
<p className="text-xs text-gray-500">admin@demo.com</p>
</div>
</div>
<button
onClick={handleLogout}
className="mt-3 w-full bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-2 rounded-md text-sm font-medium flex items-center justify-center"
>
<ArrowRightOnRectangleIcon className="h-4 w-4 mr-2" />
退
</button>
</div>
</div>
</div>
</div>
</div>
{/* 主内容区域 */}
<div className="flex flex-col w-0 flex-1 overflow-hidden">
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white shadow md:hidden">
<button
type="button"
className="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 md:hidden"
onClick={() => setSidebarOpen(true)}
>
<Bars3Icon className="h-6 w-6" />
</button>
<div className="flex-1 px-4 flex justify-between items-center">
<h1 className="text-lg font-semibold text-gray-900">{title}</h1>
{isDemoMode && (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
</span>
)}
</div>
</div>
<main className="flex-1 relative overflow-y-auto focus:outline-none">
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{children}
</div>
</div>
</main>
</div>
</div>
</>
);
}
+32
View File
@@ -0,0 +1,32 @@
const bcrypt = require('bcryptjs');
async function createAdminHash() {
const password = 'admin123';
const saltRounds = 10;
try {
const hash = await bcrypt.hash(password, saltRounds);
console.log('管理员密码哈希:', hash);
// 生成插入SQL
const sql = `
INSERT INTO users (email, password_hash, name, phone, user_type, status, created_at, updated_at)
VALUES (
'admin@example.com',
'${hash}',
'系统管理员',
'13800138000',
'admin',
'active',
NOW(),
NOW()
);`;
console.log('\n插入SQL:');
console.log(sql);
} catch (error) {
console.error('生成哈希失败:', error);
}
}
createAdminHash();
+179
View File
@@ -0,0 +1,179 @@
# 数据库设置说明
## 概述
本项目使用 Supabase 作为后端数据库服务,提供 PostgreSQL 数据库、身份验证、实时订阅等功能。
## 数据库配置步骤
### 1. 创建 Supabase 项目
1. 访问 [Supabase Dashboard](https://supabase.com/dashboard)
2. 创建新项目
3. 记录项目的 URL 和 API 密钥
### 2. 执行数据库脚本
1. 在 Supabase Dashboard 中,进入 SQL Editor
2.`schema.sql` 文件的内容复制粘贴到编辑器中
3. 点击 "Run" 执行脚本
### 3. 配置环境变量
在项目根目录的 `.env.local` 文件中配置以下变量:
```env
# Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
```
## 数据库表结构
### 核心表
1. **users** - 用户表
- 存储用户基本信息
- 支持个人用户、企业用户、管理员三种类型
2. **enterprises** - 企业表
- 存储企业客户信息
- 与用户表关联
3. **orders** - 订单表
- 存储口译服务订单
- 关联用户和译员
4. **invoices** - 发票表
- 存储发票信息
- 支持个人和企业发票
5. **interpreters** - 译员表
- 存储译员信息和能力
- 支持多语言和专业领域
6. **calls** - 通话记录表
- 存储实际通话记录
- 用于统计和质量评估
7. **documents** - 文档翻译表
- 存储文档翻译任务
- 跟踪翻译进度
### 辅助表
1. **enterprise_contracts** - 企业合同表
2. **enterprise_bills** - 企业账单表
3. **system_settings** - 系统设置表
## 安全策略
### 行级安全 (RLS)
所有表都启用了行级安全策略:
- **管理员权限**:管理员可以访问所有数据
- **用户权限**:普通用户只能访问自己的数据
- **企业权限**:企业用户可以访问所属企业的数据
### 身份验证
使用 Supabase Auth 进行用户身份验证:
- 支持邮箱密码注册/登录
- 支持社交登录(可选)
- JWT 令牌验证
- 会话管理
## 数据库索引
为提高查询性能,已创建以下索引:
- 用户邮箱索引
- 用户类型索引
- 订单状态索引
- 订单创建时间索引
- 其他常用查询字段索引
## 触发器
自动更新 `updated_at` 字段的触发器已为所有表配置。
## 初始数据
系统会自动插入以下初始设置:
- 应用基本配置
- 支持的语言列表
- 默认货币设置
- 税率配置
## 备份和恢复
Supabase 提供自动备份功能,建议:
1. 定期检查备份状态
2. 测试恢复流程
3. 导出重要数据作为额外备份
## 监控和维护
建议定期执行以下维护任务:
1. 检查数据库性能
2. 清理过期数据
3. 更新统计信息
4. 监控存储使用情况
## 故障排除
### 常见问题
1. **连接失败**
- 检查环境变量配置
- 确认 Supabase 项目状态
2. **权限错误**
- 检查 RLS 策略
- 确认用户角色设置
3. **查询性能问题**
- 检查索引使用情况
- 优化查询语句
### 调试工具
- Supabase Dashboard 中的 SQL Editor
- 实时日志监控
- 性能分析工具
## 扩展功能
### 实时订阅
项目支持实时数据订阅,可以监听:
- 新订单创建
- 订单状态变更
- 通话状态更新
### 全文搜索
可以启用 PostgreSQL 的全文搜索功能来搜索:
- 用户信息
- 订单内容
- 文档内容
### 地理位置
如需要地理位置功能,可以启用 PostGIS 扩展。
## 开发建议
1. 使用类型安全的查询构建器
2. 实施适当的错误处理
3. 使用事务处理复杂操作
4. 定期更新依赖包
5. 遵循数据库最佳实践
+288
View File
@@ -0,0 +1,288 @@
-- 口译服务管理平台数据库表结构
-- 请在Supabase SQL编辑器中执行此脚本
-- 启用必要的扩展
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- 企业表(需要先创建,因为users表引用它)
CREATE TABLE IF NOT EXISTS enterprises (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(200) NOT NULL,
contact_person VARCHAR(100),
contact_email VARCHAR(255),
contact_phone VARCHAR(20),
address TEXT,
tax_number VARCHAR(50),
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'suspended')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 译员表(需要先创建,因为orders表引用它)
CREATE TABLE IF NOT EXISTS interpreters (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
phone VARCHAR(20),
languages TEXT[] NOT NULL,
specialties TEXT[],
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'busy')),
rating DECIMAL(3,2) DEFAULT 0 CHECK (rating >= 0 AND rating <= 5),
total_calls INTEGER DEFAULT 0,
hourly_rate DECIMAL(8,2),
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
avatar_url TEXT,
bio TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
user_type VARCHAR(20) NOT NULL DEFAULT 'individual' CHECK (user_type IN ('individual', 'enterprise', 'admin')),
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive', 'suspended')),
enterprise_id UUID REFERENCES enterprises(id),
avatar_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 企业合同表
CREATE TABLE IF NOT EXISTS enterprise_contracts (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
enterprise_id UUID NOT NULL REFERENCES enterprises(id) ON DELETE CASCADE,
contract_number VARCHAR(50) UNIQUE NOT NULL,
contract_type VARCHAR(50) NOT NULL,
start_date DATE NOT NULL,
end_date DATE NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'expired', 'terminated')),
service_rates JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 企业账单表
CREATE TABLE IF NOT EXISTS enterprise_bills (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
enterprise_id UUID NOT NULL REFERENCES enterprises(id) ON DELETE CASCADE,
bill_number VARCHAR(50) UNIQUE NOT NULL,
billing_period_start DATE NOT NULL,
billing_period_end DATE NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'paid', 'overdue', 'cancelled')),
items JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 订单表
CREATE TABLE IF NOT EXISTS orders (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
order_number VARCHAR(50) UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_name VARCHAR(100) NOT NULL,
user_email VARCHAR(255) NOT NULL,
service_type VARCHAR(50) NOT NULL,
service_name VARCHAR(100) NOT NULL,
source_language VARCHAR(50) NOT NULL,
target_language VARCHAR(50) NOT NULL,
duration INTEGER, -- 分钟
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'in_progress', 'completed', 'cancelled')),
priority VARCHAR(10) NOT NULL DEFAULT 'normal' CHECK (priority IN ('low', 'normal', 'high', 'urgent')),
cost DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
scheduled_time TIMESTAMP WITH TIME ZONE,
started_time TIMESTAMP WITH TIME ZONE,
completed_time TIMESTAMP WITH TIME ZONE,
interpreter_id UUID REFERENCES interpreters(id),
interpreter_name VARCHAR(100),
notes TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 发票表
CREATE TABLE IF NOT EXISTS invoices (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
invoice_number VARCHAR(50) UNIQUE NOT NULL,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
user_name VARCHAR(100) NOT NULL,
user_email VARCHAR(255) NOT NULL,
order_id UUID REFERENCES orders(id),
invoice_type VARCHAR(20) NOT NULL CHECK (invoice_type IN ('personal', 'company')),
personal_name VARCHAR(100),
company_name VARCHAR(200),
tax_number VARCHAR(50),
company_address TEXT,
subtotal DECIMAL(10,2) NOT NULL,
tax_amount DECIMAL(10,2) NOT NULL DEFAULT 0,
total_amount DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'issued', 'sent', 'paid', 'cancelled')),
issue_date DATE,
due_date DATE,
paid_date DATE,
items JSONB,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 通话记录表
CREATE TABLE IF NOT EXISTS calls (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
interpreter_id UUID NOT NULL REFERENCES interpreters(id) ON DELETE CASCADE,
service_type VARCHAR(50) NOT NULL,
source_language VARCHAR(50) NOT NULL,
target_language VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'active', 'completed', 'cancelled')),
duration INTEGER DEFAULT 0, -- 秒
cost DECIMAL(10,2) DEFAULT 0,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
quality_rating INTEGER CHECK (quality_rating >= 1 AND quality_rating <= 5),
started_at TIMESTAMP WITH TIME ZONE,
ended_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 文档翻译表
CREATE TABLE IF NOT EXISTS documents (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
file_type VARCHAR(50) NOT NULL,
source_language VARCHAR(50) NOT NULL,
target_language VARCHAR(50) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed')),
progress INTEGER DEFAULT 0 CHECK (progress >= 0 AND progress <= 100),
cost DECIMAL(10,2) NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'CNY',
translated_file_url TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 系统设置表
CREATE TABLE IF NOT EXISTS system_settings (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
key VARCHAR(100) UNIQUE NOT NULL,
value TEXT NOT NULL,
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
-- 创建索引
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
CREATE INDEX IF NOT EXISTS idx_users_user_type ON users(user_type);
CREATE INDEX IF NOT EXISTS idx_users_enterprise_id ON users(enterprise_id);
CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id);
CREATE INDEX IF NOT EXISTS idx_orders_status ON orders(status);
CREATE INDEX IF NOT EXISTS idx_orders_created_at ON orders(created_at);
CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id);
CREATE INDEX IF NOT EXISTS idx_invoices_order_id ON invoices(order_id);
CREATE INDEX IF NOT EXISTS idx_calls_user_id ON calls(user_id);
CREATE INDEX IF NOT EXISTS idx_calls_interpreter_id ON calls(interpreter_id);
CREATE INDEX IF NOT EXISTS idx_documents_user_id ON documents(user_id);
-- 创建更新时间触发器函数
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ language 'plpgsql';
-- 为所有表添加更新时间触发器
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_enterprises_updated_at BEFORE UPDATE ON enterprises
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_enterprise_contracts_updated_at BEFORE UPDATE ON enterprise_contracts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_enterprise_bills_updated_at BEFORE UPDATE ON enterprise_bills
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_orders_updated_at BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_invoices_updated_at BEFORE UPDATE ON invoices
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_interpreters_updated_at BEFORE UPDATE ON interpreters
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_calls_updated_at BEFORE UPDATE ON calls
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_documents_updated_at BEFORE UPDATE ON documents
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_system_settings_updated_at BEFORE UPDATE ON system_settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- 启用行级安全策略 (RLS)
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE enterprises ENABLE ROW LEVEL SECURITY;
ALTER TABLE enterprise_contracts ENABLE ROW LEVEL SECURITY;
ALTER TABLE enterprise_bills ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE interpreters ENABLE ROW LEVEL SECURITY;
ALTER TABLE calls ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE system_settings ENABLE ROW LEVEL SECURITY;
-- 创建基本的RLS策略(管理员可以访问所有数据)
CREATE POLICY "管理员可以访问所有用户数据" ON users
FOR ALL USING (
EXISTS (
SELECT 1 FROM users WHERE id = auth.uid() AND user_type = 'admin'
)
);
CREATE POLICY "用户可以访问自己的数据" ON users
FOR ALL USING (id = auth.uid());
-- 为其他表创建类似的策略
CREATE POLICY "管理员可以访问所有企业数据" ON enterprises
FOR ALL USING (
EXISTS (
SELECT 1 FROM users WHERE id = auth.uid() AND user_type = 'admin'
)
);
CREATE POLICY "管理员可以访问所有订单数据" ON orders
FOR ALL USING (
EXISTS (
SELECT 1 FROM users WHERE id = auth.uid() AND user_type = 'admin'
)
);
CREATE POLICY "用户可以访问自己的订单" ON orders
FOR ALL USING (user_id = auth.uid());
-- 插入一些系统设置
INSERT INTO system_settings (key, value, description) VALUES
('app_name', '口译服务管理平台', '应用程序名称'),
('app_version', '1.0.0', '应用程序版本'),
('maintenance_mode', 'false', '维护模式开关'),
('max_file_size', '10485760', '最大文件上传大小(字节)'),
('supported_languages', '["中文", "英文", "日文", "韩文", "法文", "德文", "西班牙文", "俄文"]', '支持的语言列表'),
('default_currency', 'CNY', '默认货币'),
('tax_rate', '0.13', '税率')
ON CONFLICT (key) DO NOTHING;
+56
View File
@@ -0,0 +1,56 @@
const { createClient } = require('@supabase/supabase-js');
// 使用正确的Supabase配置
const supabaseUrl = 'https://poxwjzdianersitpnvdy.supabase.co';
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBveHdqemRpYW5lcnNpdHBudmR5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExNjk4MjMsImV4cCI6MjA2Njc0NTgyM30.FkgCCSHK0_i8bNFIhhN3k6dEbP5PpE52IggcVJC4Aj8';
const supabase = createClient(supabaseUrl, supabaseKey);
async function insertAdmin() {
try {
console.log('开始插入管理员用户...');
// 先检查是否已存在
const { data: existingUser, error: checkError } = await supabase
.from('users')
.select('*')
.eq('email', 'admin@example.com')
.single();
if (existingUser) {
console.log('管理员用户已存在:', existingUser);
return;
}
if (checkError && checkError.code !== 'PGRST116') {
console.error('检查用户时出错:', checkError);
return;
}
// 插入管理员用户
const { data, error } = await supabase
.from('users')
.insert([
{
email: 'admin@example.com',
password_hash: '$2b$10$pYwS7Kfb2VtzApuEmtcz2uhjY.Mqd0hEjgb1D5F3/wqZbOQlh0O6u', // admin123的哈希
name: '系统管理员',
phone: '13800138000',
user_type: 'admin',
status: 'active'
}
])
.select();
if (error) {
console.error('插入失败:', error);
} else {
console.log('管理员用户插入成功:', data);
}
} catch (error) {
console.error('操作失败:', error);
}
}
insertAdmin();
+323
View File
@@ -0,0 +1,323 @@
import { supabase } from './supabase';
// 用户相关接口
export interface User {
id: string;
name: string;
email: string;
phone: string;
userType: 'individual' | 'enterprise' | 'admin';
status: 'active' | 'inactive' | 'pending';
createdAt: string;
lastLogin?: string;
avatar?: string;
company?: string;
totalOrders: number;
}
// 仪表板统计数据接口
export interface DashboardStats {
totalUsers: number;
totalOrders: number;
totalRevenue: number;
activeInterpreters: number;
todayOrders: number;
pendingOrders: number;
completedOrders: number;
totalCalls: number;
}
// 最近活动接口
export interface RecentActivity {
id: string;
type: 'order' | 'call' | 'user' | 'payment';
title: string;
description: string;
time: string;
status: 'success' | 'pending' | 'warning' | 'error';
}
// API响应接口
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
// 用户过滤参数
export interface UserFilters {
search?: string;
userType?: 'all' | 'individual' | 'enterprise' | 'admin';
status?: 'all' | 'active' | 'inactive' | 'pending';
page?: number;
limit?: number;
}
class ApiService {
private baseUrl = '/api';
// 通用请求方法
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
try {
const token = localStorage.getItem('access_token');
const response = await fetch(`${this.baseUrl}${endpoint}`, {
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return data;
} catch (error) {
console.error(`API request failed: ${endpoint}`, error);
return {
success: false,
error: error instanceof Error ? error.message : '请求失败',
};
}
}
// 获取仪表板统计数据
async getDashboardStats(): Promise<ApiResponse<DashboardStats>> {
// 先尝试从真实API获取数据
const response = await this.request<DashboardStats>('/dashboard/stats');
// 如果API失败,返回模拟数据
if (!response.success) {
return {
success: true,
data: {
totalUsers: 1248,
totalOrders: 3567,
totalRevenue: 245680,
activeInterpreters: 45,
todayOrders: 23,
pendingOrders: 12,
completedOrders: 3544,
totalCalls: 2890,
},
};
}
return response;
}
// 获取最近活动
async getRecentActivities(): Promise<ApiResponse<RecentActivity[]>> {
const response = await this.request<RecentActivity[]>('/dashboard/activities');
if (!response.success) {
return {
success: true,
data: [
{
id: '1',
type: 'order',
title: '新订单创建',
description: '用户张三创建了文档翻译订单',
time: '2分钟前',
status: 'success'
},
{
id: '2',
type: 'call',
title: '通话服务完成',
description: '英语口译通话服务已完成',
time: '5分钟前',
status: 'success'
},
{
id: '3',
type: 'user',
title: '新用户注册',
description: '企业用户ABC公司完成注册',
time: '10分钟前',
status: 'success'
},
{
id: '4',
type: 'payment',
title: '付款待处理',
description: '订单#1234的付款需要审核',
time: '15分钟前',
status: 'warning'
},
{
id: '5',
type: 'order',
title: '订单状态更新',
description: '文档翻译订单已交付',
time: '20分钟前',
status: 'success'
}
],
};
}
return response;
}
// 获取用户列表
async getUsers(filters: UserFilters = {}): Promise<ApiResponse<{ users: User[]; total: number }>> {
const queryParams = new URLSearchParams();
if (filters.search) queryParams.append('search', filters.search);
if (filters.userType && filters.userType !== 'all') queryParams.append('userType', filters.userType);
if (filters.status && filters.status !== 'all') queryParams.append('status', filters.status);
if (filters.page) queryParams.append('page', filters.page.toString());
if (filters.limit) queryParams.append('limit', filters.limit.toString());
const response = await this.request<{ users: User[]; total: number }>(
`/users?${queryParams.toString()}`
);
if (!response.success) {
// 返回模拟数据
const mockUsers: User[] = [
{
id: '1',
name: '张三',
email: 'zhangsan@example.com',
phone: '13800138001',
userType: 'individual',
status: 'active',
createdAt: '2024-01-15',
lastLogin: '2024-01-20 10:30',
totalOrders: 5
},
{
id: '2',
name: 'ABC公司',
email: 'contact@abc.com',
phone: '400-123-4567',
userType: 'enterprise',
status: 'active',
createdAt: '2024-01-10',
lastLogin: '2024-01-19 15:45',
company: 'ABC科技有限公司',
totalOrders: 23
},
{
id: '3',
name: '李四',
email: 'lisi@example.com',
phone: '13900139002',
userType: 'individual',
status: 'pending',
createdAt: '2024-01-18',
totalOrders: 0
},
{
id: '4',
name: '王五',
email: 'wangwu@example.com',
phone: '13700137003',
userType: 'individual',
status: 'inactive',
createdAt: '2024-01-12',
lastLogin: '2024-01-16 09:15',
totalOrders: 2
},
{
id: '5',
name: '管理员',
email: 'admin@system.com',
phone: '13600136004',
userType: 'admin',
status: 'active',
createdAt: '2024-01-01',
lastLogin: '2024-01-20 08:00',
totalOrders: 0
}
];
// 应用过滤器
let filteredUsers = mockUsers;
if (filters.search) {
const searchTerm = filters.search.toLowerCase();
filteredUsers = filteredUsers.filter(user =>
user.name.toLowerCase().includes(searchTerm) ||
user.email.toLowerCase().includes(searchTerm) ||
user.phone.includes(searchTerm)
);
}
if (filters.userType && filters.userType !== 'all') {
filteredUsers = filteredUsers.filter(user => user.userType === filters.userType);
}
if (filters.status && filters.status !== 'all') {
filteredUsers = filteredUsers.filter(user => user.status === filters.status);
}
return {
success: true,
data: {
users: filteredUsers,
total: filteredUsers.length
}
};
}
return response;
}
// 创建用户
async createUser(userData: Partial<User>): Promise<ApiResponse<User>> {
return this.request<User>('/users', {
method: 'POST',
body: JSON.stringify(userData),
});
}
// 更新用户
async updateUser(userId: string, userData: Partial<User>): Promise<ApiResponse<User>> {
return this.request<User>(`/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(userData),
});
}
// 删除用户
async deleteUser(userId: string): Promise<ApiResponse<void>> {
return this.request<void>(`/users/${userId}`, {
method: 'DELETE',
});
}
// 批量删除用户
async deleteUsers(userIds: string[]): Promise<ApiResponse<void>> {
return this.request<void>('/users/batch-delete', {
method: 'POST',
body: JSON.stringify({ userIds }),
});
}
// 获取用户详情
async getUserDetail(userId: string): Promise<ApiResponse<User>> {
return this.request<User>(`/users/${userId}`);
}
// 检查服务状态
async checkServiceStatus(): Promise<ApiResponse<{ status: string; timestamp: string }>> {
return this.request<{ status: string; timestamp: string }>('/health');
}
}
// 导出单例实例
export const apiService = new ApiService();
// 导出默认实例
export default apiService;
+118
View File
@@ -0,0 +1,118 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { auth, db } from './supabase'
import { Database } from '../types/database'
// 用户类型定义
export type User = Database['public']['Tables']['users']['Row']
// API响应类型
export interface ApiResponse<T = any> {
success: boolean
data?: T
error?: string
message?: string
}
// 认证中间件
export async function authenticateUser(req: NextApiRequest, res: NextApiResponse) {
try {
const user = await auth.getCurrentUser()
if (!user) {
res.status(401).json({
success: false,
error: '未登录'
})
return null
}
return user
} catch (error) {
console.error('Authentication error:', error)
res.status(401).json({
success: false,
error: '认证失败'
})
return null
}
}
// 获取用户详细信息
export async function getUserProfile(userId: string): Promise<User | null> {
try {
const users = await db.select<User>('users', '*')
return users.find(user => user.id === userId) || null
} catch (error) {
console.error('Get user profile error:', error)
return null
}
}
// 错误处理函数
export function handleApiError(res: NextApiResponse, error: unknown, context: string) {
console.error(`${context} error:`, error)
const errorMessage = error instanceof Error ? error.message : '未知错误'
// 处理Supabase特定错误
if (errorMessage.includes('Invalid login credentials')) {
return res.status(401).json({
success: false,
error: '邮箱或密码错误'
})
} else if (errorMessage.includes('Email not confirmed')) {
return res.status(401).json({
success: false,
error: '请先验证邮箱'
})
} else if (errorMessage.includes('Too many requests')) {
return res.status(429).json({
success: false,
error: '请求过于频繁,请稍后再试'
})
} else if (errorMessage.includes('User already registered')) {
return res.status(400).json({
success: false,
error: '该邮箱已被注册'
})
}
return res.status(500).json({
success: false,
error: '服务器内部错误',
details: process.env.NODE_ENV === 'development' ? errorMessage : undefined
})
}
// 验证邮箱格式
export function validateEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
// 验证密码强度
export function validatePassword(password: string): { valid: boolean; message?: string } {
if (password.length < 6) {
return { valid: false, message: '密码长度至少为6位' }
}
return { valid: true }
}
// 生成订单号
export function generateOrderNumber(): string {
return `ORD-${new Date().getFullYear()}-${String(Date.now()).slice(-6)}`
}
// 计算服务费用
export function calculateServiceCost(serviceType: string, duration?: number): number {
switch (serviceType) {
case 'phone_interpretation':
return (duration || 30) * 3 // 每分钟3元
case 'video_interpretation':
return (duration || 30) * 4 // 每分钟4元
case 'on_site_interpretation':
return (duration || 60) * 5 // 每分钟5元
case 'document_translation':
return 100 // 固定100元
default:
return 0
}
}
+155 -85
View File
@@ -1,5 +1,5 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs'; import { Database } from '../types/database';
// 环境变量检查和默认值 // 环境变量检查和默认值
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supabase.co'; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supabase.co';
@@ -9,7 +9,7 @@ const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'demo-servic
// 检查是否在开发环境中使用默认配置 // 检查是否在开发环境中使用默认配置
const isDemoMode = supabaseUrl === 'https://demo.supabase.co'; const isDemoMode = supabaseUrl === 'https://demo.supabase.co';
// 客户端使用的 Supabase 客户端 // 单一的 Supabase 客户端实例
export const supabase = isDemoMode export const supabase = isDemoMode
? createClient(supabaseUrl, supabaseAnonKey, { ? createClient(supabaseUrl, supabaseAnonKey, {
realtime: { realtime: {
@@ -22,23 +22,13 @@ export const supabase = isDemoMode
autoRefreshToken: false, autoRefreshToken: false,
}, },
}) })
: createClient(supabaseUrl, supabaseAnonKey); : createClient<Database>(supabaseUrl, supabaseAnonKey, {
// 组件中使用的 Supabase 客户端
export const createSupabaseClient = () => {
if (isDemoMode) {
// 在演示模式下返回一个模拟客户端
return {
auth: { auth: {
getUser: () => Promise.resolve({ data: { user: null }, error: null }), autoRefreshToken: true,
signInWithPassword: () => Promise.resolve({ data: null, error: { message: '演示模式:请配置 Supabase 环境变量' } }), persistSession: true,
signOut: () => Promise.resolve({ error: null }), detectSessionInUrl: true
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
} }
} as any; });
}
return createClientComponentClient();
};
// 服务端使用的 Supabase 客户端(具有管理员权限) // 服务端使用的 Supabase 客户端(具有管理员权限)
export const supabaseAdmin = isDemoMode export const supabaseAdmin = isDemoMode
@@ -56,6 +46,7 @@ export const supabaseAdmin = isDemoMode
: createClient(supabaseUrl, supabaseServiceKey, { : createClient(supabaseUrl, supabaseServiceKey, {
auth: { auth: {
autoRefreshToken: false, autoRefreshToken: false,
persistSession: false,
}, },
}); });
@@ -102,12 +93,12 @@ export const auth = {
}, },
// 注册 // 注册
signUp: async (email: string, password: string, metadata?: any) => { signUp: async (email: string, password: string, userData?: any) => {
const { data, error } = await supabase.auth.signUp({ const { data, error } = await supabase.auth.signUp({
email, email,
password, password,
options: { options: {
data: metadata, data: userData,
}, },
}); });
if (error) throw error; if (error) throw error;
@@ -137,6 +128,20 @@ export const auth = {
if (error) throw error; if (error) throw error;
return data; return data;
}, },
// 获取当前会话
getSession: async () => {
const { data: { session }, error } = await supabase.auth.getSession();
if (error) throw error;
return session;
},
// 更新用户信息
updateUser: async (updates: any) => {
const { data, error } = await supabase.auth.updateUser(updates);
if (error) throw error;
return data;
},
}; };
// 数据库操作辅助函数 // 数据库操作辅助函数
@@ -181,56 +186,96 @@ export const db = {
if (error) throw error; if (error) throw error;
}, },
// 分页查询函数 // 根据条件查询单条记录
paginate: async <T>( findOne: async <T>(table: string, conditions: Record<string, any>, select?: string) => {
table: string, let query = supabase.from(table).select(select || '*');
page: number = 1,
limit: number = 10,
query?: any,
orderBy?: { column: string; ascending?: boolean }
) => {
const from = (page - 1) * limit;
const to = from + limit - 1;
let queryBuilder = supabase Object.entries(conditions).forEach(([key, value]) => {
.from(table) query = query.eq(key, value);
.select(query || '*', { count: 'exact' }) });
.range(from, to);
if (orderBy) { const { data, error } = await query.single();
queryBuilder = queryBuilder.order(orderBy.column, { if (error) throw error;
ascending: orderBy.ascending ?? true, return data as T;
},
// 根据条件查询多条记录
findMany: async <T>(table: string, conditions?: Record<string, any>, select?: string) => {
let query = supabase.from(table).select(select || '*');
if (conditions) {
Object.entries(conditions).forEach(([key, value]) => {
query = query.eq(key, value);
}); });
} }
const { data, error, count } = await queryBuilder; const { data, error } = await query;
if (error) throw error; if (error) throw error;
return data as T[];
},
return { // 计数查询
data: data as T[], count: async (table: string, conditions?: Record<string, any>) => {
total: count || 0, let query = supabase.from(table).select('*', { count: 'exact', head: true });
page,
limit, if (conditions) {
has_more: (count || 0) > page * limit, Object.entries(conditions).forEach(([key, value]) => {
}; query = query.eq(key, value);
});
}
const { count, error } = await query;
if (error) throw error;
return count || 0;
}, },
}; };
// 文件上传函数 // 实时订阅管理
export const realtime = {
subscribe: (table: string, callback: (payload: any) => void) => {
const channel = supabase
.channel(`${table}_changes`)
.on('postgres_changes',
{
event: '*',
schema: 'public',
table: table
},
callback
)
.subscribe();
return channel;
},
unsubscribe: (channel: any) => {
if (channel) {
supabase.removeChannel(channel);
}
},
};
// 文件上传相关函数
export const storage = { export const storage = {
// 上传文件 // 上传文件
upload: async (bucket: string, path: string, file: File) => { upload: async (bucket: string, path: string, file: File) => {
const { data, error } = await supabase.storage const { data, error } = await supabase.storage
.from(bucket) .from(bucket)
.upload(path, file, { .upload(path, file);
cacheControl: '3600',
upsert: false,
});
if (error) throw error; if (error) throw error;
return data; return data;
}, },
// 获取文件公共URL // 下载文件
download: async (bucket: string, path: string) => {
const { data, error } = await supabase.storage
.from(bucket)
.download(path);
if (error) throw error;
return data;
},
// 获取公共URL
getPublicUrl: (bucket: string, path: string) => { getPublicUrl: (bucket: string, path: string) => {
const { data } = supabase.storage const { data } = supabase.storage
.from(bucket) .from(bucket)
@@ -248,43 +293,68 @@ export const storage = {
}, },
}; };
// 实时订阅函数 // 用户类型定义
export const realtime = { export type UserRole = 'admin' | 'interpreter' | 'client' | 'enterprise';
// 订阅表变化
subscribe: ( // 用户权限检查
table: string, export const permissions = {
callback: (payload: any) => void, // 检查用户是否有特定权限
filter?: string hasPermission: (userRole: UserRole, requiredRole: UserRole) => {
) => { const roleHierarchy: Record<UserRole, number> = {
if (isDemoMode) { 'client': 1,
// 演示模式下返回模拟的订阅对象 'interpreter': 2,
return { 'enterprise': 3,
unsubscribe: () => {}, 'admin': 4,
}; };
}
const channel = supabase return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
.channel(`${table}-changes`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table,
filter,
},
callback
)
.subscribe();
return channel;
}, },
// 取消订阅 // 检查用户是否为管理员
unsubscribe: (channel: any) => { isAdmin: (userRole: UserRole) => userRole === 'admin',
if (isDemoMode) {
return; // 检查用户是否为翻译员
} isInterpreter: (userRole: UserRole) => userRole === 'interpreter',
supabase.removeChannel(channel);
}, // 检查用户是否为企业用户
isEnterprise: (userRole: UserRole) => userRole === 'enterprise',
}; };
// 错误处理
export const handleSupabaseError = (error: any) => {
console.error('Supabase Error:', error);
// 根据错误类型返回用户友好的消息
if (error.code === 'PGRST116') {
return '未找到记录';
} else if (error.code === '23505') {
return '数据已存在';
} else if (error.code === '23503') {
return '数据关联错误';
} else if (error.message?.includes('JWT')) {
return '登录已过期,请重新登录';
} else if (error.message?.includes('permission')) {
return '权限不足';
} else {
return error.message || '操作失败,请稍后重试';
}
};
// 检查 Supabase 是否正确配置
export const isSupabaseConfigured = () => {
return !isDemoMode && supabaseUrl !== 'https://demo.supabase.co' && supabaseAnonKey !== 'demo-key';
};
// 获取配置状态
export const getConfigStatus = () => {
return {
isDemoMode,
isConfigured: isSupabaseConfigured(),
url: supabaseUrl,
hasAnonKey: supabaseAnonKey !== 'demo-key',
hasServiceKey: supabaseServiceKey !== 'demo-service-key',
};
};
// 默认导出
export default supabase;
+180
View File
@@ -0,0 +1,180 @@
/**
* 格式化时间
* @param dateString - ISO 时间字符串
* @returns 格式化后的时间字符串
*/
export const formatTime = (dateString: string): string => {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
if (diffInSeconds < 60) {
return '刚刚';
} else if (diffInSeconds < 3600) {
const minutes = Math.floor(diffInSeconds / 60);
return `${minutes}分钟前`;
} else if (diffInSeconds < 86400) {
const hours = Math.floor(diffInSeconds / 3600);
return `${hours}小时前`;
} else if (diffInSeconds < 604800) {
const days = Math.floor(diffInSeconds / 86400);
return `${days}天前`;
} else {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
};
/**
* 格式化货币
* @param amount - 金额
* @returns 格式化后的货币字符串
*/
export const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(amount);
};
/**
* 格式化文件大小
* @param bytes - 字节数
* @returns 格式化后的文件大小字符串
*/
export const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/**
* 生成随机字符串
* @param length - 字符串长度
* @returns 随机字符串
*/
export const generateRandomString = (length: number): string => {
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
return result;
};
/**
* 防抖函数
* @param func - 要防抖的函数
* @param wait - 等待时间(毫秒)
* @returns 防抖后的函数
*/
export const debounce = <T extends (...args: any[]) => any>(
func: T,
wait: number
): ((...args: Parameters<T>) => void) => {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(null, args), wait);
};
};
/**
* 节流函数
* @param func - 要节流的函数
* @param limit - 限制时间(毫秒)
* @returns 节流后的函数
*/
export const throttle = <T extends (...args: any[]) => any>(
func: T,
limit: number
): ((...args: Parameters<T>) => void) => {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func.apply(null, args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
};
/**
* 深拷贝对象
* @param obj - 要拷贝的对象
* @returns 拷贝后的对象
*/
export const deepClone = <T>(obj: T): T => {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Date) {
return new Date(obj.getTime()) as any;
}
if (obj instanceof Array) {
return obj.map(item => deepClone(item)) as any;
}
if (typeof obj === 'object') {
const cloned = {} as any;
Object.keys(obj).forEach(key => {
cloned[key] = deepClone((obj as any)[key]);
});
return cloned;
}
return obj;
};
/**
* 获取查询参数
* @param url - URL 字符串
* @returns 查询参数对象
*/
export const getQueryParams = (url: string): Record<string, string> => {
const params: Record<string, string> = {};
const urlObj = new URL(url);
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
});
return params;
};
/**
* 验证邮箱格式
* @param email - 邮箱地址
* @returns 是否为有效邮箱
*/
export const validateEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* 验证手机号格式
* @param phone - 手机号
* @returns 是否为有效手机号
*/
export const validatePhone = (phone: string): boolean => {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(phone);
};
+25
View File
@@ -15,13 +15,16 @@
"@supabase/auth-helpers-nextjs": "^0.8.7", "@supabase/auth-helpers-nextjs": "^0.8.7",
"@supabase/supabase-js": "^2.38.5", "@supabase/supabase-js": "^2.38.5",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@types/react": "^18.2.45", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/react-table": "^7.7.17", "@types/react-table": "^7.7.17",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"bcryptjs": "^3.0.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "^14.0.4", "next": "^14.0.4",
"postcss": "^8.4.32", "postcss": "^8.4.32",
@@ -759,6 +762,20 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true "dev": true
}, },
"node_modules/@types/jsonwebtoken": {
"version": "9.0.10",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
}
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
},
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "20.19.2", "version": "20.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.2.tgz",
@@ -1715,6 +1732,14 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
}, },
"node_modules/bcryptjs": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
"bin": {
"bcrypt": "bin/bcrypt"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+3
View File
@@ -17,13 +17,16 @@
"@supabase/auth-helpers-nextjs": "^0.8.7", "@supabase/auth-helpers-nextjs": "^0.8.7",
"@supabase/supabase-js": "^2.38.5", "@supabase/supabase-js": "^2.38.5",
"@tailwindcss/forms": "^0.5.10", "@tailwindcss/forms": "^0.5.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@types/react": "^18.2.45", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"@types/react-table": "^7.7.17", "@types/react-table": "^7.7.17",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"bcryptjs": "^3.0.2",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"date-fns": "^2.30.0", "date-fns": "^2.30.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.294.0", "lucide-react": "^0.294.0",
"next": "^14.0.4", "next": "^14.0.4",
"postcss": "^8.4.32", "postcss": "^8.4.32",
+102
View File
@@ -0,0 +1,102 @@
import { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';
interface LoginRequest {
email: string;
password: string;
}
// 硬编码的管理员凭据(用于演示)
const ADMIN_CREDENTIALS = {
email: 'admin@example.com',
password: 'admin123',
user: {
id: 'admin-001',
email: 'admin@example.com',
name: '系统管理员',
userType: 'admin',
phone: '13800138000',
avatarUrl: null
}
};
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: '方法不允许' });
}
try {
const { email, password }: LoginRequest = req.body;
console.log('收到登录请求:', { email, password: '***' });
// 验证必填字段
if (!email || !password) {
console.log('缺少必填字段');
return res.status(400).json({
success: false,
error: '邮箱和密码不能为空'
});
}
// 验证邮箱格式
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
console.log('邮箱格式不正确:', email);
return res.status(400).json({
success: false,
error: '邮箱格式不正确'
});
}
console.log('验证管理员凭据...');
// 验证管理员凭据
if (email !== ADMIN_CREDENTIALS.email || password !== ADMIN_CREDENTIALS.password) {
console.log('管理员凭据不正确');
return res.status(401).json({
success: false,
error: '邮箱或密码错误'
});
}
console.log('管理员凭据验证通过');
// 生成JWT令牌
const jwtSecret = process.env.JWT_SECRET || 'your-secret-key';
const token = jwt.sign(
{
userId: ADMIN_CREDENTIALS.user.id,
email: ADMIN_CREDENTIALS.user.email,
userType: ADMIN_CREDENTIALS.user.userType,
name: ADMIN_CREDENTIALS.user.name
},
jwtSecret,
{ expiresIn: '24h' }
);
console.log('JWT令牌生成成功');
console.log('登录成功,返回用户信息');
// 返回成功响应
res.status(200).json({
success: true,
message: '登录成功',
user: ADMIN_CREDENTIALS.user,
token,
expiresIn: '24h'
});
} catch (error) {
console.error('登录错误:', error);
res.status(500).json({
success: false,
error: process.env.NODE_ENV === 'development'
? `服务器错误: ${error}`
: '服务器内部错误'
});
}
}
+92
View File
@@ -0,0 +1,92 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { auth } from '../../../lib/supabase'
import { getUserProfile, handleApiError, validateEmail } from '../../../lib/api-utils'
interface LoginRequest {
email: string
password: string
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: '方法不允许' })
}
try {
const { email, password }: LoginRequest = req.body
// 验证必填字段
if (!email || !password) {
return res.status(400).json({
success: false,
error: '缺少必填字段',
details: '邮箱和密码为必填项'
})
}
// 验证邮箱格式
if (!validateEmail(email)) {
return res.status(400).json({
success: false,
error: '邮箱格式不正确'
})
}
// 登录用户
const authData = await auth.signIn(email, password)
if (!authData?.user || !authData?.session) {
return res.status(401).json({
success: false,
error: '登录失败,请检查邮箱和密码'
})
}
// 获取用户详细信息
const userProfile = await getUserProfile(authData.user.id)
// 更新最后登录时间
if (userProfile) {
try {
await auth.updateUser({
user_metadata: {
...authData.user.user_metadata,
last_login: new Date().toISOString()
}
})
} catch (updateError) {
console.error('Update last login error:', updateError)
// 不影响登录流程
}
}
return res.status(200).json({
success: true,
message: '登录成功',
data: {
user: userProfile || {
id: authData.user.id,
email: authData.user.email,
name: authData.user.user_metadata?.name || '',
user_type: authData.user.user_metadata?.user_type || 'individual',
enterprise_id: authData.user.user_metadata?.enterprise_id || null,
status: 'active',
phone: authData.user.user_metadata?.phone || null,
created_at: authData.user.created_at,
updated_at: authData.user.updated_at
},
session: {
access_token: authData.session.access_token,
refresh_token: authData.session.refresh_token,
expires_at: authData.session.expires_at
}
}
})
} catch (error) {
return handleApiError(res, error, 'Login')
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { supabase } from '../../../lib/supabase'
interface ApiResponse {
success: boolean
message?: string
error?: string
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ApiResponse>
) {
if (req.method !== 'POST') {
return res.status(405).json({
success: false,
error: '方法不允许'
})
}
try {
// 从Supabase登出
const { error } = await supabase.auth.signOut()
if (error) {
console.error('Logout error:', error)
return res.status(400).json({
success: false,
error: error.message || '登出失败'
})
}
return res.status(200).json({
success: true,
message: '登出成功'
})
} catch (error) {
console.error('Server error during logout:', error)
return res.status(500).json({
success: false,
error: process.env.NODE_ENV === 'development'
? `服务器错误: ${error instanceof Error ? error.message : '未知错误'}`
: '服务器内部错误'
})
}
}
+85
View File
@@ -0,0 +1,85 @@
import type { NextApiRequest, NextApiResponse } from 'next'
import { supabase } from '../../../lib/supabase'
interface UserInfo {
id: string
email: string
name: string
phone?: string
user_type: 'individual' | 'enterprise' | 'admin'
status: 'active' | 'inactive' | 'suspended'
enterprise_id?: string
avatar_url?: string
created_at: string
updated_at: string
}
interface ApiResponse {
success: boolean
data?: UserInfo
error?: string
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ApiResponse>
) {
if (req.method !== 'GET') {
return res.status(405).json({
success: false,
error: '方法不允许'
})
}
try {
// 获取授权头
const authHeader = req.headers.authorization
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: '未提供有效的授权令牌'
})
}
const token = authHeader.substring(7) // 移除 'Bearer ' 前缀
// 验证JWT令牌
const { data: { user }, error: authError } = await supabase.auth.getUser(token)
if (authError || !user) {
return res.status(401).json({
success: false,
error: '无效的授权令牌'
})
}
// 从数据库获取用户详细信息
const { data: userProfile, error: profileError } = await supabase
.from('users')
.select('*')
.eq('id', user.id)
.single()
if (profileError) {
console.error('Error fetching user profile:', profileError)
return res.status(404).json({
success: false,
error: '用户信息不存在'
})
}
return res.status(200).json({
success: true,
data: userProfile
})
} catch (error) {
console.error('Server error getting user info:', error)
return res.status(500).json({
success: false,
error: process.env.NODE_ENV === 'development'
? `服务器错误: ${error instanceof Error ? error.message : '未知错误'}`
: '服务器内部错误'
})
}
}
+117
View File
@@ -0,0 +1,117 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { auth, db } from '../../../lib/supabase'
import { handleApiError, validateEmail, validatePassword } from '../../../lib/api-utils'
interface RegisterRequest {
email: string
password: string
name: string
phone?: string
user_type: 'individual' | 'enterprise'
enterprise_id?: string
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: '方法不允许' })
}
try {
const { email, password, name, phone, user_type, enterprise_id }: RegisterRequest = req.body
// 验证必填字段
if (!email || !password || !name || !user_type) {
return res.status(400).json({
success: false,
error: '缺少必填字段',
details: '邮箱、密码、姓名和用户类型为必填项'
})
}
// 验证邮箱格式
if (!validateEmail(email)) {
return res.status(400).json({
success: false,
error: '邮箱格式不正确'
})
}
// 验证密码强度
const passwordValidation = validatePassword(password)
if (!passwordValidation.valid) {
return res.status(400).json({
success: false,
error: passwordValidation.message
})
}
// 检查邮箱是否已注册
try {
const existingUsers = await db.select('users', '*')
const existingUser = existingUsers.find((user: any) => user.email === email)
if (existingUser) {
return res.status(400).json({
success: false,
error: '该邮箱已被注册'
})
}
} catch (error) {
console.error('Check existing user error:', error)
// 继续注册流程,让Supabase处理重复邮箱的情况
}
// 注册用户
const authData = await auth.signUp(email, password, {
name,
phone,
user_type,
enterprise_id
})
if (!authData?.user) {
return res.status(400).json({
success: false,
error: '注册失败,请稍后重试'
})
}
// 创建用户记录
try {
const userData = {
id: authData.user.id,
email,
name,
phone: phone || null,
user_type,
enterprise_id: enterprise_id || null,
status: 'active',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
const userRecord = await db.insert('users', userData)
return res.status(201).json({
success: true,
message: '注册成功',
data: {
user: userRecord,
needEmailVerification: !authData.session // 如果没有session,说明需要邮箱验证
}
})
} catch (dbError) {
console.error('Create user record error:', dbError)
return res.status(500).json({
success: false,
error: '用户注册成功,但创建用户记录失败',
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
})
}
} catch (error) {
return handleApiError(res, error, 'Register')
}
}
+125
View File
@@ -0,0 +1,125 @@
import { NextApiRequest, NextApiResponse } from 'next'
import { getDemoData } from '../../../lib/demo-data'
import {
authenticateUser,
getUserProfile,
handleApiError,
generateOrderNumber,
calculateServiceCost,
User
} from '../../../lib/api-utils'
import { db } from '../../../lib/supabase'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// 认证用户
const user = await authenticateUser(req, res)
if (!user) return // 错误已在authenticateUser中处理
if (req.method === 'GET') {
// 获取订单列表
try {
// 在演示模式下返回演示数据
if (process.env.NODE_ENV === 'development' && !process.env.SUPABASE_URL) {
const demoOrders = await getDemoData.orders()
return res.status(200).json({
success: true,
data: {
orders: demoOrders,
total: demoOrders.length
}
})
}
// 从数据库获取订单
const orders = await db.select('orders', '*')
// 根据用户类型过滤订单
const currentUser = await getUserProfile(user.id)
let filteredOrders = orders
if (currentUser && currentUser.user_type === 'individual') {
// 个人用户只能看到自己的订单
filteredOrders = orders.filter((order: any) => order.user_id === user.id)
}
// 企业用户和管理员可以看到所有订单
return res.status(200).json({
success: true,
data: {
orders: filteredOrders,
total: filteredOrders.length
}
})
} catch (error) {
return handleApiError(res, error, 'Get orders')
}
} else if (req.method === 'POST') {
// 创建新订单
const {
service_type,
service_name,
source_language,
target_language,
duration,
priority = 'normal',
scheduled_time,
notes
} = req.body
// 验证必填字段
if (!service_type || !source_language || !target_language) {
return res.status(400).json({
success: false,
error: '缺少必填字段',
details: '服务类型、源语言和目标语言为必填项'
})
}
try {
// 获取用户信息
const currentUser = await getUserProfile(user.id)
const orderData = {
order_number: generateOrderNumber(),
user_id: user.id,
user_name: currentUser?.name || user.email,
user_email: user.email,
service_type,
service_name: service_name || service_type,
source_language,
target_language,
duration: duration || null,
status: 'pending',
priority,
cost: calculateServiceCost(service_type, duration),
currency: 'CNY',
scheduled_time: scheduled_time || null,
notes: notes || null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}
const newOrder = await db.insert('orders', orderData)
return res.status(201).json({
success: true,
message: '订单创建成功',
data: {
order: newOrder
}
})
} catch (error) {
return handleApiError(res, error, 'Create order')
}
} else {
return res.status(405).json({
success: false,
error: '方法不允许'
})
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextApiRequest, NextApiResponse } from 'next';
import { supabase, isSupabaseConfigured } from '../../lib/supabase';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'GET') {
return res.status(405).json({ error: '方法不允许' });
}
try {
// 检查配置
const isConfigured = isSupabaseConfigured();
if (!isConfigured) {
return res.status(200).json({
success: false,
mode: 'demo',
message: '当前运行在演示模式,未配置 Supabase 数据库'
});
}
// 测试数据库连接
const { data, error } = await supabase
.from('users')
.select('count')
.limit(1);
if (error) {
console.error('数据库连接错误:', error);
return res.status(500).json({
success: false,
mode: 'production',
error: error.message,
message: '数据库连接失败'
});
}
return res.status(200).json({
success: true,
mode: 'production',
message: '数据库连接成功',
timestamp: new Date().toISOString()
});
} catch (error: any) {
console.error('连接测试失败:', error);
return res.status(500).json({
success: false,
error: error.message,
message: '连接测试失败'
});
}
}
+110 -110
View File
@@ -1,88 +1,93 @@
import { useState } from 'react'; import React, { useState } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline'; import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { auth } from '@/lib/supabase';
interface LoginForm { const LoginPage = () => {
email: string;
password: string;
}
export default function Login() {
const router = useRouter(); const router = useRouter();
const [form, setForm] = useState<LoginForm>({ const [formData, setFormData] = useState({
email: '', email: '',
password: '', password: ''
}); });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isRedirecting, setIsRedirecting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// 防止重复提交
if (loading || isRedirecting) {
return;
}
setLoading(true);
setError('');
try {
const response = await fetch('/api/auth/admin-login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
const data = await response.json();
if (response.ok && data.success) {
// 设置重定向状态,防止重复提交
setIsRedirecting(true);
// 存储用户信息和令牌
localStorage.setItem('user', JSON.stringify(data.user));
localStorage.setItem('access_token', data.token);
// 使用 window.location 进行重定向,避免 Next.js 路由问题
window.location.href = '/dashboard';
} else {
setError(data.error || '登录失败');
setLoading(false);
}
} catch (error) {
console.error('登录错误:', error);
setError('网络错误,请稍后重试');
setLoading(false);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
setForm(prev => ({ setFormData(prev => ({
...prev, ...prev,
[name]: value [name]: value
})); }));
}; };
const handleSubmit = async (e: React.FormEvent) => { // 预设账号快速填充
e.preventDefault(); const fillDemoAccount = (email: string, password: string) => {
if (loading || isRedirecting) return;
if (!form.email || !form.password) { setFormData({ email, password });
toast.error('请填写所有必填字段'); setError('');
return;
}
setLoading(true);
try {
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
if (isDemoMode) {
// 演示模式:检查测试账号
if (form.email === 'admin@demo.com' && form.password === 'admin123') {
toast.success('登录成功!');
// 在演示模式下直接跳转到仪表盘
router.push('/dashboard');
} else {
toast.error('演示模式:请使用测试账号 admin@demo.com / admin123');
}
} else {
// 真实模式:使用 Supabase 认证
try {
await auth.signIn(form.email, form.password);
toast.success('登录成功!');
router.push('/dashboard');
} catch (authError: any) {
console.error('Supabase auth error:', authError);
toast.error(authError.message || '登录失败,请检查邮箱和密码');
}
}
} catch (error: any) {
console.error('Login error:', error);
toast.error('登录过程中发生错误,请稍后重试');
} finally {
setLoading(false);
}
}; };
// 填入测试账号 // 如果正在重定向,显示加载状态
const fillTestAccount = () => { if (isRedirecting) {
setForm({ return (
email: 'admin@demo.com', <div className="min-h-screen flex items-center justify-center bg-gray-50">
password: 'admin123' <div className="text-center">
}); <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
}; <div className="mt-4 text-lg text-gray-600">...</div>
</div>
</div>
);
}
return ( return (
<> <>
<Head> <Head>
<title> - </title> <title> - </title>
</Head> </Head>
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
@@ -92,33 +97,35 @@ export default function Login() {
</h2> </h2>
<p className="mt-2 text-center text-sm text-gray-600"> <p className="mt-2 text-center text-sm text-gray-600">
</p> </p>
</div> </div>
{/* 测试账号提示 */} {/* 预设账号提示 */}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4"> <div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="flex"> <h3 className="text-sm font-medium text-blue-800 mb-2"></h3>
<div className="ml-3"> <div className="space-y-2 text-xs text-blue-700">
<h3 className="text-sm font-medium text-blue-800"> <div className="flex justify-between items-center">
<span>admin@example.com / admin123</span>
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>admin@demo.com</p>
<p>admin123</p>
<button <button
type="button" type="button"
onClick={fillTestAccount} onClick={() => fillDemoAccount('admin@example.com', 'admin123')}
className="mt-2 text-xs text-blue-600 hover:text-blue-500 underline" className="text-blue-600 hover:text-blue-800 underline disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || isRedirecting}
> >
使
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="rounded-md shadow-sm -space-y-px"> <div className="rounded-md shadow-sm -space-y-px">
<div> <div>
<label htmlFor="email" className="sr-only"> <label htmlFor="email" className="sr-only">
@@ -130,9 +137,10 @@ export default function Login() {
type="email" type="email"
autoComplete="email" autoComplete="email"
required required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" disabled={loading || isRedirecting}
placeholder="管理员邮箱" className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm disabled:bg-gray-100"
value={form.email} placeholder="邮箱地址"
value={formData.email}
onChange={handleInputChange} onChange={handleInputChange}
/> />
</div> </div>
@@ -146,15 +154,17 @@ export default function Login() {
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
autoComplete="current-password" autoComplete="current-password"
required required
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm" disabled={loading || isRedirecting}
placeholder="管理员密码" className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm disabled:bg-gray-100"
value={form.password} placeholder="密码"
value={formData.password}
onChange={handleInputChange} onChange={handleInputChange}
/> />
<button <button
type="button" type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center" className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
disabled={loading || isRedirecting}
> >
{showPassword ? ( {showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" /> <EyeSlashIcon className="h-5 w-5 text-gray-400" />
@@ -165,44 +175,34 @@ export default function Login() {
</div> </div>
</div> </div>
<div className="flex items-center justify-between">
<div className="text-sm">
<Link
href="/"
className="font-medium text-blue-600 hover:text-blue-500"
>
</Link>
</div>
<div className="text-sm">
<a
href="#"
className="font-medium text-blue-600 hover:text-blue-500"
>
</a>
</div>
</div>
<div> <div>
<button <button
type="submit" type="submit"
disabled={loading} disabled={loading || isRedirecting}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed" className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
> >
{loading ? ( {loading ? (
<div className="flex items-center"> <span className="flex items-center">
<div className="loading-spinner-sm mr-2"></div> <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
... ...
</div> </span>
) : ( ) : '登录'}
'登录'
)}
</button> </button>
</div> </div>
<div className="text-center">
<p className="text-sm text-gray-600">
</p>
</div>
</form> </form>
</div> </div>
</div> </div>
</> </>
); );
} };
export default LoginPage;
+223
View File
@@ -0,0 +1,223 @@
import React, { useState } from 'react'
import { useRouter } from 'next/router'
import Head from 'next/head'
import Link from 'next/link'
const RegisterPage = () => {
const router = useRouter()
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
name: '',
phone: '',
user_type: 'individual' as 'individual' | 'enterprise'
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
setSuccess('')
// 验证密码匹配
if (formData.password !== formData.confirmPassword) {
setError('密码不匹配')
setLoading(false)
return
}
try {
const { confirmPassword, ...registerData } = formData
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(registerData)
})
const data = await response.json()
if (data.success) {
if (data.data.needEmailVerification) {
setSuccess('注册成功!请检查您的邮箱并验证账户后登录。')
} else {
setSuccess('注册成功!正在跳转到登录页面...')
setTimeout(() => {
router.push('/auth/login')
}, 2000)
}
} else {
setError(data.error || '注册失败')
}
} catch (error) {
console.error('Register error:', error)
setError('网络错误,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<>
<Head>
<title> - </title>
</Head>
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
{' '}
<Link href="/auth/login" className="font-medium text-indigo-600 hover:text-indigo-500">
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
</label>
<input
id="name"
name="name"
type="text"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="请输入您的姓名"
value={formData.name}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="请输入邮箱地址"
value={formData.email}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
</label>
<input
id="phone"
name="phone"
type="tel"
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="请输入手机号码(可选)"
value={formData.phone}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="user_type" className="block text-sm font-medium text-gray-700">
</label>
<select
id="user_type"
name="user_type"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
value={formData.user_type}
onChange={handleChange}
>
<option value="individual"></option>
<option value="enterprise"></option>
</select>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="请输入密码(至少6位)"
value={formData.password}
onChange={handleChange}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
autoComplete="new-password"
required
className="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="请再次输入密码"
value={formData.confirmPassword}
onChange={handleChange}
/>
</div>
</div>
{error && (
<div className="rounded-md bg-red-50 p-4">
<div className="text-sm text-red-700">{error}</div>
</div>
)}
{success && (
<div className="rounded-md bg-green-50 p-4">
<div className="text-sm text-green-700">{success}</div>
</div>
)}
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? '注册中...' : '注册'}
</button>
</div>
</form>
</div>
</div>
</>
)
}
export default RegisterPage
+411 -333
View File
@@ -1,475 +1,558 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Head from 'next/head'; import Head from 'next/head';
import DashboardLayout from '../../components/Layout/DashboardLayout';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { import {
MagnifyingGlassIcon,
PhoneIcon, PhoneIcon,
ChevronLeftIcon, MagnifyingGlassIcon,
ChevronRightIcon,
PlayIcon, PlayIcon,
StopIcon, StopIcon,
EyeIcon PauseIcon,
DocumentTextIcon,
CalendarIcon,
ClockIcon,
UserIcon,
LanguageIcon,
SpeakerWaveIcon,
ArrowDownTrayIcon,
FunnelIcon,
EyeIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { supabase, TABLES } from '@/lib/supabase'; import { getDemoData } from '../../lib/demo-data';
import { getDemoData } from '@/lib/demo-data'; import { formatTime } from '../../lib/utils';
import { Call } from '@/types';
import { formatTime } from '@/utils'; interface CallRecord {
import Layout from '@/components/Layout'; id: string;
user_name: string;
interpreter_name: string;
language_pair: string;
start_time: string;
end_time: string;
duration: number;
status: 'active' | 'completed' | 'failed' | 'cancelled';
call_type: 'audio' | 'video';
recording_url?: string;
cost: number;
notes?: string;
}
interface CallFilters { interface CallFilters {
search: string; search: string;
status: 'all' | 'pending' | 'active' | 'ended' | 'cancelled' | 'failed'; status: string;
call_type: 'all' | 'audio' | 'video'; language: string;
call_mode: 'all' | 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpreter'; date_range: string;
sortBy: 'created_at' | 'duration' | 'cost'; call_type: string;
sortOrder: 'asc' | 'desc';
} }
export default function CallsPage() { export default function CallRecords() {
const [calls, setCalls] = useState<Call[]>([]); const router = useRouter();
const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedCalls, setSelectedCalls] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState<CallFilters>({ const [filters, setFilters] = useState<CallFilters>({
search: '', search: '',
status: 'all', status: '',
call_type: 'all', language: '',
call_mode: 'all', date_range: '',
sortBy: 'created_at', call_type: ''
sortOrder: 'desc'
}); });
const router = useRouter();
const pageSize = 20; const pageSize = 10;
// 获取通话记录列表 useEffect(() => {
const fetchCalls = async (page = 1) => { fetchCalls();
}, [currentPage, filters]);
const fetchCalls = async () => {
try { try {
setLoading(true); setLoading(true);
// 检查是否为演示模式 // 模拟加载延迟
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; await new Promise(resolve => setTimeout(resolve, 800));
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemo);
if (isDemo) {
// 使用演示数据 // 使用演示数据
const result = await getDemoData.calls(); const mockCalls: CallRecord[] = [
// 转换数据格式以匹配 Call 类型 {
const formattedResult = result.map(item => ({ id: '1',
...item, user_name: '张三',
caller_id: item.user_id, interpreter_name: '王五',
callee_id: item.interpreter_id, language_pair: '中文-英文',
call_type: 'audio' as const, start_time: '2024-01-20T14:30:00Z',
call_mode: 'human_interpreter' as const, end_time: '2024-01-20T15:15:00Z',
end_time: item.end_time || undefined, duration: 2700, // 45分钟
room_sid: undefined, status: 'completed',
twilio_call_sid: undefined, call_type: 'video',
quality_rating: undefined, recording_url: 'https://example.com/recording1.mp4',
currency: 'CNY' as const, cost: 180,
updated_at: item.created_at notes: '商务会议翻译,客户满意度高'
})); },
setCalls(formattedResult); {
setTotalCount(formattedResult.length); id: '2',
setTotalPages(Math.ceil(formattedResult.length / pageSize)); user_name: '李四',
setCurrentPage(page); interpreter_name: '赵六',
} else { language_pair: '中文-日文',
// 使用真实数据 start_time: '2024-01-20T10:00:00Z',
let query = supabase end_time: '2024-01-20T10:30:00Z',
.from(TABLES.CALLS) duration: 1800, // 30分钟
.select('*', { count: 'exact' }); status: 'completed',
call_type: 'audio',
recording_url: 'https://example.com/recording2.mp3',
cost: 120,
notes: '技术文档翻译'
},
{
id: '3',
user_name: '王二',
interpreter_name: '孙七',
language_pair: '中文-韩文',
start_time: '2024-01-20T16:00:00Z',
end_time: '',
duration: 0,
status: 'active',
call_type: 'video',
cost: 0,
notes: '正在进行中的通话'
},
{
id: '4',
user_name: '陈五',
interpreter_name: '周八',
language_pair: '中文-法文',
start_time: '2024-01-19T09:30:00Z',
end_time: '2024-01-19T09:35:00Z',
duration: 300, // 5分钟
status: 'failed',
call_type: 'audio',
cost: 0,
notes: '连接失败,技术问题'
},
{
id: '5',
user_name: '刘六',
interpreter_name: '吴九',
language_pair: '中文-德文',
start_time: '2024-01-19T14:00:00Z',
end_time: '2024-01-19T14:05:00Z',
duration: 300, // 5分钟
status: 'cancelled',
call_type: 'video',
cost: 0,
notes: '用户取消通话'
},
{
id: '6',
user_name: '黄七',
interpreter_name: '郑十',
language_pair: '中文-西班牙文',
start_time: '2024-01-18T11:00:00Z',
end_time: '2024-01-18T12:30:00Z',
duration: 5400, // 90分钟
status: 'completed',
call_type: 'video',
recording_url: 'https://example.com/recording3.mp4',
cost: 360,
notes: '法律合同翻译,专业性强'
}
];
// 应用过滤器
let filteredCalls = mockCalls;
// 搜索过滤
if (filters.search) { if (filters.search) {
query = query.or(`caller_id.ilike.%${filters.search}%,callee_id.ilike.%${filters.search}%`); filteredCalls = filteredCalls.filter(call =>
call.user_name.toLowerCase().includes(filters.search.toLowerCase()) ||
call.interpreter_name.toLowerCase().includes(filters.search.toLowerCase()) ||
call.language_pair.toLowerCase().includes(filters.search.toLowerCase())
);
} }
// 状态过滤 if (filters.status) {
if (filters.status !== 'all') { filteredCalls = filteredCalls.filter(call => call.status === filters.status);
query = query.eq('status', filters.status);
} }
// 通话类型过滤 if (filters.language) {
if (filters.call_type !== 'all') { filteredCalls = filteredCalls.filter(call =>
query = query.eq('call_type', filters.call_type); call.language_pair.toLowerCase().includes(filters.language.toLowerCase())
);
} }
// 通话模式过滤 if (filters.call_type) {
if (filters.call_mode !== 'all') { filteredCalls = filteredCalls.filter(call => call.call_type === filters.call_type);
query = query.eq('call_mode', filters.call_mode);
} }
// 排序
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' });
// 分页 // 分页
const from = (page - 1) * pageSize; const startIndex = (currentPage - 1) * pageSize;
const to = from + pageSize - 1; const endIndex = startIndex + pageSize;
query = query.range(from, to); const paginatedCalls = filteredCalls.slice(startIndex, endIndex);
const { data, error, count } = await query; setCalls(paginatedCalls);
setTotalCount(filteredCalls.length);
if (error) throw error; setTotalPages(Math.ceil(filteredCalls.length / pageSize));
setCalls(data || []);
setTotalCount(count || 0);
setTotalPages(Math.ceil((count || 0) / pageSize));
setCurrentPage(page);
}
} catch (error) { } catch (error) {
console.error('Error fetching calls:', error); console.error('Failed to fetch calls:', error);
toast.error('获取通话记录失败'); toast.error('加载通话记录失败');
// 如果真实数据获取失败,切换到演示模式
if (!isDemoMode) {
setIsDemoMode(true);
const result = await getDemoData.calls();
const formattedResult = result.map(item => ({
...item,
caller_id: item.user_id,
callee_id: item.interpreter_id,
call_type: 'audio' as const,
call_mode: 'human_interpreter' as const,
end_time: item.end_time || undefined,
room_sid: undefined,
twilio_call_sid: undefined,
quality_rating: undefined,
currency: 'CNY' as const,
updated_at: item.created_at
}));
setCalls(formattedResult);
setTotalCount(formattedResult.length);
setTotalPages(Math.ceil(formattedResult.length / pageSize));
setCurrentPage(page);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 处理筛选变更 const handleSearch = (value: string) => {
const handleFilterChange = (key: keyof CallFilters, value: any) => { setFilters(prev => ({ ...prev, search: value }));
setFilters(prev => ({
...prev,
[key]: value
}));
};
// 应用筛选
const applyFilters = () => {
setCurrentPage(1); setCurrentPage(1);
fetchCalls(1);
}; };
// 重置筛选 const handleFilterChange = (key: keyof CallFilters, value: string) => {
const resetFilters = () => { setFilters(prev => ({ ...prev, [key]: value }));
setFilters({
search: '',
status: 'all',
call_type: 'all',
call_mode: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
setCurrentPage(1); setCurrentPage(1);
fetchCalls(1);
}; };
// 获取状态颜色 const handleSelectCall = (callId: string) => {
setSelectedCalls(prev =>
prev.includes(callId)
? prev.filter(id => id !== callId)
: [...prev, callId]
);
};
const handleSelectAll = () => {
if (selectedCalls.length === calls.length) {
setSelectedCalls([]);
} else {
setSelectedCalls(calls.map(call => call.id));
}
};
const handleExport = async () => {
try {
toast.loading('正在导出通话记录...', { id: 'export' });
// 模拟导出延迟
await new Promise(resolve => setTimeout(resolve, 2000));
toast.success('通话记录导出成功', { id: 'export' });
} catch (error) {
toast.error('导出失败', { id: 'export' });
}
};
const handlePlayRecording = (recordingUrl: string) => {
if (recordingUrl) {
toast.success('开始播放录音');
// 这里可以集成音频播放器
} else {
toast.error('录音文件不存在');
}
};
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
switch (status) { switch (status) {
case 'completed':
return 'text-green-800 bg-green-100';
case 'active': case 'active':
return 'bg-green-100 text-green-800'; return 'text-blue-800 bg-blue-100';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'ended':
return 'bg-blue-100 text-blue-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'failed': case 'failed':
return 'bg-red-100 text-red-800'; return 'text-red-800 bg-red-100';
case 'cancelled':
return 'text-gray-800 bg-gray-100';
default: default:
return 'bg-gray-100 text-gray-800'; return 'text-gray-800 bg-gray-100';
} }
}; };
// 获取状态文本
const getStatusText = (status: string) => { const getStatusText = (status: string) => {
switch (status) { switch (status) {
case 'completed':
return '已完成';
case 'active': case 'active':
return '进行中'; return '进行中';
case 'pending':
return '待接听';
case 'ended':
return '已结束';
case 'cancelled':
return '已取消';
case 'failed': case 'failed':
return '失败'; return '失败';
case 'cancelled':
return '已取消';
default: default:
return '未知'; return status;
} }
}; };
// 获取通话类型文本 const getStatusIcon = (status: string) => {
const getCallTypeText = (type: string) => { switch (status) {
switch (type) { case 'completed':
case 'audio': return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
return '语音通话'; case 'active':
case 'video': return <PlayIcon className="h-4 w-4 text-blue-500" />;
return '视频通话'; case 'failed':
return <XCircleIcon className="h-4 w-4 text-red-500" />;
case 'cancelled':
return <StopIcon className="h-4 w-4 text-gray-500" />;
default: default:
return '未知'; return <ClockIcon className="h-4 w-4 text-gray-500" />;
} }
}; };
// 获取通话模式文本 const formatDuration = (seconds: number) => {
const getCallModeText = (mode: string) => { if (seconds === 0) return '-';
switch (mode) {
case 'ai_voice':
return 'AI语音';
case 'ai_video':
return 'AI视频';
case 'sign_language':
return '手语翻译';
case 'human_interpreter':
return '人工翻译';
default:
return '未知';
}
};
// 格式化时长 const hours = Math.floor(seconds / 3600);
const formatDuration = (seconds?: number) => { const minutes = Math.floor((seconds % 3600) / 60);
if (!seconds) return '-';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60; const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds}`;
};
useEffect(() => { if (hours > 0) {
fetchCalls(); return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}, []); } else {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
};
return ( return (
<Layout> <>
<Head> <Head>
<title> - </title> <title> - </title>
</Head> </Head>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <DashboardLayout title="通话记录">
<div className="px-4 py-6 sm:px-0"> <div className="space-y-6">
{/* 页面标题 */} {/* 页面标题和操作 */}
<div className="flex items-center justify-between mb-6"> <div className="sm:flex sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1> <h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-700">
</p>
</div>
<div className="mt-4 sm:mt-0 sm:flex sm:space-x-3">
<button
onClick={handleExport}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
</button>
</div>
</div> </div>
{/* 搜索和筛选 */} {/* 搜索和过滤器 */}
<div className="bg-white shadow rounded-lg mb-6"> <div className="bg-white shadow rounded-lg p-6">
<div className="px-4 py-5 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
{/* 搜索框 */} <div>
<div className="lg:col-span-1"> <label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" /> <MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div> </div>
<input <input
type="text" type="text"
placeholder="搜索用户ID..." id="search"
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500" className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="搜索用户、翻译员..."
value={filters.search}
onChange={(e) => handleSearch(e.target.value)}
/> />
</div> </div>
</div> </div>
{/* 状态筛选 */}
<div> <div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select <select
id="status"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={filters.status} value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)} onChange={(e) => handleFilterChange('status', e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="all"></option> <option value=""></option>
<option value="active"></option> <option value="active"></option>
<option value="pending"></option> <option value="completed"></option>
<option value="ended"></option>
<option value="cancelled"></option>
<option value="failed"></option> <option value="failed"></option>
<option value="cancelled"></option>
</select> </select>
</div> </div>
{/* 通话类型筛选 */}
<div> <div>
<label htmlFor="language" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
id="language"
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="过滤语言对..."
value={filters.language}
onChange={(e) => handleFilterChange('language', e.target.value)}
/>
</div>
<div>
<label htmlFor="call_type" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select <select
id="call_type"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={filters.call_type} value={filters.call_type}
onChange={(e) => handleFilterChange('call_type', e.target.value)} onChange={(e) => handleFilterChange('call_type', e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="all"></option> <option value=""></option>
<option value="audio"></option> <option value="audio"></option>
<option value="video"></option> <option value="video"></option>
</select> </select>
</div> </div>
{/* 通话模式筛选 */}
<div> <div>
<label htmlFor="date_range" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select <select
value={filters.call_mode} id="date_range"
onChange={(e) => handleFilterChange('call_mode', e.target.value)} className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" value={filters.date_range}
onChange={(e) => handleFilterChange('date_range', e.target.value)}
> >
<option value="all"></option> <option value=""></option>
<option value="ai_voice">AI语音</option> <option value="today"></option>
<option value="ai_video">AI视频</option> <option value="yesterday"></option>
<option value="sign_language"></option> <option value="week"></option>
<option value="human_interpreter"></option> <option value="month"></option>
</select> </select>
</div> </div>
{/* 排序 */}
<div>
<select
value={`${filters.sortBy}-${filters.sortOrder}`}
onChange={(e) => {
const [sortBy, sortOrder] = e.target.value.split('-');
handleFilterChange('sortBy', sortBy);
handleFilterChange('sortOrder', sortOrder);
}}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="created_at-desc"> ()</option>
<option value="created_at-asc"> ()</option>
<option value="duration-desc"> ()</option>
<option value="duration-asc"> ()</option>
<option value="cost-desc"> ()</option>
<option value="cost-asc"> ()</option>
</select>
</div>
</div>
<div className="mt-4 flex space-x-3">
<button
onClick={applyFilters}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
</button>
<button
onClick={resetFilters}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
</button>
</div>
</div> </div>
</div> </div>
{/* 通话记录列表 */} {/* 通话记录列表 */}
<div className="bg-white shadow rounded-lg overflow-hidden">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center h-64">
<div className="loading-spinner"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : calls.length === 0 ? (
<div className="text-center py-12">
<PhoneIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500">
</p>
</div> </div>
) : ( ) : (
<div className="bg-white shadow overflow-hidden sm:rounded-md"> <>
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
({totalCount} )
</h3>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200"> <table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50"> <thead className="bg-gray-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" className="relative px-6 py-3">
<input
type="checkbox"
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={selectedCalls.length === calls.length && calls.length > 0}
onChange={handleSelectAll}
/>
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
/
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
/
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th> </th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"> <th scope="col" className="relative px-6 py-3">
<span className="sr-only"></span>
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="bg-white divide-y divide-gray-200"> <tbody className="bg-white divide-y divide-gray-200">
{calls.map((call) => ( {calls.map((call) => (
<tr key={call.id} className="hover:bg-gray-50"> <tr key={call.id} className="hover:bg-gray-50">
<td className="relative px-6 py-4">
<input
type="checkbox"
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={selectedCalls.includes(call.id)}
onChange={() => handleSelectCall(call.id)}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div> <div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className={`h-10 w-10 rounded-full flex items-center justify-center ${
call.call_type === 'video' ? 'bg-blue-100' : 'bg-green-100'
}`}>
<PhoneIcon className={`h-6 w-6 ${
call.call_type === 'video' ? 'text-blue-600' : 'text-green-600'
}`} />
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">
{call.id} {call.call_type === 'video' ? '视频通话' : '音频通话'}
</div> </div>
<div className="text-sm text-gray-500"> <div className="text-sm text-gray-500 flex items-center">
: {call.caller_id} <CalendarIcon className="h-4 w-4 mr-1" />
{formatTime(call.start_time)}
</div> </div>
{call.callee_id && (
<div className="text-sm text-gray-500">
: {call.callee_id}
</div> </div>
)}
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900"> <div className="space-y-1">
{getCallTypeText(call.call_type)} <div className="flex items-center text-sm text-gray-900">
<UserIcon className="h-4 w-4 mr-1 text-gray-400" />
{call.user_name}
</div>
<div className="flex items-center text-sm text-gray-500">
<LanguageIcon className="h-4 w-4 mr-1 text-gray-400" />
{call.interpreter_name}
</div> </div>
<div className="text-sm text-gray-500">
{getCallModeText(call.call_mode)}
</div> </div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDuration(call.duration)}
</td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900"> <div className="text-sm text-gray-900">{call.language_pair}</div>
¥{call.cost.toFixed(2)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}>
{call.status === 'active' && <PlayIcon className="h-3 w-3 mr-1" />}
{call.status === 'ended' && <StopIcon className="h-3 w-3 mr-1" />}
{getStatusText(call.status)}
</span>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatTime(call.start_time)} <div className="flex items-center">
<ClockIcon className="h-4 w-4 mr-1" />
{formatDuration(call.duration)}
</div>
<div className="text-sm font-medium text-gray-900">
¥{call.cost}
</div>
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium"> <td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{getStatusIcon(call.status)}
<span className={`ml-2 inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(call.status)}`}>
{getStatusText(call.status)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{call.recording_url ? (
<button <button
className="text-blue-600 hover:text-blue-900 mr-3" onClick={() => handlePlayRecording(call.recording_url!)}
onClick={() => { className="flex items-center text-blue-600 hover:text-blue-900"
// 查看通话详情 >
toast.success('查看通话详情功能待实现'); <SpeakerWaveIcon className="h-4 w-4 mr-1" />
}}
</button>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => router.push(`/dashboard/calls/${call.id}`)}
className="text-blue-600 hover:text-blue-900"
> >
<EyeIcon className="h-4 w-4" /> <EyeIcon className="h-4 w-4" />
</button> </button>
@@ -482,19 +565,19 @@ export default function CallsPage() {
{/* 分页 */} {/* 分页 */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="mt-6 flex items-center justify-between"> <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden"> <div className="flex-1 flex justify-between sm:hidden">
<button <button
onClick={() => fetchCalls(currentPage - 1)} onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
</button> </button>
<button <button
onClick={() => fetchCalls(currentPage + 1)} onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
</button> </button>
@@ -503,27 +586,23 @@ export default function CallsPage() {
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
<span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> {' '} <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> {' '}
<span className="font-medium"> <span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span>
{Math.min(currentPage * pageSize, totalCount)} <span className="font-medium">{totalCount}</span>
</span>{' '}
<span className="font-medium">{totalCount}</span>
</p> </p>
</div> </div>
<div> <div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> <nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button <button
onClick={() => fetchCalls(currentPage - 1)} onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<ChevronLeftIcon className="h-5 w-5" />
</button> </button>
{[...Array(Math.min(totalPages, 5))].map((_, i) => { {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
const page = i + 1;
return (
<button <button
key={page} key={page}
onClick={() => fetchCalls(page)} onClick={() => setCurrentPage(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${ className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage page === currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600' ? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
@@ -532,25 +611,24 @@ export default function CallsPage() {
> >
{page} {page}
</button> </button>
); ))}
})}
<button <button
onClick={() => fetchCalls(currentPage + 1)} onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<ChevronRightIcon className="h-5 w-5" />
</button> </button>
</nav> </nav>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </>
</div>
)} )}
</div> </div>
</div> </div>
</Layout> </DashboardLayout>
</>
); );
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+253 -364
View File
@@ -1,484 +1,373 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import DashboardLayout from '../../components/Layout/DashboardLayout';
import Head from 'next/head'; import { getDemoData } from '../../lib/demo-data';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import { import {
UsersIcon,
PhoneIcon, PhoneIcon,
VideoCameraIcon, DocumentTextIcon,
UserGroupIcon,
ClockIcon,
CurrencyDollarIcon, CurrencyDollarIcon,
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
PlayIcon, ArrowUpIcon,
StopIcon, ArrowDownIcon,
UserPlusIcon, EyeIcon
ArrowRightOnRectangleIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { auth, db, TABLES, realtime, supabase } from '@/lib/supabase';
import { getDemoData } from '@/lib/demo-data';
import { Call, CallStats, Interpreter, User } from '@/types';
import {
formatCurrency,
formatTime,
formatDuration,
getCallStatusText,
getCallModeText,
getStatusColor
} from '@/utils';
import Layout from '@/components/Layout';
interface DashboardProps { interface DashboardStats {
user?: User; totalUsers: number;
activeUsers: number;
totalCalls: number;
activeCalls: number;
totalOrders: number;
pendingOrders: number;
completedOrders: number;
totalRevenue: number;
monthlyRevenue: number;
activeInterpreters: number;
} }
export default function Dashboard({ user }: DashboardProps) { interface RecentActivity {
const router = useRouter(); id: string;
const [loading, setLoading] = useState(true); type: 'call' | 'order' | 'user' | 'system';
const [stats, setStats] = useState<CallStats>({ title: string;
total_calls_today: 0, description: string;
active_calls: 0, time: string;
average_response_time: 0, status: 'success' | 'warning' | 'error' | 'info';
online_interpreters: 0, }
total_revenue_today: 0,
currency: 'CNY',
});
const [activeCalls, setActiveCalls] = useState<Call[]>([]);
const [onlineInterpreters, setOnlineInterpreters] = useState<Interpreter[]>([]);
const [isDemoMode, setIsDemoMode] = useState(false);
// 获取仪表盘数据 export default function Dashboard() {
const fetchDashboardData = async () => { const [stats, setStats] = useState<DashboardStats | null>(null);
const [activities, setActivities] = useState<RecentActivity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const loadDashboardData = async () => {
try { try {
setLoading(true); setLoading(true);
// 检查是否为演示模式 // 模拟加载延迟
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; await new Promise(resolve => setTimeout(resolve, 1000));
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemoMode);
if (isDemoMode) {
// 使用演示数据 // 使用演示数据
const [statsData, callsData, interpretersData] = await Promise.all([ const mockStats: DashboardStats = {
getDemoData.stats(), totalUsers: 1248,
getDemoData.calls(), activeUsers: 856,
getDemoData.interpreters(), totalCalls: 3456,
]); activeCalls: 12,
totalOrders: 2789,
pendingOrders: 45,
completedOrders: 2654,
totalRevenue: 125000,
monthlyRevenue: 15600,
activeInterpreters: 23
};
// 转换演示数据格式以匹配类型定义 const mockActivities: RecentActivity[] = [
setStats({ {
total_calls_today: statsData.todayCalls, id: '1',
active_calls: statsData.activeCalls, type: 'call',
average_response_time: statsData.avgResponseTime, title: '新通话开始',
online_interpreters: statsData.onlineInterpreters, description: '张三开始了中英互译通话',
total_revenue_today: statsData.todayRevenue, time: '2分钟前',
currency: 'CNY', status: 'success'
}); },
{
// 转换通话数据格式 id: '2',
const formattedCalls = callsData type: 'order',
.filter(call => call.status === 'active') title: '订单完成',
.map(call => ({ description: '订单ORD-2024-001已完成,费用¥180',
id: call.id, time: '5分钟前',
caller_id: call.user_id, status: 'success'
callee_id: call.interpreter_id, },
call_type: 'audio' as const, {
call_mode: 'human_interpreter' as const, id: '3',
status: call.status as 'active', type: 'user',
start_time: call.start_time, title: '新用户注册',
end_time: call.end_time, description: 'ABC公司注册了企业账户',
duration: call.duration, time: '10分钟前',
cost: call.cost, status: 'info'
currency: 'CNY' as const, },
created_at: call.created_at, {
updated_at: call.created_at, id: '4',
})); type: 'system',
title: '系统维护',
// 转换翻译员数据格式 description: '系统将在今晚22:00-23:00进行维护',
const formattedInterpreters = interpretersData time: '30分钟前',
.filter(interpreter => interpreter.status !== 'offline') status: 'warning'
.map(interpreter => ({ },
id: interpreter.id, {
user_id: interpreter.id, id: '5',
name: interpreter.name, type: 'call',
avatar_url: interpreter.avatar_url, title: '通话异常',
languages: interpreter.languages, description: '通话CALL-2024-003出现连接问题',
specializations: interpreter.specialties, time: '1小时前',
hourly_rate: 100, status: 'error'
currency: 'CNY' as const,
rating: interpreter.rating,
total_calls: 50,
status: interpreter.status === 'busy' ? 'busy' as const : 'online' as const,
is_certified: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
setActiveCalls(formattedCalls);
setOnlineInterpreters(formattedInterpreters);
} else {
// 使用真实数据
const today = new Date();
today.setHours(0, 0, 0, 0);
// 获取今日通话统计
const { data: todayCalls } = await supabase
.from(TABLES.CALLS)
.select('*')
.gte('created_at', today.toISOString());
// 获取活跃通话
const { data: activeCallsData } = await supabase
.from(TABLES.CALLS)
.select(`
*,
user:users(full_name, email),
interpreter:interpreters(name, rating)
`)
.eq('status', 'active');
// 获取在线翻译员
const { data: interpretersData } = await supabase
.from(TABLES.INTERPRETERS)
.select('*')
.neq('status', 'offline');
// 计算统计数据
const totalRevenue = todayCalls && todayCalls.length > 0
? todayCalls
.filter(call => call.status === 'ended')
.reduce((sum, call) => sum + call.cost, 0)
: 0;
const avgResponseTime = todayCalls && todayCalls.length > 0
? todayCalls.reduce((sum, call) => {
const startTime = new Date(call.start_time);
const createdTime = new Date(call.created_at);
return sum + (startTime.getTime() - createdTime.getTime()) / 1000;
}, 0) / todayCalls.length
: 0;
setStats({
total_calls_today: todayCalls?.length || 0,
active_calls: activeCallsData?.length || 0,
average_response_time: Math.round(avgResponseTime),
online_interpreters: interpretersData?.length || 0,
total_revenue_today: totalRevenue,
currency: 'CNY',
});
setActiveCalls(activeCallsData || []);
setOnlineInterpreters(interpretersData || []);
} }
];
setStats(mockStats);
setActivities(mockActivities);
} catch (error) { } catch (error) {
console.error('获取仪表盘数据失败:', error); console.error('Failed to load dashboard data:', error);
toast.error('获取数据失败,请稍后重试');
// 如果获取真实数据失败,切换到演示模式
setIsDemoMode(true);
const [statsData, callsData, interpretersData] = await Promise.all([
getDemoData.stats(),
getDemoData.calls(),
getDemoData.interpreters(),
]);
setStats({
total_calls_today: statsData.todayCalls,
active_calls: statsData.activeCalls,
average_response_time: statsData.avgResponseTime,
online_interpreters: statsData.onlineInterpreters,
total_revenue_today: statsData.todayRevenue,
currency: 'CNY',
});
// 设置空数组避免类型错误
setActiveCalls([]);
setOnlineInterpreters([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 强制结束通话 loadDashboardData();
const handleEndCall = async (callId: string) => { }, []);
try {
await db.update(TABLES.CALLS, callId, { const getStatusColor = (status: string) => {
status: 'ended', switch (status) {
end_time: new Date().toISOString() case 'success':
}); return 'text-green-600 bg-green-100';
toast.success('通话已结束'); case 'warning':
fetchDashboardData(); return 'text-yellow-600 bg-yellow-100';
} catch (error) { case 'error':
console.error('Error ending call:', error); return 'text-red-600 bg-red-100';
toast.error('结束通话失败'); default:
return 'text-blue-600 bg-blue-100';
} }
}; };
// 分配翻译员 const getStatusIcon = (status: string) => {
const handleAssignInterpreter = async (callId: string, interpreterId: string) => { switch (status) {
try { case 'success':
await db.update(TABLES.CALLS, callId, { return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
callee_id: interpreterId, case 'warning':
call_mode: 'human_interpreter' return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
}); case 'error':
toast.success('翻译员已分配'); return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
fetchDashboardData(); default:
} catch (error) { return <ClockIcon className="h-5 w-5 text-blue-500" />;
console.error('Error assigning interpreter:', error);
toast.error('分配翻译员失败');
} }
}; };
useEffect(() => {
// 在演示模式下不检查用户认证
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
if (!isDemoMode && !user) {
router.push('/auth/login');
return;
}
fetchDashboardData();
// 设置实时数据更新
const callsChannel = realtime.subscribe(
TABLES.CALLS,
() => {
fetchDashboardData();
}
);
const interpretersChannel = realtime.subscribe(
TABLES.INTERPRETERS,
() => {
fetchDashboardData();
}
);
// 每30秒刷新一次数据
const interval = setInterval(fetchDashboardData, 30000);
return () => {
clearInterval(interval);
realtime.unsubscribe(callsChannel);
realtime.unsubscribe(interpretersChannel);
};
}, [user, router]);
if (loading) { if (loading) {
return ( return (
<Layout user={user}> <DashboardLayout title="仪表盘">
<div className="min-h-screen flex items-center justify-center"> <div className="flex items-center justify-center h-64">
<div className="loading-spinner"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div> </div>
</Layout> </DashboardLayout>
); );
} }
return ( return (
<Layout user={user}> <DashboardLayout title="仪表盘">
<Head> <div className="space-y-6">
<title> - </title> {/* 欢迎区域 */}
</Head> <div className="bg-white shadow rounded-lg p-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-600">
</p>
</div>
{/* 主要内容区域 */}
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* 统计卡片 */} {/* 统计卡片 */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8"> <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5"> <div className="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<PhoneIcon className="h-6 w-6 text-gray-400" /> <UsersIcon className="h-6 w-6 text-blue-400" />
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate"> <dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
</dt> <div className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</div>
<dd className="text-lg font-medium text-gray-900"> <div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
{stats.total_calls_today} <ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
12%
</div>
</dd> </dd>
</dl> </dl>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">{stats?.activeUsers || 0}</span>
</div>
</div>
</div> </div>
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5"> <div className="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<VideoCameraIcon className="h-6 w-6 text-green-400" /> <PhoneIcon className="h-6 w-6 text-green-400" />
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate"> <dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
</dt> <div className="text-2xl font-semibold text-gray-900">{stats?.totalCalls || 0}</div>
<dd className="text-lg font-medium text-gray-900"> <div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
{stats.active_calls} <ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
8%
</div>
</dd> </dd>
</dl> </dl>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">{stats?.activeCalls || 0}</span>
</div>
</div>
</div> </div>
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5"> <div className="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<UserGroupIcon className="h-6 w-6 text-blue-400" /> <DocumentTextIcon className="h-6 w-6 text-yellow-400" />
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate"> <dt className="text-sm font-medium text-gray-500 truncate"></dt>
线 <dd className="flex items-baseline">
</dt> <div className="text-2xl font-semibold text-gray-900">{stats?.totalOrders || 0}</div>
<dd className="text-lg font-medium text-gray-900"> <div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
{stats.online_interpreters} <ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
15%
</div>
</dd> </dd>
</dl> </dl>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">{stats?.pendingOrders || 0}</span>
</div>
</div>
</div> </div>
<div className="bg-white overflow-hidden shadow rounded-lg"> <div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5"> <div className="p-5">
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<CurrencyDollarIcon className="h-6 w-6 text-yellow-400" /> <CurrencyDollarIcon className="h-6 w-6 text-purple-400" />
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate"> <dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
</dt> <div className="text-2xl font-semibold text-gray-900">¥{stats?.totalRevenue?.toLocaleString() || 0}</div>
<dd className="text-lg font-medium text-gray-900"> <div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
{formatCurrency(stats.total_revenue_today, 'CNY')} <ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
22%
</div>
</dd> </dd>
</dl> </dl>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">¥{stats?.monthlyRevenue?.toLocaleString() || 0}</span>
</div>
</div>
</div> </div>
</div> </div>
{/* 主要内容区域 */} {/* 最近活动和快速操作 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 活跃通话列表 */} {/* 最近活动 */}
<div className="lg:col-span-2">
<div className="bg-white shadow rounded-lg"> <div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"> <h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"></h3>
</h3>
<div className="space-y-4"> <div className="space-y-4">
{activeCalls.length === 0 ? ( {activities.map((activity) => (
<p className="text-gray-500 text-center py-8"> <div key={activity.id} className="flex items-start space-x-3">
<div className="flex-shrink-0">
</p> {getStatusIcon(activity.status)}
) : (
activeCalls.map((call) => (
<div
key={call.id}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
>
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className={`call-status ${call.status}`}>
<div className="w-3 h-3 rounded-full bg-green-500"></div>
</div> </div>
<div> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900"> <div className="text-sm font-medium text-gray-900">{activity.title}</div>
{getCallModeText(call.call_mode)} <div className="text-sm text-gray-500">{activity.description}</div>
</p> <div className="text-xs text-gray-400 mt-1">{activity.time}</div>
<p className="text-xs text-gray-500"> </div>
{formatTime(call.start_time)} <div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(activity.status)}`}>
</p> {activity.status === 'success' && '成功'}
{activity.status === 'warning' && '警告'}
{activity.status === 'error' && '错误'}
{activity.status === 'info' && '信息'}
</div> </div>
</div> </div>
))}
</div> </div>
<div className="flex items-center space-x-2"> <div className="mt-6">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}> <button className="w-full bg-gray-50 border border-gray-300 rounded-md py-2 px-4 inline-flex justify-center items-center text-sm font-medium text-gray-700 hover:bg-gray-100">
{getCallStatusText(call.status)} <EyeIcon className="h-4 w-4 mr-2" />
</span>
<div className="flex space-x-1">
<button
onClick={() => handleEndCall(call.id)}
className="p-1 text-red-600 hover:text-red-500"
title="强制结束通话"
>
<StopIcon className="h-4 w-4" />
</button> </button>
<button
onClick={() => {/* 跳转到通话详情 */}}
className="p-1 text-blue-600 hover:text-blue-500"
title="查看详情"
>
<PlayIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
))
)}
</div>
</div> </div>
</div> </div>
</div> </div>
{/* 在线翻译员 */} {/* 快速操作 */}
<div>
<div className="bg-white shadow rounded-lg"> <div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6"> <div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"> <h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"></h3>
线 <div className="grid grid-cols-2 gap-4">
</h3> <button className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-left hover:bg-blue-100 transition-colors">
<div className="space-y-3"> <div className="flex items-center">
{onlineInterpreters.length === 0 ? ( <UsersIcon className="h-8 w-8 text-blue-600" />
<p className="text-gray-500 text-center py-4"> <div className="ml-3">
线 <div className="text-sm font-medium text-blue-900"></div>
</p> <div className="text-xs text-blue-700"></div>
) : (
onlineInterpreters.slice(0, 5).map((interpreter) => (
<div
key={interpreter.id}
className="flex items-center justify-between"
>
<div className="flex items-center space-x-3">
<img
className="h-8 w-8 rounded-full"
src={interpreter.avatar_url || `https://ui-avatars.com/api/?name=${interpreter.name}`}
alt={interpreter.name}
/>
<div>
<p className="text-sm font-medium text-gray-900">
{interpreter.name}
</p>
<p className="text-xs text-gray-500">
: {interpreter.rating}/5
</p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-1"> </button>
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span className="text-xs text-green-600">线</span> <button className="bg-green-50 border border-green-200 rounded-lg p-4 text-left hover:bg-green-100 transition-colors">
<div className="flex items-center">
<PhoneIcon className="h-8 w-8 text-green-600" />
<div className="ml-3">
<div className="text-sm font-medium text-green-900"></div>
<div className="text-xs text-green-700"></div>
</div> </div>
</div> </div>
)) </button>
)}
<button className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-left hover:bg-yellow-100 transition-colors">
<div className="flex items-center">
<DocumentTextIcon className="h-8 w-8 text-yellow-600" />
<div className="ml-3">
<div className="text-sm font-medium text-yellow-900"></div>
<div className="text-xs text-yellow-700"></div>
</div>
</div>
</button>
<button className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-left hover:bg-purple-100 transition-colors">
<div className="flex items-center">
<CurrencyDollarIcon className="h-8 w-8 text-purple-600" />
<div className="ml-3">
<div className="text-sm font-medium text-purple-900"></div>
<div className="text-xs text-purple-700"></div>
</div>
</div>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </DashboardLayout>
</div>
</Layout>
); );
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+586 -276
View File
File diff suppressed because it is too large Load Diff
+512 -221
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Head from 'next/head'; import Head from 'next/head';
import DashboardLayout from '../../components/Layout/DashboardLayout';
import { toast } from 'react-hot-toast'; import { toast } from 'react-hot-toast';
import { import {
MagnifyingGlassIcon, MagnifyingGlassIcon,
@@ -9,306 +10,601 @@ import {
TrashIcon, TrashIcon,
EyeIcon, EyeIcon,
UserIcon, UserIcon,
BuildingOfficeIcon,
PhoneIcon,
EnvelopeIcon,
CalendarIcon,
CheckCircleIcon, CheckCircleIcon,
XCircleIcon, XCircleIcon,
ChevronLeftIcon, ExclamationTriangleIcon,
ChevronRightIcon ArrowDownTrayIcon,
FunnelIcon
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import { supabase, TABLES } from '@/lib/supabase'; import { getDemoData } from '../../lib/demo-data';
import { getDemoData } from '@/lib/demo-data'; import { formatTime } from '../../lib/utils';
import { User } from '@/types';
import { formatTime } from '@/utils';
import Layout from '@/components/Layout';
// 添加用户状态文本函数 interface User {
const getUserStatusText = (isActive: boolean): string => { id: string;
return isActive ? '活跃' : '非活跃'; name: string;
}; email: string;
phone: string;
company: string;
role: 'admin' | 'user' | 'interpreter';
status: 'active' | 'inactive' | 'pending';
created_at: string;
last_login: string;
total_calls: number;
total_spent: number;
}
interface UserFilters { interface UserFilters {
search: string; search: string;
userType: 'all' | 'individual' | 'enterprise'; role: string;
status: 'all' | 'active' | 'inactive'; status: string;
sortBy: 'created_at' | 'full_name' | 'last_login'; company: string;
sortOrder: 'asc' | 'desc';
} }
export default function UsersPage() { export default function Users() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]); const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState<UserFilters>({ const [filters, setFilters] = useState<UserFilters>({
search: '', search: '',
userType: 'all', role: '',
status: 'all', status: '',
sortBy: 'created_at', company: ''
sortOrder: 'desc'
}); });
const router = useRouter();
const pageSize = 20; const pageSize = 10;
// 获取用户列表 useEffect(() => {
const fetchUsers = async (page = 1) => { fetchUsers();
}, [currentPage, filters]);
const fetchUsers = async () => {
try { try {
setLoading(true); setLoading(true);
// 检查是否为演示模式 // 模拟加载延迟
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; await new Promise(resolve => setTimeout(resolve, 800));
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemo);
if (isDemo) {
// 使用演示数据 // 使用演示数据
const result = await getDemoData.users(filters); const mockUsers: User[] = [
setUsers(result.data); {
setTotalCount(result.total); id: '1',
setTotalPages(Math.ceil(result.total / pageSize)); name: '张三',
setCurrentPage(page); email: 'zhangsan@example.com',
} else { phone: '13800138001',
// 使用真实数据 company: 'ABC科技有限公司',
let query = supabase role: 'user',
.from(TABLES.USERS) status: 'active',
.select('*', { count: 'exact' }); created_at: '2024-01-15T10:30:00Z',
last_login: '2024-01-20T14:25:00Z',
total_calls: 25,
total_spent: 1250
},
{
id: '2',
name: '李四',
email: 'lisi@example.com',
phone: '13800138002',
company: 'XYZ贸易公司',
role: 'user',
status: 'active',
created_at: '2024-01-10T09:15:00Z',
last_login: '2024-01-19T16:45:00Z',
total_calls: 18,
total_spent: 890
},
{
id: '3',
name: '王五',
email: 'wangwu@example.com',
phone: '13800138003',
company: '翻译服务中心',
role: 'interpreter',
status: 'active',
created_at: '2024-01-05T11:20:00Z',
last_login: '2024-01-20T10:30:00Z',
total_calls: 156,
total_spent: 0
},
{
id: '4',
name: '赵六',
email: 'zhaoliu@example.com',
phone: '13800138004',
company: '管理员',
role: 'admin',
status: 'active',
created_at: '2024-01-01T08:00:00Z',
last_login: '2024-01-20T18:00:00Z',
total_calls: 5,
total_spent: 0
},
{
id: '5',
name: '孙七',
email: 'sunqi@example.com',
phone: '13800138005',
company: '新用户公司',
role: 'user',
status: 'pending',
created_at: '2024-01-18T15:30:00Z',
last_login: '',
total_calls: 0,
total_spent: 0
},
{
id: '6',
name: '周八',
email: 'zhouba@example.com',
phone: '13800138006',
company: '暂停用户公司',
role: 'user',
status: 'inactive',
created_at: '2024-01-12T13:45:00Z',
last_login: '2024-01-15T09:20:00Z',
total_calls: 8,
total_spent: 320
}
];
// 应用过滤器
let filteredUsers = mockUsers;
// 搜索过滤
if (filters.search) { if (filters.search) {
query = query.or(`full_name.ilike.%${filters.search}%,email.ilike.%${filters.search}%`); filteredUsers = filteredUsers.filter(user =>
user.name.toLowerCase().includes(filters.search.toLowerCase()) ||
user.email.toLowerCase().includes(filters.search.toLowerCase()) ||
user.company.toLowerCase().includes(filters.search.toLowerCase())
);
} }
// 状态过滤 if (filters.role) {
if (filters.status !== 'all') { filteredUsers = filteredUsers.filter(user => user.role === filters.role);
const isActive = filters.status === 'active';
query = query.eq('is_active', isActive);
} }
// 排序 if (filters.status) {
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' }); filteredUsers = filteredUsers.filter(user => user.status === filters.status);
}
if (filters.company) {
filteredUsers = filteredUsers.filter(user =>
user.company.toLowerCase().includes(filters.company.toLowerCase())
);
}
// 分页 // 分页
const from = (page - 1) * pageSize; const startIndex = (currentPage - 1) * pageSize;
const to = from + pageSize - 1; const endIndex = startIndex + pageSize;
query = query.range(from, to); const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
const { data, error, count } = await query; setUsers(paginatedUsers);
setTotalCount(filteredUsers.length);
if (error) throw error; setTotalPages(Math.ceil(filteredUsers.length / pageSize));
setUsers(data || []);
setTotalCount(count || 0);
setTotalPages(Math.ceil((count || 0) / pageSize));
setCurrentPage(page);
}
} catch (error) { } catch (error) {
console.error('Error fetching users:', error); console.error('Failed to fetch users:', error);
toast.error('获取用户列表失败'); toast.error('加载用户数据失败');
// 如果真实数据获取失败,切换到演示模式
if (!isDemoMode) {
setIsDemoMode(true);
const result = await getDemoData.users(filters);
setUsers(result.data);
setTotalCount(result.total);
setTotalPages(Math.ceil(result.total / pageSize));
setCurrentPage(page);
}
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// 处理筛选变更 const handleSearch = (value: string) => {
const handleFilterChange = (key: keyof UserFilters, value: any) => { setFilters(prev => ({ ...prev, search: value }));
setFilters(prev => ({
...prev,
[key]: value
}));
};
// 应用筛选
const applyFilters = () => {
setCurrentPage(1); setCurrentPage(1);
fetchUsers(1);
}; };
// 重置筛选 const handleFilterChange = (key: keyof UserFilters, value: string) => {
const resetFilters = () => { setFilters(prev => ({ ...prev, [key]: value }));
setFilters({
search: '',
userType: 'all',
status: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
setCurrentPage(1); setCurrentPage(1);
fetchUsers(1);
}; };
useEffect(() => { const handleSelectUser = (userId: string) => {
setSelectedUsers(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
};
const handleSelectAll = () => {
if (selectedUsers.length === users.length) {
setSelectedUsers([]);
} else {
setSelectedUsers(users.map(user => user.id));
}
};
const handleBulkAction = async (action: string) => {
if (selectedUsers.length === 0) {
toast.error('请选择要操作的用户');
return;
}
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
switch (action) {
case 'activate':
toast.success(`已激活 ${selectedUsers.length} 个用户`);
break;
case 'deactivate':
toast.success(`已停用 ${selectedUsers.length} 个用户`);
break;
case 'delete':
toast.success(`已删除 ${selectedUsers.length} 个用户`);
break;
default:
break;
}
setSelectedUsers([]);
fetchUsers(); fetchUsers();
}, []); } catch (error) {
toast.error('操作失败');
}
};
const handleExport = async () => {
try {
toast.loading('正在导出用户数据...', { id: 'export' });
// 模拟导出延迟
await new Promise(resolve => setTimeout(resolve, 2000));
toast.success('用户数据导出成功', { id: 'export' });
} catch (error) {
toast.error('导出失败', { id: 'export' });
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'text-green-800 bg-green-100';
case 'inactive':
return 'text-red-800 bg-red-100';
case 'pending':
return 'text-yellow-800 bg-yellow-100';
default:
return 'text-gray-800 bg-gray-100';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return '活跃';
case 'inactive':
return '停用';
case 'pending':
return '待审核';
default:
return status;
}
};
const getRoleText = (role: string) => {
switch (role) {
case 'admin':
return '管理员';
case 'user':
return '用户';
case 'interpreter':
return '翻译员';
default:
return role;
}
};
const getRoleColor = (role: string) => {
switch (role) {
case 'admin':
return 'text-purple-800 bg-purple-100';
case 'user':
return 'text-blue-800 bg-blue-100';
case 'interpreter':
return 'text-green-800 bg-green-100';
default:
return 'text-gray-800 bg-gray-100';
}
};
return ( return (
<Layout> <>
<Head> <Head>
<title> - </title> <title> - </title>
</Head> </Head>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <DashboardLayout title="用户管理">
<div className="px-4 py-6 sm:px-0"> <div className="space-y-6">
{/* 页面标题 */} {/* 页面标题和操作 */}
<div className="flex items-center justify-between mb-6"> <div className="sm:flex sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1> <h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-700">
</p>
</div>
<div className="mt-4 sm:mt-0 sm:flex sm:space-x-3">
<button
onClick={handleExport}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/dashboard/users/new')}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<PlusIcon className="h-4 w-4 mr-2" />
</button>
</div>
</div> </div>
{/* 搜索和筛选 */} {/* 搜索和过滤器 */}
<div className="bg-white shadow rounded-lg mb-6"> <div className="bg-white shadow rounded-lg p-6">
<div className="px-4 py-5 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* 搜索框 */} <div>
<div className="lg:col-span-2"> <label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="relative"> <div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" /> <MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div> </div>
<input <input
type="text" type="text"
placeholder="搜索用户名或邮箱..." id="search"
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500" className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="搜索用户名、邮箱或公司..."
value={filters.search}
onChange={(e) => handleSearch(e.target.value)}
/> />
</div> </div>
</div> </div>
{/* 状态筛选 */}
<div> <div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select <select
id="role"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={filters.role}
onChange={(e) => handleFilterChange('role', e.target.value)}
>
<option value=""></option>
<option value="admin"></option>
<option value="user"></option>
<option value="interpreter"></option>
</select>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="status"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={filters.status} value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)} onChange={(e) => handleFilterChange('status', e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
> >
<option value="all"></option> <option value=""></option>
<option value="active"></option> <option value="active"></option>
<option value="inactive"></option> <option value="inactive"></option>
<option value="pending"></option>
</select> </select>
</div> </div>
{/* 排序 */}
<div> <div>
<select <label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
value={`${filters.sortBy}-${filters.sortOrder}`}
onChange={(e) => { </label>
const [sortBy, sortOrder] = e.target.value.split('-'); <input
handleFilterChange('sortBy', sortBy); type="text"
handleFilterChange('sortOrder', sortOrder); id="company"
}} className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500" placeholder="过滤公司..."
> value={filters.company}
<option value="created_at-desc"> ()</option> onChange={(e) => handleFilterChange('company', e.target.value)}
<option value="created_at-asc"> ()</option>
<option value="full_name-asc"> (A-Z)</option>
<option value="full_name-desc"> (Z-A)</option>
</select>
</div>
</div>
<div className="mt-4 flex space-x-3">
<button
onClick={applyFilters}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
</button>
<button
onClick={resetFilters}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
{/* 用户列表 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="loading-spinner"></div>
</div>
) : users.length === 0 ? (
<div className="text-center py-12">
<UserIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500">
</p>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
({totalCount} )
</h3>
</div>
<div className="space-y-4">
{users.map((user) => (
<div
key={user.id}
className="p-4 border border-gray-200 rounded-lg"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<img
className="h-10 w-10 rounded-full"
src={user.avatar_url || `https://ui-avatars.com/api/?name=${user.full_name || user.email}`}
alt={user.full_name || user.email}
/> />
<div>
<h4 className="text-sm font-medium text-gray-900">
{user.full_name || user.email}
</h4>
<p className="text-sm text-gray-500">{user.email}</p>
<p className="text-xs text-gray-400">
: {formatTime(user.created_at)}
</p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4"> </div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.is_active {/* 批量操作 */}
? 'bg-green-100 text-green-800' {selectedUsers.length > 0 && (
: 'bg-red-100 text-red-800' <div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
}`}> <div className="flex items-center justify-between">
{getUserStatusText(user.is_active)} <div className="flex items-center">
<span className="text-sm font-medium text-blue-900">
{selectedUsers.length}
</span> </span>
</div> </div>
<div className="flex space-x-2">
<button
onClick={() => handleBulkAction('activate')}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200"
>
<CheckCircleIcon className="h-4 w-4 mr-1" />
</button>
<button
onClick={() => handleBulkAction('deactivate')}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-yellow-700 bg-yellow-100 hover:bg-yellow-200"
>
<XCircleIcon className="h-4 w-4 mr-1" />
</button>
<button
onClick={() => handleBulkAction('delete')}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200"
>
<TrashIcon className="h-4 w-4 mr-1" />
</button>
</div> </div>
</div> </div>
</div>
)}
{/* 用户列表 */}
<div className="bg-white shadow rounded-lg overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="relative px-6 py-3">
<input
type="checkbox"
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={selectedUsers.length === users.length && users.length > 0}
onChange={handleSelectAll}
/>
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
/
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only"></span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="relative px-6 py-4">
<input
type="checkbox"
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={selectedUsers.includes(user.id)}
onChange={() => handleSelectUser(user.id)}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<UserIcon className="h-6 w-6 text-gray-600" />
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
<div className="text-sm text-gray-500 flex items-center">
<EnvelopeIcon className="h-4 w-4 mr-1" />
{user.email}
</div>
<div className="text-sm text-gray-500 flex items-center">
<PhoneIcon className="h-4 w-4 mr-1" />
{user.phone}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="space-y-1">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}>
{getRoleText(user.role)}
</span>
<br />
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}>
{getStatusText(user.status)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<BuildingOfficeIcon className="h-4 w-4 mr-2 text-gray-400" />
{user.company}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div>: {user.total_calls} </div>
<div>: ¥{user.total_spent}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<CalendarIcon className="h-4 w-4 mr-1" />
{user.last_login ? formatTime(user.last_login) : '从未登录'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => router.push(`/dashboard/users/${user.id}`)}
className="text-blue-600 hover:text-blue-900"
>
<EyeIcon className="h-4 w-4" />
</button>
<button
onClick={() => router.push(`/dashboard/users/${user.id}/edit`)}
className="text-yellow-600 hover:text-yellow-900"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => {
if (confirm('确定要删除这个用户吗?')) {
toast.success('用户删除成功');
}
}}
className="text-red-600 hover:text-red-900"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))} ))}
</tbody>
</table>
</div> </div>
{/* 分页 */} {/* 分页 */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="mt-6 flex items-center justify-between"> <div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden"> <div className="flex-1 flex justify-between sm:hidden">
<button <button
onClick={() => fetchUsers(currentPage - 1)} onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
</button> </button>
<button <button
onClick={() => fetchUsers(currentPage + 1)} onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50" className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
</button> </button>
@@ -317,27 +613,23 @@ export default function UsersPage() {
<div> <div>
<p className="text-sm text-gray-700"> <p className="text-sm text-gray-700">
<span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> {' '} <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> {' '}
<span className="font-medium"> <span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span>
{Math.min(currentPage * pageSize, totalCount)} <span className="font-medium">{totalCount}</span>
</span>{' '}
<span className="font-medium">{totalCount}</span>
</p> </p>
</div> </div>
<div> <div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px"> <nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button <button
onClick={() => fetchUsers(currentPage - 1)} onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<ChevronLeftIcon className="h-5 w-5" />
</button> </button>
{[...Array(Math.min(totalPages, 5))].map((_, i) => { {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
const page = i + 1;
return (
<button <button
key={page} key={page}
onClick={() => fetchUsers(page)} onClick={() => setCurrentPage(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${ className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage page === currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600' ? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
@@ -346,25 +638,24 @@ export default function UsersPage() {
> >
{page} {page}
</button> </button>
); ))}
})}
<button <button
onClick={() => fetchUsers(currentPage + 1)} onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50" className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
> >
<ChevronRightIcon className="h-5 w-5" />
</button> </button>
</nav> </nav>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </>
</div>
)} )}
</div> </div>
</div> </div>
</Layout> </DashboardLayout>
</>
); );
} }
+44
View File
@@ -0,0 +1,44 @@
const https = require('http');
const data = JSON.stringify({
email: 'admin@example.com',
password: 'admin123'
});
const options = {
hostname: 'localhost',
port: 3000,
path: '/api/auth/admin-login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': data.length
}
};
const req = https.request(options, (res) => {
console.log(`状态码: ${res.statusCode}`);
console.log(`响应头: ${JSON.stringify(res.headers)}`);
let body = '';
res.on('data', (chunk) => {
body += chunk;
});
res.on('end', () => {
console.log('响应体:', body);
try {
const jsonResponse = JSON.parse(body);
console.log('解析后的响应:', JSON.stringify(jsonResponse, null, 2));
} catch (e) {
console.log('无法解析JSON响应');
}
});
});
req.on('error', (error) => {
console.error('请求错误:', error);
});
req.write(data);
req.end();
+62
View File
@@ -0,0 +1,62 @@
const http = require('http');
// 测试登录API
function testLogin() {
const postData = JSON.stringify({
email: 'admin@example.com',
password: 'admin123'
});
const options = {
hostname: 'localhost',
port: 3000,
path: '/api/auth/admin-login',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData)
}
};
const req = http.request(options, (res) => {
console.log(`状态码: ${res.statusCode}`);
console.log(`响应头: ${JSON.stringify(res.headers)}`);
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
console.log('响应体:', data);
try {
const parsedData = JSON.parse(data);
console.log('解析后的响应:', JSON.stringify(parsedData, null, 2));
if (parsedData.success) {
console.log('✅ 登录测试成功!');
console.log('用户信息:', parsedData.user);
console.log('JWT令牌已生成');
} else {
console.log('❌ 登录测试失败:', parsedData.error);
}
} catch (error) {
console.log('❌ 解析响应失败:', error.message);
}
});
});
req.on('error', (e) => {
console.error(`请求错误: ${e.message}`);
});
req.write(postData);
req.end();
}
// 等待服务器启动
console.log('等待服务器启动...');
setTimeout(() => {
console.log('开始测试登录流程...');
testLogin();
}, 3000);
+520
View File
@@ -0,0 +1,520 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export interface Database {
public: {
Tables: {
users: {
Row: {
id: string
email: string
name: string
phone?: string
user_type: 'individual' | 'enterprise'
status: 'active' | 'inactive'
enterprise_id?: string
avatar_url?: string
created_at: string
updated_at: string
}
Insert: {
id?: string
email: string
name: string
phone?: string
user_type: 'individual' | 'enterprise'
status?: 'active' | 'inactive'
enterprise_id?: string
avatar_url?: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
email?: string
name?: string
phone?: string
user_type?: 'individual' | 'enterprise'
status?: 'active' | 'inactive'
enterprise_id?: string
avatar_url?: string
created_at?: string
updated_at?: string
}
}
enterprises: {
Row: {
id: string
name: string
contact_person: string
contact_email: string
contact_phone: string
address: string
tax_number?: string
status: 'active' | 'inactive'
created_at: string
updated_at: string
}
Insert: {
id?: string
name: string
contact_person: string
contact_email: string
contact_phone: string
address: string
tax_number?: string
status?: 'active' | 'inactive'
created_at?: string
updated_at?: string
}
Update: {
id?: string
name?: string
contact_person?: string
contact_email?: string
contact_phone?: string
address?: string
tax_number?: string
status?: 'active' | 'inactive'
created_at?: string
updated_at?: string
}
}
enterprise_contracts: {
Row: {
id: string
enterprise_id: string
contract_number: string
contract_type: string
start_date: string
end_date: string
total_amount: number
currency: string
status: 'active' | 'expired' | 'terminated'
service_rates: Json
created_at: string
updated_at: string
}
Insert: {
id?: string
enterprise_id: string
contract_number: string
contract_type: string
start_date: string
end_date: string
total_amount: number
currency?: string
status?: 'active' | 'expired' | 'terminated'
service_rates?: Json
created_at?: string
updated_at?: string
}
Update: {
id?: string
enterprise_id?: string
contract_number?: string
contract_type?: string
start_date?: string
end_date?: string
total_amount?: number
currency?: string
status?: 'active' | 'expired' | 'terminated'
service_rates?: Json
created_at?: string
updated_at?: string
}
}
enterprise_bills: {
Row: {
id: string
enterprise_id: string
bill_number: string
billing_period_start: string
billing_period_end: string
total_amount: number
currency: string
status: 'draft' | 'sent' | 'paid' | 'overdue'
items: Json
created_at: string
updated_at: string
}
Insert: {
id?: string
enterprise_id: string
bill_number: string
billing_period_start: string
billing_period_end: string
total_amount: number
currency?: string
status?: 'draft' | 'sent' | 'paid' | 'overdue'
items?: Json
created_at?: string
updated_at?: string
}
Update: {
id?: string
enterprise_id?: string
bill_number?: string
billing_period_start?: string
billing_period_end?: string
total_amount?: number
currency?: string
status?: 'draft' | 'sent' | 'paid' | 'overdue'
items?: Json
created_at?: string
updated_at?: string
}
}
orders: {
Row: {
id: string
order_number: string
user_id: string
user_name: string
user_email: string
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation' | 'document_translation'
service_name: string
source_language: string
target_language: string
duration?: number
status: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'
priority: 'urgent' | 'high' | 'normal' | 'low'
cost: number
currency: string
scheduled_time?: string
started_time?: string
completed_time?: string
interpreter_id?: string
interpreter_name?: string
notes?: string
created_at: string
updated_at: string
}
Insert: {
id?: string
order_number: string
user_id: string
user_name: string
user_email: string
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation' | 'document_translation'
service_name: string
source_language: string
target_language: string
duration?: number
status?: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'
priority?: 'urgent' | 'high' | 'normal' | 'low'
cost: number
currency?: string
scheduled_time?: string
started_time?: string
completed_time?: string
interpreter_id?: string
interpreter_name?: string
notes?: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
order_number?: string
user_id?: string
user_name?: string
user_email?: string
service_type?: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation' | 'document_translation'
service_name?: string
source_language?: string
target_language?: string
duration?: number
status?: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'
priority?: 'urgent' | 'high' | 'normal' | 'low'
cost?: number
currency?: string
scheduled_time?: string
started_time?: string
completed_time?: string
interpreter_id?: string
interpreter_name?: string
notes?: string
created_at?: string
updated_at?: string
}
}
invoices: {
Row: {
id: string
invoice_number: string
user_id: string
user_name: string
user_email: string
order_id?: string
invoice_type: 'personal' | 'enterprise'
personal_name?: string
company_name?: string
tax_number?: string
company_address?: string
subtotal: number
tax_amount: number
total_amount: number
currency: string
status: 'draft' | 'issued' | 'paid' | 'cancelled'
issue_date?: string
due_date?: string
paid_date?: string
items: Json
created_at: string
updated_at: string
}
Insert: {
id?: string
invoice_number: string
user_id: string
user_name: string
user_email: string
order_id?: string
invoice_type: 'personal' | 'enterprise'
personal_name?: string
company_name?: string
tax_number?: string
company_address?: string
subtotal: number
tax_amount: number
total_amount: number
currency?: string
status?: 'draft' | 'issued' | 'paid' | 'cancelled'
issue_date?: string
due_date?: string
paid_date?: string
items?: Json
created_at?: string
updated_at?: string
}
Update: {
id?: string
invoice_number?: string
user_id?: string
user_name?: string
user_email?: string
order_id?: string
invoice_type?: 'personal' | 'enterprise'
personal_name?: string
company_name?: string
tax_number?: string
company_address?: string
subtotal?: number
tax_amount?: number
total_amount?: number
currency?: string
status?: 'draft' | 'issued' | 'paid' | 'cancelled'
issue_date?: string
due_date?: string
paid_date?: string
items?: Json
created_at?: string
updated_at?: string
}
}
interpreters: {
Row: {
id: string
name: string
email: string
phone: string
languages: string[]
specialties: string[]
status: 'online' | 'offline' | 'busy'
rating: number
total_calls: number
hourly_rate: number
currency: string
avatar_url?: string
bio?: string
created_at: string
updated_at: string
}
Insert: {
id?: string
name: string
email: string
phone: string
languages: string[]
specialties: string[]
status?: 'online' | 'offline' | 'busy'
rating?: number
total_calls?: number
hourly_rate: number
currency?: string
avatar_url?: string
bio?: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
name?: string
email?: string
phone?: string
languages?: string[]
specialties?: string[]
status?: 'online' | 'offline' | 'busy'
rating?: number
total_calls?: number
hourly_rate?: number
currency?: string
avatar_url?: string
bio?: string
created_at?: string
updated_at?: string
}
}
calls: {
Row: {
id: string
user_id: string
interpreter_id?: string
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation'
source_language: string
target_language: string
status: 'waiting' | 'connecting' | 'active' | 'completed' | 'failed'
duration?: number
cost: number
currency: string
quality_rating?: number
started_at?: string
ended_at?: string
created_at: string
updated_at: string
}
Insert: {
id?: string
user_id: string
interpreter_id?: string
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation'
source_language: string
target_language: string
status?: 'waiting' | 'connecting' | 'active' | 'completed' | 'failed'
duration?: number
cost: number
currency?: string
quality_rating?: number
started_at?: string
ended_at?: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
user_id?: string
interpreter_id?: string
service_type?: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation'
source_language?: string
target_language?: string
status?: 'waiting' | 'connecting' | 'active' | 'completed' | 'failed'
duration?: number
cost?: number
currency?: string
quality_rating?: number
started_at?: string
ended_at?: string
created_at?: string
updated_at?: string
}
}
documents: {
Row: {
id: string
user_id: string
filename: string
original_name: string
file_size: number
file_type: string
source_language: string
target_language: string
status: 'uploaded' | 'processing' | 'completed' | 'failed'
progress: number
cost: number
currency: string
translated_file_url?: string
created_at: string
updated_at: string
}
Insert: {
id?: string
user_id: string
filename: string
original_name: string
file_size: number
file_type: string
source_language: string
target_language: string
status?: 'uploaded' | 'processing' | 'completed' | 'failed'
progress?: number
cost: number
currency?: string
translated_file_url?: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
user_id?: string
filename?: string
original_name?: string
file_size?: number
file_type?: string
source_language?: string
target_language?: string
status?: 'uploaded' | 'processing' | 'completed' | 'failed'
progress?: number
cost?: number
currency?: string
translated_file_url?: string
created_at?: string
updated_at?: string
}
}
system_settings: {
Row: {
id: string
key: string
value: Json
description?: string
created_at: string
updated_at: string
}
Insert: {
id?: string
key: string
value: Json
description?: string
created_at?: string
updated_at?: string
}
Update: {
id?: string
key?: string
value?: Json
description?: string
created_at?: string
updated_at?: string
}
}
}
Views: {
[_ in never]: never
}
Functions: {
[_ in never]: never
}
Enums: {
[_ in never]: never
}
CompositeTypes: {
[_ in never]: never
}
}
}