feat: 完成所有页面的演示模式实现
- 更新 DashboardLayout 组件,统一使用演示模式布局 - 实现仪表盘页面的完整演示数据和功能 - 完成用户管理页面的演示模式,包含搜索、过滤、分页等功能 - 实现通话记录页面的演示数据和录音播放功能 - 完成翻译员管理页面的演示模式 - 实现订单管理页面的完整功能 - 完成发票管理页面的演示数据 - 更新文档管理页面 - 添加 utils.ts 工具函数库 - 完善 API 路由和数据库结构 - 修复各种 TypeScript 类型错误 - 统一界面风格和用户体验
This commit is contained in:
parent
0b8be9377a
commit
f20988b90c
25
.env.example
25
.env.example
@ -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
368
DEPLOYMENT.md
Normal 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
183
FIXES.md
Normal 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
173
README.md
@ -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
54
check-table.js
Normal file
@ -0,0 +1,54 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = 'https://poxwjzdianersitpnvdy.supabase.co';
|
||||
const supabaseKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBveHdqemRpYW5lcnNpdHBudmR5Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTExNjk4MjMsImV4cCI6MjA2Njc0NTgyM30.FkgCCSHK0_i8bNFIhhN3k6dEbP5PpE52IggcVJC4Aj8';
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
async function checkTable() {
|
||||
try {
|
||||
console.log('检查users表结构...');
|
||||
|
||||
// 尝试查询表中的所有数据
|
||||
const { data, error } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.limit(1);
|
||||
|
||||
if (error) {
|
||||
console.error('查询错误:', error);
|
||||
} else {
|
||||
console.log('查询结果:', data);
|
||||
if (data && data.length > 0) {
|
||||
console.log('表列名:', Object.keys(data[0]));
|
||||
} else {
|
||||
console.log('表为空,尝试插入测试数据...');
|
||||
|
||||
// 尝试插入一个简单的用户
|
||||
const { data: insertData, error: insertError } = await supabase
|
||||
.from('users')
|
||||
.insert([
|
||||
{
|
||||
email: 'admin@example.com',
|
||||
name: '系统管理员',
|
||||
phone: '13800138000',
|
||||
user_type: 'admin',
|
||||
status: 'active'
|
||||
}
|
||||
])
|
||||
.select();
|
||||
|
||||
if (insertError) {
|
||||
console.error('插入错误:', insertError);
|
||||
} else {
|
||||
console.log('插入成功:', insertData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('操作失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
checkTable();
|
287
components/Layout/DashboardLayout.tsx
Normal file
287
components/Layout/DashboardLayout.tsx
Normal 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
32
create-admin.js
Normal 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
179
database/README.md
Normal 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
288
database/schema.sql
Normal 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
56
insert-admin.js
Normal 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
323
lib/api-service.ts
Normal 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
118
lib/api-utils.ts
Normal 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
|
||||
}
|
||||
}
|
244
lib/supabase.ts
244
lib/supabase.ts
@ -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
180
lib/utils.ts
Normal 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
25
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
102
pages/api/auth/admin-login.ts
Normal file
102
pages/api/auth/admin-login.ts
Normal 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
92
pages/api/auth/login.ts
Normal 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
47
pages/api/auth/logout.ts
Normal 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
85
pages/api/auth/me.ts
Normal 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
117
pages/api/auth/register.ts
Normal 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
125
pages/api/orders/index.ts
Normal file
@ -0,0 +1,125 @@
|
||||
import { NextApiRequest, NextApiResponse } from 'next'
|
||||
import { getDemoData } from '../../../lib/demo-data'
|
||||
import {
|
||||
authenticateUser,
|
||||
getUserProfile,
|
||||
handleApiError,
|
||||
generateOrderNumber,
|
||||
calculateServiceCost,
|
||||
User
|
||||
} from '../../../lib/api-utils'
|
||||
import { db } from '../../../lib/supabase'
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse
|
||||
) {
|
||||
// 认证用户
|
||||
const user = await authenticateUser(req, res)
|
||||
if (!user) return // 错误已在authenticateUser中处理
|
||||
|
||||
if (req.method === 'GET') {
|
||||
// 获取订单列表
|
||||
try {
|
||||
// 在演示模式下返回演示数据
|
||||
if (process.env.NODE_ENV === 'development' && !process.env.SUPABASE_URL) {
|
||||
const demoOrders = await getDemoData.orders()
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
orders: demoOrders,
|
||||
total: demoOrders.length
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 从数据库获取订单
|
||||
const orders = await db.select('orders', '*')
|
||||
|
||||
// 根据用户类型过滤订单
|
||||
const currentUser = await getUserProfile(user.id)
|
||||
|
||||
let filteredOrders = orders
|
||||
if (currentUser && currentUser.user_type === 'individual') {
|
||||
// 个人用户只能看到自己的订单
|
||||
filteredOrders = orders.filter((order: any) => order.user_id === user.id)
|
||||
}
|
||||
// 企业用户和管理员可以看到所有订单
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
orders: filteredOrders,
|
||||
total: filteredOrders.length
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
return handleApiError(res, error, 'Get orders')
|
||||
}
|
||||
|
||||
} else if (req.method === 'POST') {
|
||||
// 创建新订单
|
||||
const {
|
||||
service_type,
|
||||
service_name,
|
||||
source_language,
|
||||
target_language,
|
||||
duration,
|
||||
priority = 'normal',
|
||||
scheduled_time,
|
||||
notes
|
||||
} = req.body
|
||||
|
||||
// 验证必填字段
|
||||
if (!service_type || !source_language || !target_language) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: '缺少必填字段',
|
||||
details: '服务类型、源语言和目标语言为必填项'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取用户信息
|
||||
const currentUser = await getUserProfile(user.id)
|
||||
|
||||
const orderData = {
|
||||
order_number: generateOrderNumber(),
|
||||
user_id: user.id,
|
||||
user_name: currentUser?.name || user.email,
|
||||
user_email: user.email,
|
||||
service_type,
|
||||
service_name: service_name || service_type,
|
||||
source_language,
|
||||
target_language,
|
||||
duration: duration || null,
|
||||
status: 'pending',
|
||||
priority,
|
||||
cost: calculateServiceCost(service_type, duration),
|
||||
currency: 'CNY',
|
||||
scheduled_time: scheduled_time || null,
|
||||
notes: notes || null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
const newOrder = await db.insert('orders', orderData)
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
message: '订单创建成功',
|
||||
data: {
|
||||
order: newOrder
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
return handleApiError(res, error, 'Create order')
|
||||
}
|
||||
|
||||
} else {
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: '方法不允许'
|
||||
})
|
||||
}
|
||||
}
|
55
pages/api/test-connection.ts
Normal file
55
pages/api/test-connection.ts
Normal 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: '连接测试失败'
|
||||
});
|
||||
}
|
||||
}
|
@ -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
223
pages/auth/register.tsx
Normal 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
@ -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
@ -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
44
test-api.js
Normal 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
62
test-login-flow.js
Normal 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
520
types/database.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user