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

- 更新 DashboardLayout 组件,统一使用演示模式布局
- 实现仪表盘页面的完整演示数据和功能
- 完成用户管理页面的演示模式,包含搜索、过滤、分页等功能
- 实现通话记录页面的演示数据和录音播放功能
- 完成翻译员管理页面的演示模式
- 实现订单管理页面的完整功能
- 完成发票管理页面的演示数据
- 更新文档管理页面
- 添加 utils.ts 工具函数库
- 完善 API 路由和数据库结构
- 修复各种 TypeScript 类型错误
- 统一界面风格和用户体验
This commit is contained in:
mars 2025-06-30 19:42:43 +08:00
parent 0b8be9377a
commit f20988b90c
36 changed files with 8752 additions and 3638 deletions

View File

@ -1,31 +1,31 @@
# Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
NEXT_PUBLIC_SUPABASE_URL=https://riwtulmitqioswmgwftg.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx...
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Twilio 配置
TWILIO_ACCOUNT_SID=your_twilio_account_sid
TWILIO_ACCOUNT_SID=AC0123456789abcdef0123456789abcdef
TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_API_KEY=your_twilio_api_key
TWILIO_API_SECRET=your_twilio_api_secret
TWILIO_API_KEY_SID=SK0123456789abcdef0123456789abcdef
TWILIO_API_KEY_SECRET=0123456789abcdef0123456789abcdef
# Stripe 配置
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
STRIPE_SECRET_KEY=your_stripe_secret_key
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51RTwLuDWamLO9gYlv7ZX0Jj2aLBkADGWmTC3NP0aoez3nEdnLlQiWH3KUie1C45CSa1ho3DvTm0GqR59X0sNTnqN00Q15Fq0zw
STRIPE_SECRET_KEY=sk_test_51RTwLuDWamLO9gYliBCJFtPob28ttoTtvsglGtyXrHkrnuppY2ScnVz7BRh1hCHzvOXcOyvMejBRVsx5vMpgKLVE0065W8VOU8
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
# ElevenLabs 配置
ELEVENLABS_API_KEY=your_elevenlabs_api_key
# JWT 密钥
JWT_SECRET=your_jwt_secret
JWT_SECRET=your_jwt_secret_key_here
# 应用配置
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
@ -41,4 +41,7 @@ MAX_FILE_SIZE=10485760 # 10MB
# 支付配置
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
DEPLOYMENT.md Normal file
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
FIXES.md Normal file
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月*

173
README.md
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
- **类型系统**TypeScript
- **样式框架**Tailwind CSS
- **图标库**Heroicons
- **状态管理**React Hooks
- **数据库**Supabase可选
- **部署**Vercel推荐
### 后端技术
- **数据库**Supabase (PostgreSQL)
- **身份验证**Supabase Auth
- **API**Next.js API Routes
- **实时功能**Supabase Realtime
### 第三方集成
- **支付处理**Stripe
- **通信服务**Twilio
- **AI服务**OpenAI
- **部署平台**Vercel
## 安装和运行
### 环境要求
- Node.js 18.0 或更高版本
- npm 或 yarn
- Supabase 项目(用于数据库和认证)
### 安装依赖
### 1. 安装依赖
```bash
npm install
# 或
yarn install
```
### 环境配置
### 2. 环境配置
复制 `.env.example``.env.local` 并配置必要的环境变量:
```bash
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
npm run dev
# 或
@ -92,14 +130,14 @@ yarn dev
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
### 构建生产版本
### 5. 构建生产版本
```bash
npm run build
# 或
yarn build
```
### 启动生产服务器
### 6. 启动生产服务器
```bash
npm start
# 或
@ -112,26 +150,84 @@ yarn start
├── components/ # 可复用组件
├── lib/ # 工具库和配置
│ ├── demo-data.ts # 演示数据
│ ├── supabase.ts # Supabase 配置
│ ├── supabase.ts # Supabase 配置和操作
│ └── utils.ts # 工具函数
├── pages/ # 页面组件
│ ├── api/ # API 路由
│ │ ├── auth/ # 认证 API
│ │ ├── orders/ # 订单 API
│ │ ├── users/ # 用户 API
│ │ └── ... # 其他 API
│ ├── auth/ # 认证页面
│ └── dashboard/ # 管理后台页面
├── database/ # 数据库相关文件
│ ├── schema.sql # 数据库结构
│ └── README.md # 数据库说明
├── types/ # TypeScript 类型定义
│ ├── database.ts # 数据库类型
│ └── auth.ts # 认证类型
├── public/ # 静态资源
├── styles/ # 样式文件
├── types/ # TypeScript 类型定义
└── 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. **企业合同费率**:优先级最高,适用于企业员工
2. **系统通用费率**:适用于个人用户和无合同企业
3. **默认费率**:系统兜底费率
### 实时功能
- **订单状态更新**:实时同步订单状态变化
- **通话监控**:实时监控活跃通话
- **消息通知**:即时消息推送
### 演示模式
项目支持演示模式,无需配置数据库即可体验完整功能:
- 自动检测 Supabase 配置
@ -151,6 +247,8 @@ yarn start
3. 配置环境变量
4. 部署完成
详细部署指南请参考 [DEPLOYMENT.md](./DEPLOYMENT.md)
### 其他平台部署
项目支持部署到任何支持 Next.js 的平台,如:
- Netlify
@ -160,15 +258,31 @@ yarn start
## 环境变量
### 必需配置
```bash
# Supabase 配置(可选)
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
# 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
# 其他第三方服务配置
TWILIO_ACCOUNT_SID=your_twilio_sid
TWILIO_AUTH_TOKEN=your_twilio_token
OPENAI_API_KEY=your_openai_key
# 应用配置
NEXTAUTH_SECRET=your-nextauth-secret
JWT_SECRET=your-jwt-secret
```
### 可选配置
```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`)
5. 创建 Pull Request
### 开发规范
- 使用 TypeScript 进行类型安全开发
- 遵循 ESLint 和 Prettier 代码规范
- 编写单元测试
- 更新相关文档
## 许可证
本项目采用 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)
- 问题反馈:请在 GitLab Issues 中提交
## 更新日志
### v1.1.0 (2024-01-30)
- ✅ 集成 Supabase 数据库和身份验证
- ✅ 实现完整的 API 接口
- ✅ 添加用户认证和权限控制
- ✅ 优化数据库结构和安全策略
- ✅ 完善部署文档和指南
### v1.0.0 (2024-01-30)
- ✅ 完成企业服务管理功能
- ✅ 完成订单管理功能

54
check-table.js Normal file
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();

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
create-admin.js Normal file
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
database/README.md Normal file
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
database/schema.sql Normal file
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
insert-admin.js Normal file
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
lib/api-service.ts Normal file
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
lib/api-utils.ts Normal file
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
}
}

View File

@ -1,5 +1,5 @@
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';
@ -9,7 +9,7 @@ const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'demo-servic
// 检查是否在开发环境中使用默认配置
const isDemoMode = supabaseUrl === 'https://demo.supabase.co';
// 客户端使用的 Supabase 客户端
// 单一的 Supabase 客户端实例
export const supabase = isDemoMode
? createClient(supabaseUrl, supabaseAnonKey, {
realtime: {
@ -22,23 +22,13 @@ export const supabase = isDemoMode
autoRefreshToken: false,
},
})
: createClient(supabaseUrl, supabaseAnonKey);
// 组件中使用的 Supabase 客户端
export const createSupabaseClient = () => {
if (isDemoMode) {
// 在演示模式下返回一个模拟客户端
return {
: createClient<Database>(supabaseUrl, supabaseAnonKey, {
auth: {
getUser: () => Promise.resolve({ data: { user: null }, error: null }),
signInWithPassword: () => Promise.resolve({ data: null, error: { message: '演示模式:请配置 Supabase 环境变量' } }),
signOut: () => Promise.resolve({ error: null }),
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
} as any;
}
return createClientComponentClient();
};
});
// 服务端使用的 Supabase 客户端(具有管理员权限)
export const supabaseAdmin = isDemoMode
@ -56,6 +46,7 @@ export const supabaseAdmin = isDemoMode
: createClient(supabaseUrl, supabaseServiceKey, {
auth: {
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({
email,
password,
options: {
data: metadata,
data: userData,
},
});
if (error) throw error;
@ -137,6 +128,20 @@ export const auth = {
if (error) throw error;
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;
},
// 分页查询函数
paginate: async <T>(
table: string,
page: number = 1,
limit: number = 10,
query?: any,
orderBy?: { column: string; ascending?: boolean }
) => {
const from = (page - 1) * limit;
const to = from + limit - 1;
// 根据条件查询单条记录
findOne: async <T>(table: string, conditions: Record<string, any>, select?: string) => {
let query = supabase.from(table).select(select || '*');
Object.entries(conditions).forEach(([key, value]) => {
query = query.eq(key, value);
});
let queryBuilder = supabase
.from(table)
.select(query || '*', { count: 'exact' })
.range(from, to);
const { data, error } = await query.single();
if (error) throw error;
return data as T;
},
if (orderBy) {
queryBuilder = queryBuilder.order(orderBy.column, {
ascending: orderBy.ascending ?? true,
// 根据条件查询多条记录
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;
return data as T[];
},
return {
data: data as T[],
total: count || 0,
page,
limit,
has_more: (count || 0) > page * limit,
};
// 计数查询
count: async (table: string, conditions?: Record<string, any>) => {
let query = supabase.from(table).select('*', { count: 'exact', head: true });
if (conditions) {
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 = {
// 上传文件
upload: async (bucket: string, path: string, file: File) => {
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, {
cacheControl: '3600',
upsert: false,
});
.upload(path, file);
if (error) throw error;
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) => {
const { data } = supabase.storage
.from(bucket)
@ -248,43 +293,68 @@ export const storage = {
},
};
// 实时订阅函数
export const realtime = {
// 订阅表变化
subscribe: (
table: string,
callback: (payload: any) => void,
filter?: string
) => {
if (isDemoMode) {
// 演示模式下返回模拟的订阅对象
return {
unsubscribe: () => {},
};
}
// 用户类型定义
export type UserRole = 'admin' | 'interpreter' | 'client' | 'enterprise';
const channel = supabase
.channel(`${table}-changes`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table,
filter,
},
callback
)
.subscribe();
return channel;
// 用户权限检查
export const permissions = {
// 检查用户是否有特定权限
hasPermission: (userRole: UserRole, requiredRole: UserRole) => {
const roleHierarchy: Record<UserRole, number> = {
'client': 1,
'interpreter': 2,
'enterprise': 3,
'admin': 4,
};
return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
},
// 取消订阅
unsubscribe: (channel: any) => {
if (isDemoMode) {
return;
}
supabase.removeChannel(channel);
},
};
// 检查用户是否为管理员
isAdmin: (userRole: UserRole) => userRole === 'admin',
// 检查用户是否为翻译员
isInterpreter: (userRole: UserRole) => userRole === 'interpreter',
// 检查用户是否为企业用户
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
lib/utils.ts Normal file
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
package-lock.json generated
View File

@ -15,13 +15,16 @@
"@supabase/auth-helpers-nextjs": "^0.8.7",
"@supabase/supabase-js": "^2.38.5",
"@tailwindcss/forms": "^0.5.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-table": "^7.7.17",
"autoprefixer": "^10.4.16",
"bcryptjs": "^3.0.2",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.294.0",
"next": "^14.0.4",
"postcss": "^8.4.32",
@ -759,6 +762,20 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"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": {
"version": "20.19.2",
"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",
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",

View File

@ -17,13 +17,16 @@
"@supabase/auth-helpers-nextjs": "^0.8.7",
"@supabase/supabase-js": "^2.38.5",
"@tailwindcss/forms": "^0.5.10",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-table": "^7.7.17",
"autoprefixer": "^10.4.16",
"bcryptjs": "^3.0.2",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.294.0",
"next": "^14.0.4",
"postcss": "^8.4.32",

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
pages/api/auth/login.ts Normal file
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
pages/api/auth/logout.ts Normal file
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
pages/api/auth/me.ts Normal file
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
pages/api/auth/register.ts Normal file
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
pages/api/orders/index.ts Normal file
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: '方法不允许'
})
}
}

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: '连接测试失败'
});
}
}

View File

@ -1,90 +1,95 @@
import { useState } from 'react';
import React, { useState } from 'react';
import { useRouter } from 'next/router';
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 { auth } from '@/lib/supabase';
interface LoginForm {
email: string;
password: string;
}
export default function Login() {
const LoginPage = () => {
const router = useRouter();
const [form, setForm] = useState<LoginForm>({
const [formData, setFormData] = useState({
email: '',
password: '',
password: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
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 { name, value } = e.target;
setForm(prev => ({
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.email || !form.password) {
toast.error('请填写所有必填字段');
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 fillDemoAccount = (email: string, password: string) => {
if (loading || isRedirecting) return;
setFormData({ email, password });
setError('');
};
// 填入测试账号
const fillTestAccount = () => {
setForm({
email: 'admin@demo.com',
password: 'admin123'
});
};
// 如果正在重定向,显示加载状态
if (isRedirecting) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<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 (
<>
<Head>
<title> - </title>
<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>
@ -92,33 +97,35 @@ export default function Login() {
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</p>
</div>
{/* 测试账号提示 */}
{/* 预设账号提示 */}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>admin@demo.com</p>
<p>admin123</p>
<button
type="button"
onClick={fillTestAccount}
className="mt-2 text-xs text-blue-600 hover:text-blue-500 underline"
>
</button>
</div>
<h3 className="text-sm font-medium text-blue-800 mb-2"></h3>
<div className="space-y-2 text-xs text-blue-700">
<div className="flex justify-between items-center">
<span>admin@example.com / admin123</span>
<button
type="button"
onClick={() => fillDemoAccount('admin@example.com', 'admin123')}
className="text-blue-600 hover:text-blue-800 underline disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading || isRedirecting}
>
使
</button>
</div>
</div>
</div>
<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>
<label htmlFor="email" className="sr-only">
@ -130,9 +137,10 @@ export default function Login() {
type="email"
autoComplete="email"
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"
placeholder="管理员邮箱"
value={form.email}
disabled={loading || isRedirecting}
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"
placeholder="邮箱地址"
value={formData.email}
onChange={handleInputChange}
/>
</div>
@ -146,15 +154,17 @@ export default function Login() {
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
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"
placeholder="管理员密码"
value={form.password}
disabled={loading || isRedirecting}
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"
placeholder="密码"
value={formData.password}
onChange={handleInputChange}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
disabled={loading || isRedirecting}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
@ -165,44 +175,34 @@ export default function Login() {
</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>
<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-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"
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-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 ? (
<div className="flex items-center">
<div className="loading-spinner-sm mr-2"></div>
<span className="flex items-center">
<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>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
</p>
</div>
</form>
</div>
</div>
</>
);
}
};
export default LoginPage;

223
pages/auth/register.tsx Normal file
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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,484 +1,373 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import {
PhoneIcon,
VideoCameraIcon,
UserGroupIcon,
ClockIcon,
import DashboardLayout from '../../components/Layout/DashboardLayout';
import { getDemoData } from '../../lib/demo-data';
import {
UsersIcon,
PhoneIcon,
DocumentTextIcon,
CurrencyDollarIcon,
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
PlayIcon,
StopIcon,
UserPlusIcon,
ArrowRightOnRectangleIcon
ArrowUpIcon,
ArrowDownIcon,
EyeIcon
} 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 {
user?: User;
interface DashboardStats {
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) {
const router = useRouter();
interface RecentActivity {
id: string;
type: 'call' | 'order' | 'user' | 'system';
title: string;
description: string;
time: string;
status: 'success' | 'warning' | 'error' | 'info';
}
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [activities, setActivities] = useState<RecentActivity[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<CallStats>({
total_calls_today: 0,
active_calls: 0,
average_response_time: 0,
online_interpreters: 0,
total_revenue_today: 0,
currency: 'CNY',
});
const [activeCalls, setActiveCalls] = useState<Call[]>([]);
const [onlineInterpreters, setOnlineInterpreters] = useState<Interpreter[]>([]);
const [isDemoMode, setIsDemoMode] = useState(false);
// 获取仪表盘数据
const fetchDashboardData = async () => {
try {
setLoading(true);
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemoMode);
if (isDemoMode) {
// 使用演示数据
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',
});
// 转换通话数据格式
const formattedCalls = callsData
.filter(call => call.status === 'active')
.map(call => ({
id: call.id,
caller_id: call.user_id,
callee_id: call.interpreter_id,
call_type: 'audio' as const,
call_mode: 'human_interpreter' as const,
status: call.status as 'active',
start_time: call.start_time,
end_time: call.end_time,
duration: call.duration,
cost: call.cost,
currency: 'CNY' as const,
created_at: call.created_at,
updated_at: call.created_at,
}));
// 转换翻译员数据格式
const formattedInterpreters = interpretersData
.filter(interpreter => interpreter.status !== 'offline')
.map(interpreter => ({
id: interpreter.id,
user_id: interpreter.id,
name: interpreter.name,
avatar_url: interpreter.avatar_url,
languages: interpreter.languages,
specializations: interpreter.specialties,
hourly_rate: 100,
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 || []);
}
} catch (error) {
console.error('获取仪表盘数据失败:', 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 {
setLoading(false);
}
};
// 强制结束通话
const handleEndCall = async (callId: string) => {
try {
await db.update(TABLES.CALLS, callId, {
status: 'ended',
end_time: new Date().toISOString()
});
toast.success('通话已结束');
fetchDashboardData();
} catch (error) {
console.error('Error ending call:', error);
toast.error('结束通话失败');
}
};
// 分配翻译员
const handleAssignInterpreter = async (callId: string, interpreterId: string) => {
try {
await db.update(TABLES.CALLS, callId, {
callee_id: interpreterId,
call_mode: 'human_interpreter'
});
toast.success('翻译员已分配');
fetchDashboardData();
} catch (error) {
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;
}
const loadDashboardData = async () => {
try {
setLoading(true);
// 模拟加载延迟
await new Promise(resolve => setTimeout(resolve, 1000));
// 使用演示数据
const mockStats: DashboardStats = {
totalUsers: 1248,
activeUsers: 856,
totalCalls: 3456,
activeCalls: 12,
totalOrders: 2789,
pendingOrders: 45,
completedOrders: 2654,
totalRevenue: 125000,
monthlyRevenue: 15600,
activeInterpreters: 23
};
fetchDashboardData();
const mockActivities: RecentActivity[] = [
{
id: '1',
type: 'call',
title: '新通话开始',
description: '张三开始了中英互译通话',
time: '2分钟前',
status: 'success'
},
{
id: '2',
type: 'order',
title: '订单完成',
description: '订单ORD-2024-001已完成费用¥180',
time: '5分钟前',
status: 'success'
},
{
id: '3',
type: 'user',
title: '新用户注册',
description: 'ABC公司注册了企业账户',
time: '10分钟前',
status: 'info'
},
{
id: '4',
type: 'system',
title: '系统维护',
description: '系统将在今晚22:00-23:00进行维护',
time: '30分钟前',
status: 'warning'
},
{
id: '5',
type: 'call',
title: '通话异常',
description: '通话CALL-2024-003出现连接问题',
time: '1小时前',
status: 'error'
}
];
// 设置实时数据更新
const callsChannel = realtime.subscribe(
TABLES.CALLS,
() => {
fetchDashboardData();
setStats(mockStats);
setActivities(mockActivities);
} catch (error) {
console.error('Failed to load dashboard data:', error);
} finally {
setLoading(false);
}
);
const interpretersChannel = realtime.subscribe(
TABLES.INTERPRETERS,
() => {
fetchDashboardData();
}
);
// 每30秒刷新一次数据
const interval = setInterval(fetchDashboardData, 30000);
return () => {
clearInterval(interval);
realtime.unsubscribe(callsChannel);
realtime.unsubscribe(interpretersChannel);
};
}, [user, router]);
loadDashboardData();
}, []);
const getStatusColor = (status: string) => {
switch (status) {
case 'success':
return 'text-green-600 bg-green-100';
case 'warning':
return 'text-yellow-600 bg-yellow-100';
case 'error':
return 'text-red-600 bg-red-100';
default:
return 'text-blue-600 bg-blue-100';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case 'warning':
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
case 'error':
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
default:
return <ClockIcon className="h-5 w-5 text-blue-500" />;
}
};
if (loading) {
return (
<Layout user={user}>
<div className="min-h-screen flex items-center justify-center">
<div className="loading-spinner"></div>
<DashboardLayout title="仪表盘">
<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>
</Layout>
</DashboardLayout>
);
}
return (
<Layout user={user}>
<Head>
<title> - </title>
</Head>
<DashboardLayout title="仪表盘">
<div className="space-y-6">
{/* 欢迎区域 */}
<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="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<PhoneIcon className="h-6 w-6 text-gray-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.total_calls_today}
</dd>
</dl>
</div>
{/* 统计卡片 */}
<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="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<UsersIcon className="h-6 w-6 text-blue-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
12%
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<VideoCameraIcon className="h-6 w-6 text-green-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.active_calls}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<UserGroupIcon className="h-6 w-6 text-blue-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
线
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.online_interpreters}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<CurrencyDollarIcon className="h-6 w-6 text-yellow-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
</dt>
<dd className="text-lg font-medium text-gray-900">
{formatCurrency(stats.total_revenue_today, 'CNY')}
</dd>
</dl>
</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 className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 活跃通话列表 */}
<div className="lg:col-span-2">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
</h3>
<div className="space-y-4">
{activeCalls.length === 0 ? (
<p className="text-gray-500 text-center py-8">
</p>
) : (
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>
<p className="text-sm font-medium text-gray-900">
{getCallModeText(call.call_mode)}
</p>
<p className="text-xs text-gray-500">
{formatTime(call.start_time)}
</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}>
{getCallStatusText(call.status)}
</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
onClick={() => {/* 跳转到通话详情 */}}
className="p-1 text-blue-600 hover:text-blue-500"
title="查看详情"
>
<PlayIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
))
)}
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<PhoneIcon className="h-6 w-6 text-green-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{stats?.totalCalls || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
8%
</div>
</dd>
</dl>
</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 shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
线
</h3>
<div className="space-y-3">
{onlineInterpreters.length === 0 ? (
<p className="text-gray-500 text-center py-4">
线
</p>
) : (
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 className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span className="text-xs text-green-600">线</span>
</div>
</div>
))
)}
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<DocumentTextIcon className="h-6 w-6 text-yellow-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{stats?.totalOrders || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
15%
</div>
</dd>
</dl>
</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 className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<CurrencyDollarIcon className="h-6 w-6 text-purple-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">¥{stats?.totalRevenue?.toLocaleString() || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
22%
</div>
</dd>
</dl>
</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 className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 最近活动 */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"></h3>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start space-x-3">
<div className="flex-shrink-0">
{getStatusIcon(activity.status)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900">{activity.title}</div>
<div className="text-sm text-gray-500">{activity.description}</div>
<div className="text-xs text-gray-400 mt-1">{activity.time}</div>
</div>
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(activity.status)}`}>
{activity.status === 'success' && '成功'}
{activity.status === 'warning' && '警告'}
{activity.status === 'error' && '错误'}
{activity.status === 'info' && '信息'}
</div>
</div>
))}
</div>
<div className="mt-6">
<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">
<EyeIcon className="h-4 w-4 mr-2" />
</button>
</div>
</div>
</div>
{/* 快速操作 */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"></h3>
<div className="grid grid-cols-2 gap-4">
<button className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-left hover:bg-blue-100 transition-colors">
<div className="flex items-center">
<UsersIcon className="h-8 w-8 text-blue-600" />
<div className="ml-3">
<div className="text-sm font-medium text-blue-900"></div>
<div className="text-xs text-blue-700"></div>
</div>
</div>
</button>
<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>
</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>
</Layout>
</DashboardLayout>
);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import DashboardLayout from '../../components/Layout/DashboardLayout';
import { toast } from 'react-hot-toast';
import {
MagnifyingGlassIcon,
@ -9,306 +10,601 @@ import {
TrashIcon,
EyeIcon,
UserIcon,
BuildingOfficeIcon,
PhoneIcon,
EnvelopeIcon,
CalendarIcon,
CheckCircleIcon,
XCircleIcon,
ChevronLeftIcon,
ChevronRightIcon
ExclamationTriangleIcon,
ArrowDownTrayIcon,
FunnelIcon
} from '@heroicons/react/24/outline';
import { supabase, TABLES } from '@/lib/supabase';
import { getDemoData } from '@/lib/demo-data';
import { User } from '@/types';
import { formatTime } from '@/utils';
import Layout from '@/components/Layout';
import { getDemoData } from '../../lib/demo-data';
import { formatTime } from '../../lib/utils';
// 添加用户状态文本函数
const getUserStatusText = (isActive: boolean): string => {
return isActive ? '活跃' : '非活跃';
};
interface User {
id: string;
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 {
search: string;
userType: 'all' | 'individual' | 'enterprise';
status: 'all' | 'active' | 'inactive';
sortBy: 'created_at' | 'full_name' | 'last_login';
sortOrder: 'asc' | 'desc';
role: string;
status: string;
company: string;
}
export default function UsersPage() {
export default function Users() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState<UserFilters>({
search: '',
userType: 'all',
status: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
role: '',
status: '',
company: ''
});
const router = useRouter();
const pageSize = 20;
const pageSize = 10;
// 获取用户列表
const fetchUsers = async (page = 1) => {
useEffect(() => {
fetchUsers();
}, [currentPage, filters]);
const fetchUsers = async () => {
try {
setLoading(true);
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemo);
// 模拟加载延迟
await new Promise(resolve => setTimeout(resolve, 800));
if (isDemo) {
// 使用演示数据
const result = await getDemoData.users(filters);
setUsers(result.data);
setTotalCount(result.total);
setTotalPages(Math.ceil(result.total / pageSize));
setCurrentPage(page);
} else {
// 使用真实数据
let query = supabase
.from(TABLES.USERS)
.select('*', { count: 'exact' });
// 搜索过滤
if (filters.search) {
query = query.or(`full_name.ilike.%${filters.search}%,email.ilike.%${filters.search}%`);
// 使用演示数据
const mockUsers: User[] = [
{
id: '1',
name: '张三',
email: 'zhangsan@example.com',
phone: '13800138001',
company: 'ABC科技有限公司',
role: 'user',
status: 'active',
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
}
];
// 状态过滤
if (filters.status !== 'all') {
const isActive = filters.status === 'active';
query = query.eq('is_active', isActive);
}
// 排序
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' });
// 分页
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
query = query.range(from, to);
const { data, error, count } = await query;
if (error) throw error;
setUsers(data || []);
setTotalCount(count || 0);
setTotalPages(Math.ceil((count || 0) / pageSize));
setCurrentPage(page);
// 应用过滤器
let filteredUsers = mockUsers;
if (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) {
filteredUsers = filteredUsers.filter(user => user.role === filters.role);
}
if (filters.status) {
filteredUsers = filteredUsers.filter(user => user.status === filters.status);
}
if (filters.company) {
filteredUsers = filteredUsers.filter(user =>
user.company.toLowerCase().includes(filters.company.toLowerCase())
);
}
// 分页
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
setUsers(paginatedUsers);
setTotalCount(filteredUsers.length);
setTotalPages(Math.ceil(filteredUsers.length / pageSize));
} catch (error) {
console.error('Error fetching users:', 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);
}
console.error('Failed to fetch users:', error);
toast.error('加载用户数据失败');
} finally {
setLoading(false);
}
};
// 处理筛选变更
const handleFilterChange = (key: keyof UserFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value
}));
};
// 应用筛选
const applyFilters = () => {
const handleSearch = (value: string) => {
setFilters(prev => ({ ...prev, search: value }));
setCurrentPage(1);
fetchUsers(1);
};
// 重置筛选
const resetFilters = () => {
setFilters({
search: '',
userType: 'all',
status: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
const handleFilterChange = (key: keyof UserFilters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1);
fetchUsers(1);
};
useEffect(() => {
fetchUsers();
}, []);
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();
} 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 (
<Layout>
<>
<Head>
<title> - </title>
<title> - </title>
</Head>
<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="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
{/* 搜索和筛选 */}
<div className="bg-white shadow rounded-lg mb-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="lg:col-span-2">
<div className="relative">
<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" />
</div>
<input
type="text"
placeholder="搜索用户名或邮箱..."
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"
/>
</div>
</div>
{/* 状态筛选 */}
<div>
<select
value={filters.status}
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="active"></option>
<option value="inactive"></option>
</select>
</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="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">
<DashboardLayout title="用户管理">
<div className="space-y-6">
{/* 页面标题和操作 */}
<div className="sm:flex sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-700">
</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="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 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 className="bg-white shadow rounded-lg p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="relative">
<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" />
</div>
<input
type="text"
id="search"
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>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<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}
onChange={(e) => handleFilterChange('status', e.target.value)}
>
<option value=""></option>
<option value="active"></option>
<option value="inactive"></option>
<option value="pending"></option>
</select>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
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"
placeholder="过滤公司..."
value={filters.company}
onChange={(e) => handleFilterChange('company', e.target.value)}
/>
</div>
</div>
</div>
{/* 批量操作 */}
{selectedUsers.length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm font-medium text-blue-900">
{selectedUsers.length}
</span>
</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 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}
/>
<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 className="flex items-center space-x-4">
<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'
: 'bg-red-100 text-red-800'
}`}>
{getUserStatusText(user.is_active)}
</span>
</div>
</div>
</div>
))}
</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>
{/* 分页 */}
{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">
<button
onClick={() => fetchUsers(currentPage - 1)}
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 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
onClick={() => fetchUsers(currentPage + 1)}
onClick={() => setCurrentPage(prev => Math.min(prev + 1, 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>
@ -317,54 +613,49 @@ export default function UsersPage() {
<div>
<p className="text-sm text-gray-700">
<span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> {' '}
<span className="font-medium">
{Math.min(currentPage * pageSize, totalCount)}
</span>{' '}
<span className="font-medium">{totalCount}</span>
<span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span>
<span className="font-medium">{totalCount}</span>
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => fetchUsers(currentPage - 1)}
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 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>
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
const page = i + 1;
return (
<button
key={page}
onClick={() => fetchUsers(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{page}
</button>
);
})}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{page}
</button>
))}
<button
onClick={() => fetchUsers(currentPage + 1)}
onClick={() => setCurrentPage(prev => Math.min(prev + 1, 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>
</nav>
</div>
</div>
</div>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
</Layout>
</DashboardLayout>
</>
);
}

44
test-api.js Normal file
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
test-login-flow.js Normal file
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
types/database.ts Normal file
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
}
}
}