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 配置
|
# Supabase 配置
|
||||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
|
NEXT_PUBLIC_SUPABASE_URL=https://riwtulmitqioswmgwftg.supabase.co
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxx...
|
||||||
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
|
||||||
|
|
||||||
# Twilio 配置
|
# Twilio 配置
|
||||||
TWILIO_ACCOUNT_SID=your_twilio_account_sid
|
TWILIO_ACCOUNT_SID=AC0123456789abcdef0123456789abcdef
|
||||||
TWILIO_AUTH_TOKEN=your_twilio_auth_token
|
TWILIO_AUTH_TOKEN=your_twilio_auth_token
|
||||||
TWILIO_API_KEY=your_twilio_api_key
|
TWILIO_API_KEY_SID=SK0123456789abcdef0123456789abcdef
|
||||||
TWILIO_API_SECRET=your_twilio_api_secret
|
TWILIO_API_KEY_SECRET=0123456789abcdef0123456789abcdef
|
||||||
|
|
||||||
# Stripe 配置
|
# Stripe 配置
|
||||||
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51RTwLuDWamLO9gYlv7ZX0Jj2aLBkADGWmTC3NP0aoez3nEdnLlQiWH3KUie1C45CSa1ho3DvTm0GqR59X0sNTnqN00Q15Fq0zw
|
||||||
STRIPE_SECRET_KEY=your_stripe_secret_key
|
STRIPE_SECRET_KEY=sk_test_51RTwLuDWamLO9gYliBCJFtPob28ttoTtvsglGtyXrHkrnuppY2ScnVz7BRh1hCHzvOXcOyvMejBRVsx5vMpgKLVE0065W8VOU8
|
||||||
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
|
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
|
||||||
|
|
||||||
# ElevenLabs 配置
|
# ElevenLabs 配置
|
||||||
ELEVENLABS_API_KEY=your_elevenlabs_api_key
|
ELEVENLABS_API_KEY=your_elevenlabs_api_key
|
||||||
|
|
||||||
# JWT 密钥
|
# JWT 密钥
|
||||||
JWT_SECRET=your_jwt_secret
|
JWT_SECRET=your_jwt_secret_key_here
|
||||||
|
|
||||||
# 应用配置
|
# 应用配置
|
||||||
NEXTAUTH_URL=http://localhost:3000
|
NEXTAUTH_URL=http://localhost:3000
|
||||||
NEXTAUTH_SECRET=your_nextauth_secret
|
NEXTAUTH_SECRET=your_nextauth_secret_key_here
|
||||||
|
|
||||||
# 数据库配置
|
# 数据库配置
|
||||||
DATABASE_URL=your_database_url
|
DATABASE_URL=your_database_url_here
|
||||||
|
|
||||||
# 邮件配置 (可选,用于通知)
|
# 邮件配置 (可选,用于通知)
|
||||||
SMTP_HOST=your_smtp_host
|
SMTP_HOST=your_smtp_host
|
||||||
@ -41,4 +41,7 @@ MAX_FILE_SIZE=10485760 # 10MB
|
|||||||
|
|
||||||
# 支付配置
|
# 支付配置
|
||||||
PAYMENT_SUCCESS_URL=http://localhost:3000/payment/success
|
PAYMENT_SUCCESS_URL=http://localhost:3000/payment/success
|
||||||
PAYMENT_CANCEL_URL=http://localhost:3000/payment/cancel
|
PAYMENT_CANCEL_URL=http://localhost:3000/payment/cancel
|
||||||
|
|
||||||
|
# OpenAI 配置
|
||||||
|
OPENAI_API_KEY=sk_live_o_pqmR3A26poD7ltpYgZ1aoDZEOaAJr8lUlvTw
|
368
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
|
- **前端框架**:Next.js 14
|
||||||
- **类型系统**:TypeScript
|
- **类型系统**:TypeScript
|
||||||
- **样式框架**:Tailwind CSS
|
- **样式框架**:Tailwind CSS
|
||||||
- **图标库**:Heroicons
|
- **图标库**:Heroicons
|
||||||
- **状态管理**:React Hooks
|
- **状态管理**:React Hooks
|
||||||
- **数据库**:Supabase(可选)
|
|
||||||
- **部署**:Vercel(推荐)
|
### 后端技术
|
||||||
|
- **数据库**:Supabase (PostgreSQL)
|
||||||
|
- **身份验证**:Supabase Auth
|
||||||
|
- **API**:Next.js API Routes
|
||||||
|
- **实时功能**:Supabase Realtime
|
||||||
|
|
||||||
|
### 第三方集成
|
||||||
|
- **支付处理**:Stripe
|
||||||
|
- **通信服务**:Twilio
|
||||||
|
- **AI服务**:OpenAI
|
||||||
|
- **部署平台**:Vercel
|
||||||
|
|
||||||
## 安装和运行
|
## 安装和运行
|
||||||
|
|
||||||
### 环境要求
|
### 环境要求
|
||||||
- Node.js 18.0 或更高版本
|
- Node.js 18.0 或更高版本
|
||||||
- npm 或 yarn
|
- npm 或 yarn
|
||||||
|
- Supabase 项目(用于数据库和认证)
|
||||||
|
|
||||||
### 安装依赖
|
### 1. 安装依赖
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
# 或
|
# 或
|
||||||
yarn install
|
yarn install
|
||||||
```
|
```
|
||||||
|
|
||||||
### 环境配置
|
### 2. 环境配置
|
||||||
复制 `.env.example` 到 `.env.local` 并配置必要的环境变量:
|
复制 `.env.example` 到 `.env.local` 并配置必要的环境变量:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env.local
|
cp .env.example .env.local
|
||||||
```
|
```
|
||||||
|
|
||||||
### 开发模式运行
|
配置 Supabase 和其他服务:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Supabase 配置
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||||
|
|
||||||
|
# 其他服务配置
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
TWILIO_ACCOUNT_SID=AC...
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 数据库设置
|
||||||
|
在 Supabase Dashboard 中执行数据库脚本:
|
||||||
|
|
||||||
|
1. 登录 [Supabase Dashboard](https://supabase.com/dashboard)
|
||||||
|
2. 进入 SQL Editor
|
||||||
|
3. 复制 `database/schema.sql` 内容并执行
|
||||||
|
4. 创建管理员账户(可选)
|
||||||
|
|
||||||
|
### 4. 开发模式运行
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
# 或
|
# 或
|
||||||
@ -92,14 +130,14 @@ yarn dev
|
|||||||
|
|
||||||
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
访问 [http://localhost:3000](http://localhost:3000) 查看应用。
|
||||||
|
|
||||||
### 构建生产版本
|
### 5. 构建生产版本
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
npm run build
|
||||||
# 或
|
# 或
|
||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
### 启动生产服务器
|
### 6. 启动生产服务器
|
||||||
```bash
|
```bash
|
||||||
npm start
|
npm start
|
||||||
# 或
|
# 或
|
||||||
@ -112,26 +150,84 @@ yarn start
|
|||||||
├── components/ # 可复用组件
|
├── components/ # 可复用组件
|
||||||
├── lib/ # 工具库和配置
|
├── lib/ # 工具库和配置
|
||||||
│ ├── demo-data.ts # 演示数据
|
│ ├── demo-data.ts # 演示数据
|
||||||
│ ├── supabase.ts # Supabase 配置
|
│ ├── supabase.ts # Supabase 配置和操作
|
||||||
│ └── utils.ts # 工具函数
|
│ └── utils.ts # 工具函数
|
||||||
├── pages/ # 页面组件
|
├── pages/ # 页面组件
|
||||||
│ ├── api/ # API 路由
|
│ ├── api/ # API 路由
|
||||||
|
│ │ ├── auth/ # 认证 API
|
||||||
|
│ │ ├── orders/ # 订单 API
|
||||||
|
│ │ ├── users/ # 用户 API
|
||||||
|
│ │ └── ... # 其他 API
|
||||||
│ ├── auth/ # 认证页面
|
│ ├── auth/ # 认证页面
|
||||||
│ └── dashboard/ # 管理后台页面
|
│ └── dashboard/ # 管理后台页面
|
||||||
|
├── database/ # 数据库相关文件
|
||||||
|
│ ├── schema.sql # 数据库结构
|
||||||
|
│ └── README.md # 数据库说明
|
||||||
|
├── types/ # TypeScript 类型定义
|
||||||
|
│ ├── database.ts # 数据库类型
|
||||||
|
│ └── auth.ts # 认证类型
|
||||||
├── public/ # 静态资源
|
├── public/ # 静态资源
|
||||||
├── styles/ # 样式文件
|
├── styles/ # 样式文件
|
||||||
├── types/ # TypeScript 类型定义
|
|
||||||
└── utils/ # 工具函数
|
└── utils/ # 工具函数
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 认证接口
|
||||||
|
- `POST /api/auth/register` - 用户注册
|
||||||
|
- `POST /api/auth/login` - 用户登录
|
||||||
|
- `POST /api/auth/logout` - 用户登出
|
||||||
|
- `GET /api/auth/me` - 获取当前用户信息
|
||||||
|
|
||||||
|
### 订单接口
|
||||||
|
- `GET /api/orders` - 获取订单列表
|
||||||
|
- `POST /api/orders` - 创建新订单
|
||||||
|
- `PUT /api/orders/:id` - 更新订单
|
||||||
|
- `DELETE /api/orders/:id` - 删除订单
|
||||||
|
|
||||||
|
### 用户接口
|
||||||
|
- `GET /api/users` - 获取用户列表
|
||||||
|
- `POST /api/users` - 创建用户
|
||||||
|
- `PUT /api/users/:id` - 更新用户信息
|
||||||
|
- `DELETE /api/users/:id` - 删除用户
|
||||||
|
|
||||||
|
## 数据库架构
|
||||||
|
|
||||||
|
### 核心表结构
|
||||||
|
- **users** - 用户表(支持个人/企业/管理员)
|
||||||
|
- **enterprises** - 企业表
|
||||||
|
- **orders** - 订单表
|
||||||
|
- **invoices** - 发票表
|
||||||
|
- **interpreters** - 译员表
|
||||||
|
- **calls** - 通话记录表
|
||||||
|
- **documents** - 文档翻译表
|
||||||
|
|
||||||
|
### 安全策略
|
||||||
|
- 启用行级安全 (RLS)
|
||||||
|
- 基于用户角色的权限控制
|
||||||
|
- 数据访问审计日志
|
||||||
|
|
||||||
|
详细说明请参考 [database/README.md](./database/README.md)
|
||||||
|
|
||||||
## 核心功能说明
|
## 核心功能说明
|
||||||
|
|
||||||
|
### 身份验证系统
|
||||||
|
- **多角色支持**:个人用户、企业用户、管理员
|
||||||
|
- **安全认证**:基于 JWT 令牌的会话管理
|
||||||
|
- **权限控制**:细粒度的权限管理
|
||||||
|
- **会话管理**:自动刷新和过期处理
|
||||||
|
|
||||||
### 费率优先级机制
|
### 费率优先级机制
|
||||||
系统采用三级费率优先级:
|
系统采用三级费率优先级:
|
||||||
1. **企业合同费率**:优先级最高,适用于企业员工
|
1. **企业合同费率**:优先级最高,适用于企业员工
|
||||||
2. **系统通用费率**:适用于个人用户和无合同企业
|
2. **系统通用费率**:适用于个人用户和无合同企业
|
||||||
3. **默认费率**:系统兜底费率
|
3. **默认费率**:系统兜底费率
|
||||||
|
|
||||||
|
### 实时功能
|
||||||
|
- **订单状态更新**:实时同步订单状态变化
|
||||||
|
- **通话监控**:实时监控活跃通话
|
||||||
|
- **消息通知**:即时消息推送
|
||||||
|
|
||||||
### 演示模式
|
### 演示模式
|
||||||
项目支持演示模式,无需配置数据库即可体验完整功能:
|
项目支持演示模式,无需配置数据库即可体验完整功能:
|
||||||
- 自动检测 Supabase 配置
|
- 自动检测 Supabase 配置
|
||||||
@ -151,6 +247,8 @@ yarn start
|
|||||||
3. 配置环境变量
|
3. 配置环境变量
|
||||||
4. 部署完成
|
4. 部署完成
|
||||||
|
|
||||||
|
详细部署指南请参考 [DEPLOYMENT.md](./DEPLOYMENT.md)
|
||||||
|
|
||||||
### 其他平台部署
|
### 其他平台部署
|
||||||
项目支持部署到任何支持 Next.js 的平台,如:
|
项目支持部署到任何支持 Next.js 的平台,如:
|
||||||
- Netlify
|
- Netlify
|
||||||
@ -160,15 +258,31 @@ yarn start
|
|||||||
|
|
||||||
## 环境变量
|
## 环境变量
|
||||||
|
|
||||||
|
### 必需配置
|
||||||
```bash
|
```bash
|
||||||
# Supabase 配置(可选)
|
# Supabase 配置
|
||||||
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url
|
NEXT_PUBLIC_SUPABASE_URL=https://your-project-id.supabase.co
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||||
|
|
||||||
# 其他第三方服务配置
|
# 应用配置
|
||||||
TWILIO_ACCOUNT_SID=your_twilio_sid
|
NEXTAUTH_SECRET=your-nextauth-secret
|
||||||
TWILIO_AUTH_TOKEN=your_twilio_token
|
JWT_SECRET=your-jwt-secret
|
||||||
OPENAI_API_KEY=your_openai_key
|
```
|
||||||
|
|
||||||
|
### 可选配置
|
||||||
|
```bash
|
||||||
|
# Stripe 支付
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||||
|
|
||||||
|
# Twilio 通信
|
||||||
|
TWILIO_ACCOUNT_SID=AC...
|
||||||
|
TWILIO_API_KEY_SID=SK...
|
||||||
|
TWILIO_API_KEY_SECRET=...
|
||||||
|
|
||||||
|
# OpenAI 服务
|
||||||
|
OPENAI_API_KEY=sk-...
|
||||||
```
|
```
|
||||||
|
|
||||||
## 贡献指南
|
## 贡献指南
|
||||||
@ -179,17 +293,36 @@ OPENAI_API_KEY=your_openai_key
|
|||||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||||
5. 创建 Pull Request
|
5. 创建 Pull Request
|
||||||
|
|
||||||
|
### 开发规范
|
||||||
|
- 使用 TypeScript 进行类型安全开发
|
||||||
|
- 遵循 ESLint 和 Prettier 代码规范
|
||||||
|
- 编写单元测试
|
||||||
|
- 更新相关文档
|
||||||
|
|
||||||
## 许可证
|
## 许可证
|
||||||
|
|
||||||
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
本项目采用 MIT 许可证 - 查看 [LICENSE](LICENSE) 文件了解详情。
|
||||||
|
|
||||||
## 联系方式
|
## 文档和支持
|
||||||
|
|
||||||
|
### 相关文档
|
||||||
|
- [部署指南](./DEPLOYMENT.md)
|
||||||
|
- [数据库说明](./database/README.md)
|
||||||
|
- [API 参考](./docs/api-reference.md)
|
||||||
|
|
||||||
|
### 联系方式
|
||||||
- 项目地址:[http://git.wanzhongtech.com/mars/Twilioapp-admin](http://git.wanzhongtech.com/mars/Twilioapp-admin)
|
- 项目地址:[http://git.wanzhongtech.com/mars/Twilioapp-admin](http://git.wanzhongtech.com/mars/Twilioapp-admin)
|
||||||
- 问题反馈:请在 GitLab Issues 中提交
|
- 问题反馈:请在 GitLab Issues 中提交
|
||||||
|
|
||||||
## 更新日志
|
## 更新日志
|
||||||
|
|
||||||
|
### v1.1.0 (2024-01-30)
|
||||||
|
- ✅ 集成 Supabase 数据库和身份验证
|
||||||
|
- ✅ 实现完整的 API 接口
|
||||||
|
- ✅ 添加用户认证和权限控制
|
||||||
|
- ✅ 优化数据库结构和安全策略
|
||||||
|
- ✅ 完善部署文档和指南
|
||||||
|
|
||||||
### v1.0.0 (2024-01-30)
|
### v1.0.0 (2024-01-30)
|
||||||
- ✅ 完成企业服务管理功能
|
- ✅ 完成企业服务管理功能
|
||||||
- ✅ 完成订单管理功能
|
- ✅ 完成订单管理功能
|
||||||
|
54
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 { createClient } from '@supabase/supabase-js';
|
||||||
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
|
import { Database } from '../types/database';
|
||||||
|
|
||||||
// 环境变量检查和默认值
|
// 环境变量检查和默认值
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supabase.co';
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supabase.co';
|
||||||
@ -9,7 +9,7 @@ const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'demo-servic
|
|||||||
// 检查是否在开发环境中使用默认配置
|
// 检查是否在开发环境中使用默认配置
|
||||||
const isDemoMode = supabaseUrl === 'https://demo.supabase.co';
|
const isDemoMode = supabaseUrl === 'https://demo.supabase.co';
|
||||||
|
|
||||||
// 客户端使用的 Supabase 客户端
|
// 单一的 Supabase 客户端实例
|
||||||
export const supabase = isDemoMode
|
export const supabase = isDemoMode
|
||||||
? createClient(supabaseUrl, supabaseAnonKey, {
|
? createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
realtime: {
|
realtime: {
|
||||||
@ -22,23 +22,13 @@ export const supabase = isDemoMode
|
|||||||
autoRefreshToken: false,
|
autoRefreshToken: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
: createClient(supabaseUrl, supabaseAnonKey);
|
: createClient<Database>(supabaseUrl, supabaseAnonKey, {
|
||||||
|
|
||||||
// 组件中使用的 Supabase 客户端
|
|
||||||
export const createSupabaseClient = () => {
|
|
||||||
if (isDemoMode) {
|
|
||||||
// 在演示模式下返回一个模拟客户端
|
|
||||||
return {
|
|
||||||
auth: {
|
auth: {
|
||||||
getUser: () => Promise.resolve({ data: { user: null }, error: null }),
|
autoRefreshToken: true,
|
||||||
signInWithPassword: () => Promise.resolve({ data: null, error: { message: '演示模式:请配置 Supabase 环境变量' } }),
|
persistSession: true,
|
||||||
signOut: () => Promise.resolve({ error: null }),
|
detectSessionInUrl: true
|
||||||
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
|
|
||||||
}
|
}
|
||||||
} as any;
|
});
|
||||||
}
|
|
||||||
return createClientComponentClient();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 服务端使用的 Supabase 客户端(具有管理员权限)
|
// 服务端使用的 Supabase 客户端(具有管理员权限)
|
||||||
export const supabaseAdmin = isDemoMode
|
export const supabaseAdmin = isDemoMode
|
||||||
@ -56,6 +46,7 @@ export const supabaseAdmin = isDemoMode
|
|||||||
: createClient(supabaseUrl, supabaseServiceKey, {
|
: createClient(supabaseUrl, supabaseServiceKey, {
|
||||||
auth: {
|
auth: {
|
||||||
autoRefreshToken: false,
|
autoRefreshToken: false,
|
||||||
|
persistSession: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -102,12 +93,12 @@ export const auth = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// 注册
|
// 注册
|
||||||
signUp: async (email: string, password: string, metadata?: any) => {
|
signUp: async (email: string, password: string, userData?: any) => {
|
||||||
const { data, error } = await supabase.auth.signUp({
|
const { data, error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: {
|
options: {
|
||||||
data: metadata,
|
data: userData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
@ -137,6 +128,20 @@ export const auth = {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 获取当前会话
|
||||||
|
getSession: async () => {
|
||||||
|
const { data: { session }, error } = await supabase.auth.getSession();
|
||||||
|
if (error) throw error;
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户信息
|
||||||
|
updateUser: async (updates: any) => {
|
||||||
|
const { data, error } = await supabase.auth.updateUser(updates);
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 数据库操作辅助函数
|
// 数据库操作辅助函数
|
||||||
@ -181,56 +186,96 @@ export const db = {
|
|||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 分页查询函数
|
// 根据条件查询单条记录
|
||||||
paginate: async <T>(
|
findOne: async <T>(table: string, conditions: Record<string, any>, select?: string) => {
|
||||||
table: string,
|
let query = supabase.from(table).select(select || '*');
|
||||||
page: number = 1,
|
|
||||||
limit: number = 10,
|
Object.entries(conditions).forEach(([key, value]) => {
|
||||||
query?: any,
|
query = query.eq(key, value);
|
||||||
orderBy?: { column: string; ascending?: boolean }
|
});
|
||||||
) => {
|
|
||||||
const from = (page - 1) * limit;
|
|
||||||
const to = from + limit - 1;
|
|
||||||
|
|
||||||
let queryBuilder = supabase
|
const { data, error } = await query.single();
|
||||||
.from(table)
|
if (error) throw error;
|
||||||
.select(query || '*', { count: 'exact' })
|
return data as T;
|
||||||
.range(from, to);
|
},
|
||||||
|
|
||||||
if (orderBy) {
|
// 根据条件查询多条记录
|
||||||
queryBuilder = queryBuilder.order(orderBy.column, {
|
findMany: async <T>(table: string, conditions?: Record<string, any>, select?: string) => {
|
||||||
ascending: orderBy.ascending ?? true,
|
let query = supabase.from(table).select(select || '*');
|
||||||
|
|
||||||
|
if (conditions) {
|
||||||
|
Object.entries(conditions).forEach(([key, value]) => {
|
||||||
|
query = query.eq(key, value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, error, count } = await queryBuilder;
|
const { data, error } = await query;
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
|
return data as T[];
|
||||||
|
},
|
||||||
|
|
||||||
return {
|
// 计数查询
|
||||||
data: data as T[],
|
count: async (table: string, conditions?: Record<string, any>) => {
|
||||||
total: count || 0,
|
let query = supabase.from(table).select('*', { count: 'exact', head: true });
|
||||||
page,
|
|
||||||
limit,
|
if (conditions) {
|
||||||
has_more: (count || 0) > page * limit,
|
Object.entries(conditions).forEach(([key, value]) => {
|
||||||
};
|
query = query.eq(key, value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { count, error } = await query;
|
||||||
|
if (error) throw error;
|
||||||
|
return count || 0;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 文件上传函数
|
// 实时订阅管理
|
||||||
|
export const realtime = {
|
||||||
|
subscribe: (table: string, callback: (payload: any) => void) => {
|
||||||
|
const channel = supabase
|
||||||
|
.channel(`${table}_changes`)
|
||||||
|
.on('postgres_changes',
|
||||||
|
{
|
||||||
|
event: '*',
|
||||||
|
schema: 'public',
|
||||||
|
table: table
|
||||||
|
},
|
||||||
|
callback
|
||||||
|
)
|
||||||
|
.subscribe();
|
||||||
|
|
||||||
|
return channel;
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribe: (channel: any) => {
|
||||||
|
if (channel) {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件上传相关函数
|
||||||
export const storage = {
|
export const storage = {
|
||||||
// 上传文件
|
// 上传文件
|
||||||
upload: async (bucket: string, path: string, file: File) => {
|
upload: async (bucket: string, path: string, file: File) => {
|
||||||
const { data, error } = await supabase.storage
|
const { data, error } = await supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
.upload(path, file, {
|
.upload(path, file);
|
||||||
cacheControl: '3600',
|
|
||||||
upsert: false,
|
|
||||||
});
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取文件公共URL
|
// 下载文件
|
||||||
|
download: async (bucket: string, path: string) => {
|
||||||
|
const { data, error } = await supabase.storage
|
||||||
|
.from(bucket)
|
||||||
|
.download(path);
|
||||||
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取公共URL
|
||||||
getPublicUrl: (bucket: string, path: string) => {
|
getPublicUrl: (bucket: string, path: string) => {
|
||||||
const { data } = supabase.storage
|
const { data } = supabase.storage
|
||||||
.from(bucket)
|
.from(bucket)
|
||||||
@ -248,43 +293,68 @@ export const storage = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// 实时订阅函数
|
// 用户类型定义
|
||||||
export const realtime = {
|
export type UserRole = 'admin' | 'interpreter' | 'client' | 'enterprise';
|
||||||
// 订阅表变化
|
|
||||||
subscribe: (
|
|
||||||
table: string,
|
|
||||||
callback: (payload: any) => void,
|
|
||||||
filter?: string
|
|
||||||
) => {
|
|
||||||
if (isDemoMode) {
|
|
||||||
// 演示模式下返回模拟的订阅对象
|
|
||||||
return {
|
|
||||||
unsubscribe: () => {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const channel = supabase
|
// 用户权限检查
|
||||||
.channel(`${table}-changes`)
|
export const permissions = {
|
||||||
.on(
|
// 检查用户是否有特定权限
|
||||||
'postgres_changes',
|
hasPermission: (userRole: UserRole, requiredRole: UserRole) => {
|
||||||
{
|
const roleHierarchy: Record<UserRole, number> = {
|
||||||
event: '*',
|
'client': 1,
|
||||||
schema: 'public',
|
'interpreter': 2,
|
||||||
table,
|
'enterprise': 3,
|
||||||
filter,
|
'admin': 4,
|
||||||
},
|
};
|
||||||
callback
|
|
||||||
)
|
return roleHierarchy[userRole] >= roleHierarchy[requiredRole];
|
||||||
.subscribe();
|
|
||||||
|
|
||||||
return channel;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// 取消订阅
|
// 检查用户是否为管理员
|
||||||
unsubscribe: (channel: any) => {
|
isAdmin: (userRole: UserRole) => userRole === 'admin',
|
||||||
if (isDemoMode) {
|
|
||||||
return;
|
// 检查用户是否为翻译员
|
||||||
}
|
isInterpreter: (userRole: UserRole) => userRole === 'interpreter',
|
||||||
supabase.removeChannel(channel);
|
|
||||||
},
|
// 检查用户是否为企业用户
|
||||||
};
|
isEnterprise: (userRole: UserRole) => userRole === 'enterprise',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 错误处理
|
||||||
|
export const handleSupabaseError = (error: any) => {
|
||||||
|
console.error('Supabase Error:', error);
|
||||||
|
|
||||||
|
// 根据错误类型返回用户友好的消息
|
||||||
|
if (error.code === 'PGRST116') {
|
||||||
|
return '未找到记录';
|
||||||
|
} else if (error.code === '23505') {
|
||||||
|
return '数据已存在';
|
||||||
|
} else if (error.code === '23503') {
|
||||||
|
return '数据关联错误';
|
||||||
|
} else if (error.message?.includes('JWT')) {
|
||||||
|
return '登录已过期,请重新登录';
|
||||||
|
} else if (error.message?.includes('permission')) {
|
||||||
|
return '权限不足';
|
||||||
|
} else {
|
||||||
|
return error.message || '操作失败,请稍后重试';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查 Supabase 是否正确配置
|
||||||
|
export const isSupabaseConfigured = () => {
|
||||||
|
return !isDemoMode && supabaseUrl !== 'https://demo.supabase.co' && supabaseAnonKey !== 'demo-key';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取配置状态
|
||||||
|
export const getConfigStatus = () => {
|
||||||
|
return {
|
||||||
|
isDemoMode,
|
||||||
|
isConfigured: isSupabaseConfigured(),
|
||||||
|
url: supabaseUrl,
|
||||||
|
hasAnonKey: supabaseAnonKey !== 'demo-key',
|
||||||
|
hasServiceKey: supabaseServiceKey !== 'demo-service-key',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 默认导出
|
||||||
|
export default supabase;
|
180
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/auth-helpers-nextjs": "^0.8.7",
|
||||||
"@supabase/supabase-js": "^2.38.5",
|
"@supabase/supabase-js": "^2.38.5",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-table": "^7.7.17",
|
"@types/react-table": "^7.7.17",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "^14.0.4",
|
"next": "^14.0.4",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
@ -759,6 +762,20 @@
|
|||||||
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/jsonwebtoken": {
|
||||||
|
"version": "9.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz",
|
||||||
|
"integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "20.19.2",
|
"version": "20.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.2.tgz",
|
||||||
@ -1715,6 +1732,14 @@
|
|||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-k38b3XOZKv60C4E2hVsXTolJWfkGRMbILBIe2IBITXciy5bOsTKot5kDrf3ZfufQtQOUN5mXceUEpU1rTl9Uog==",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
@ -17,13 +17,16 @@
|
|||||||
"@supabase/auth-helpers-nextjs": "^0.8.7",
|
"@supabase/auth-helpers-nextjs": "^0.8.7",
|
||||||
"@supabase/supabase-js": "^2.38.5",
|
"@supabase/supabase-js": "^2.38.5",
|
||||||
"@tailwindcss/forms": "^0.5.10",
|
"@tailwindcss/forms": "^0.5.10",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
"@types/react": "^18.2.45",
|
"@types/react": "^18.2.45",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@types/react-table": "^7.7.17",
|
"@types/react-table": "^7.7.17",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"date-fns": "^2.30.0",
|
"date-fns": "^2.30.0",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-react": "^0.294.0",
|
"lucide-react": "^0.294.0",
|
||||||
"next": "^14.0.4",
|
"next": "^14.0.4",
|
||||||
"postcss": "^8.4.32",
|
"postcss": "^8.4.32",
|
||||||
|
102
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 { useRouter } from 'next/router';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
|
||||||
import { toast } from 'react-hot-toast';
|
|
||||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||||
import { auth } from '@/lib/supabase';
|
|
||||||
|
|
||||||
interface LoginForm {
|
const LoginPage = () => {
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Login() {
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [form, setForm] = useState<LoginForm>({
|
const [formData, setFormData] = useState({
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: ''
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 防止重复提交
|
||||||
|
if (loading || isRedirecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/admin-login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.success) {
|
||||||
|
// 设置重定向状态,防止重复提交
|
||||||
|
setIsRedirecting(true);
|
||||||
|
|
||||||
|
// 存储用户信息和令牌
|
||||||
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
|
localStorage.setItem('access_token', data.token);
|
||||||
|
|
||||||
|
// 使用 window.location 进行重定向,避免 Next.js 路由问题
|
||||||
|
window.location.href = '/dashboard';
|
||||||
|
} else {
|
||||||
|
setError(data.error || '登录失败');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('登录错误:', error);
|
||||||
|
setError('网络错误,请稍后重试');
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setForm(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[name]: value
|
[name]: value
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// 预设账号快速填充
|
||||||
e.preventDefault();
|
const fillDemoAccount = (email: string, password: string) => {
|
||||||
|
if (loading || isRedirecting) return;
|
||||||
if (!form.email || !form.password) {
|
setFormData({ email, password });
|
||||||
toast.error('请填写所有必填字段');
|
setError('');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 检查是否为演示模式
|
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
||||||
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
|
||||||
|
|
||||||
if (isDemoMode) {
|
|
||||||
// 演示模式:检查测试账号
|
|
||||||
if (form.email === 'admin@demo.com' && form.password === 'admin123') {
|
|
||||||
toast.success('登录成功!');
|
|
||||||
// 在演示模式下直接跳转到仪表盘
|
|
||||||
router.push('/dashboard');
|
|
||||||
} else {
|
|
||||||
toast.error('演示模式:请使用测试账号 admin@demo.com / admin123');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 真实模式:使用 Supabase 认证
|
|
||||||
try {
|
|
||||||
await auth.signIn(form.email, form.password);
|
|
||||||
toast.success('登录成功!');
|
|
||||||
router.push('/dashboard');
|
|
||||||
} catch (authError: any) {
|
|
||||||
console.error('Supabase auth error:', authError);
|
|
||||||
toast.error(authError.message || '登录失败,请检查邮箱和密码');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Login error:', error);
|
|
||||||
toast.error('登录过程中发生错误,请稍后重试');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 填入测试账号
|
// 如果正在重定向,显示加载状态
|
||||||
const fillTestAccount = () => {
|
if (isRedirecting) {
|
||||||
setForm({
|
return (
|
||||||
email: 'admin@demo.com',
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
password: 'admin123'
|
<div className="text-center">
|
||||||
});
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||||
};
|
<div className="mt-4 text-lg text-gray-600">登录成功,正在跳转...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>管理员登录 - 口译服务管理后台</title>
|
<title>管理员登录 - 口译服务管理平台</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||||
<div className="max-w-md w-full space-y-8">
|
<div className="max-w-md w-full space-y-8">
|
||||||
<div>
|
<div>
|
||||||
@ -92,33 +97,35 @@ export default function Login() {
|
|||||||
管理员登录
|
管理员登录
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-2 text-center text-sm text-gray-600">
|
<p className="mt-2 text-center text-sm text-gray-600">
|
||||||
口译服务后台管理系统
|
口译服务管理后台系统
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 测试账号提示 */}
|
{/* 预设账号提示 */}
|
||||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||||
<div className="flex">
|
<h3 className="text-sm font-medium text-blue-800 mb-2">测试管理员账号</h3>
|
||||||
<div className="ml-3">
|
<div className="space-y-2 text-xs text-blue-700">
|
||||||
<h3 className="text-sm font-medium text-blue-800">
|
<div className="flex justify-between items-center">
|
||||||
测试账号
|
<span>系统管理员:admin@example.com / admin123</span>
|
||||||
</h3>
|
<button
|
||||||
<div className="mt-2 text-sm text-blue-700">
|
type="button"
|
||||||
<p>邮箱:admin@demo.com</p>
|
onClick={() => fillDemoAccount('admin@example.com', 'admin123')}
|
||||||
<p>密码:admin123</p>
|
className="text-blue-600 hover:text-blue-800 underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
<button
|
disabled={loading || isRedirecting}
|
||||||
type="button"
|
>
|
||||||
onClick={fillTestAccount}
|
使用
|
||||||
className="mt-2 text-xs text-blue-600 hover:text-blue-500 underline"
|
</button>
|
||||||
>
|
|
||||||
点击自动填入
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="rounded-md shadow-sm -space-y-px">
|
<div className="rounded-md shadow-sm -space-y-px">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="email" className="sr-only">
|
<label htmlFor="email" className="sr-only">
|
||||||
@ -130,9 +137,10 @@ export default function Login() {
|
|||||||
type="email"
|
type="email"
|
||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
disabled={loading || isRedirecting}
|
||||||
placeholder="管理员邮箱"
|
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm disabled:bg-gray-100"
|
||||||
value={form.email}
|
placeholder="邮箱地址"
|
||||||
|
value={formData.email}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -146,15 +154,17 @@ export default function Login() {
|
|||||||
type={showPassword ? 'text' : 'password'}
|
type={showPassword ? 'text' : 'password'}
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
required
|
required
|
||||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
disabled={loading || isRedirecting}
|
||||||
placeholder="管理员密码"
|
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm disabled:bg-gray-100"
|
||||||
value={form.password}
|
placeholder="密码"
|
||||||
|
value={formData.password}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
disabled={loading || isRedirecting}
|
||||||
>
|
>
|
||||||
{showPassword ? (
|
{showPassword ? (
|
||||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||||
@ -165,44 +175,34 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="text-sm">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
返回首页
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm">
|
|
||||||
<a
|
|
||||||
href="#"
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-500"
|
|
||||||
>
|
|
||||||
忘记密码?
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || isRedirecting}
|
||||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center">
|
<span className="flex items-center">
|
||||||
<div className="loading-spinner-sm mr-2"></div>
|
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
登录中...
|
登录中...
|
||||||
</div>
|
</span>
|
||||||
) : (
|
) : '登录'}
|
||||||
'登录'
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
如需添加新的管理员账号,请联系系统管理员
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default LoginPage;
|
223
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 { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import DashboardLayout from '../../components/Layout/DashboardLayout';
|
||||||
import Head from 'next/head';
|
import { getDemoData } from '../../lib/demo-data';
|
||||||
import Link from 'next/link';
|
import {
|
||||||
import { toast } from 'react-hot-toast';
|
UsersIcon,
|
||||||
import {
|
PhoneIcon,
|
||||||
PhoneIcon,
|
DocumentTextIcon,
|
||||||
VideoCameraIcon,
|
|
||||||
UserGroupIcon,
|
|
||||||
ClockIcon,
|
|
||||||
CurrencyDollarIcon,
|
CurrencyDollarIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ClockIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
PlayIcon,
|
ArrowUpIcon,
|
||||||
StopIcon,
|
ArrowDownIcon,
|
||||||
UserPlusIcon,
|
EyeIcon
|
||||||
ArrowRightOnRectangleIcon
|
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { auth, db, TABLES, realtime, supabase } from '@/lib/supabase';
|
|
||||||
import { getDemoData } from '@/lib/demo-data';
|
|
||||||
import { Call, CallStats, Interpreter, User } from '@/types';
|
|
||||||
import {
|
|
||||||
formatCurrency,
|
|
||||||
formatTime,
|
|
||||||
formatDuration,
|
|
||||||
getCallStatusText,
|
|
||||||
getCallModeText,
|
|
||||||
getStatusColor
|
|
||||||
} from '@/utils';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
|
|
||||||
interface DashboardProps {
|
interface DashboardStats {
|
||||||
user?: User;
|
totalUsers: number;
|
||||||
|
activeUsers: number;
|
||||||
|
totalCalls: number;
|
||||||
|
activeCalls: number;
|
||||||
|
totalOrders: number;
|
||||||
|
pendingOrders: number;
|
||||||
|
completedOrders: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
monthlyRevenue: number;
|
||||||
|
activeInterpreters: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard({ user }: DashboardProps) {
|
interface RecentActivity {
|
||||||
const router = useRouter();
|
id: string;
|
||||||
|
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 [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(() => {
|
useEffect(() => {
|
||||||
// 在演示模式下不检查用户认证
|
const loadDashboardData = async () => {
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
try {
|
||||||
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
setLoading(true);
|
||||||
|
|
||||||
if (!isDemoMode && !user) {
|
// 模拟加载延迟
|
||||||
router.push('/auth/login');
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
return;
|
|
||||||
}
|
// 使用演示数据
|
||||||
|
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'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
// 设置实时数据更新
|
setStats(mockStats);
|
||||||
const callsChannel = realtime.subscribe(
|
setActivities(mockActivities);
|
||||||
TABLES.CALLS,
|
} catch (error) {
|
||||||
() => {
|
console.error('Failed to load dashboard data:', error);
|
||||||
fetchDashboardData();
|
} 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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<DashboardLayout title="仪表盘">
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="loading-spinner"></div>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||||
</div>
|
</div>
|
||||||
</Layout>
|
</DashboardLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<DashboardLayout title="仪表盘">
|
||||||
<Head>
|
<div className="space-y-6">
|
||||||
<title>仪表盘 - 口译服务管理后台</title>
|
{/* 欢迎区域 */}
|
||||||
</Head>
|
<div className="bg-white shadow rounded-lg p-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">欢迎回来!</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
|
这里是您的管理仪表板,查看最新的业务数据和活动。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
{/* 统计卡片 */}
|
||||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="px-4 py-6 sm:px-0">
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
{/* 统计卡片 */}
|
<div className="p-5">
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
<div className="flex items-center">
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="flex-shrink-0">
|
||||||
<div className="p-5">
|
<UsersIcon className="h-6 w-6 text-blue-400" />
|
||||||
<div className="flex items-center">
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<PhoneIcon className="h-6 w-6 text-gray-400" />
|
<dl>
|
||||||
</div>
|
<dt className="text-sm font-medium text-gray-500 truncate">总用户数</dt>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<dd className="flex items-baseline">
|
||||||
<dl>
|
<div className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</div>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
<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" />
|
||||||
</dt>
|
<span className="sr-only">增加了</span>
|
||||||
<dd className="text-lg font-medium text-gray-900">
|
12%
|
||||||
{stats.total_calls_today}
|
</div>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-gray-50 px-5 py-3">
|
||||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
<div className="text-sm">
|
||||||
<div className="p-5">
|
<span className="font-medium text-gray-500">活跃用户: </span>
|
||||||
<div className="flex items-center">
|
<span className="text-gray-900">{stats?.activeUsers || 0}</span>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 主要内容区域 */}
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="p-5">
|
||||||
{/* 活跃通话列表 */}
|
<div className="flex items-center">
|
||||||
<div className="lg:col-span-2">
|
<div className="flex-shrink-0">
|
||||||
<div className="bg-white shadow rounded-lg">
|
<PhoneIcon className="h-6 w-6 text-green-400" />
|
||||||
<div className="px-4 py-5 sm:p-6">
|
</div>
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
<div className="ml-5 w-0 flex-1">
|
||||||
实时通话列表
|
<dl>
|
||||||
</h3>
|
<dt className="text-sm font-medium text-gray-500 truncate">总通话数</dt>
|
||||||
<div className="space-y-4">
|
<dd className="flex items-baseline">
|
||||||
{activeCalls.length === 0 ? (
|
<div className="text-2xl font-semibold text-gray-900">{stats?.totalCalls || 0}</div>
|
||||||
<p className="text-gray-500 text-center py-8">
|
<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" />
|
||||||
</p>
|
<span className="sr-only">增加了</span>
|
||||||
) : (
|
8%
|
||||||
activeCalls.map((call) => (
|
</div>
|
||||||
<div
|
</dd>
|
||||||
key={call.id}
|
</dl>
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="bg-gray-50 px-5 py-3">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span className="font-medium text-gray-500">进行中: </span>
|
||||||
|
<span className="text-gray-900">{stats?.activeCalls || 0}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 在线翻译员 */}
|
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div>
|
<div className="p-5">
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="flex items-center">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="flex-shrink-0">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
<DocumentTextIcon className="h-6 w-6 text-yellow-400" />
|
||||||
在线翻译员
|
|
||||||
</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>
|
</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>
|
</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 { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
|
import DashboardLayout from '../../components/Layout/DashboardLayout';
|
||||||
import { toast } from 'react-hot-toast';
|
import { toast } from 'react-hot-toast';
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@ -9,306 +10,601 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
BuildingOfficeIcon,
|
||||||
|
PhoneIcon,
|
||||||
|
EnvelopeIcon,
|
||||||
|
CalendarIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
ChevronLeftIcon,
|
ExclamationTriangleIcon,
|
||||||
ChevronRightIcon
|
ArrowDownTrayIcon,
|
||||||
|
FunnelIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { supabase, TABLES } from '@/lib/supabase';
|
import { getDemoData } from '../../lib/demo-data';
|
||||||
import { getDemoData } from '@/lib/demo-data';
|
import { formatTime } from '../../lib/utils';
|
||||||
import { User } from '@/types';
|
|
||||||
import { formatTime } from '@/utils';
|
|
||||||
import Layout from '@/components/Layout';
|
|
||||||
|
|
||||||
// 添加用户状态文本函数
|
interface User {
|
||||||
const getUserStatusText = (isActive: boolean): string => {
|
id: string;
|
||||||
return isActive ? '活跃' : '非活跃';
|
name: string;
|
||||||
};
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
company: string;
|
||||||
|
role: 'admin' | 'user' | 'interpreter';
|
||||||
|
status: 'active' | 'inactive' | 'pending';
|
||||||
|
created_at: string;
|
||||||
|
last_login: string;
|
||||||
|
total_calls: number;
|
||||||
|
total_spent: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface UserFilters {
|
interface UserFilters {
|
||||||
search: string;
|
search: string;
|
||||||
userType: 'all' | 'individual' | 'enterprise';
|
role: string;
|
||||||
status: 'all' | 'active' | 'inactive';
|
status: string;
|
||||||
sortBy: 'created_at' | 'full_name' | 'last_login';
|
company: string;
|
||||||
sortOrder: 'asc' | 'desc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function Users() {
|
||||||
|
const router = useRouter();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
|
||||||
const [filters, setFilters] = useState<UserFilters>({
|
const [filters, setFilters] = useState<UserFilters>({
|
||||||
search: '',
|
search: '',
|
||||||
userType: 'all',
|
role: '',
|
||||||
status: 'all',
|
status: '',
|
||||||
sortBy: 'created_at',
|
company: ''
|
||||||
sortOrder: 'desc'
|
|
||||||
});
|
});
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const pageSize = 20;
|
const pageSize = 10;
|
||||||
|
|
||||||
// 获取用户列表
|
useEffect(() => {
|
||||||
const fetchUsers = async (page = 1) => {
|
fetchUsers();
|
||||||
|
}, [currentPage, filters]);
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
// 检查是否为演示模式
|
// 模拟加载延迟
|
||||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
|
||||||
setIsDemoMode(isDemo);
|
|
||||||
|
|
||||||
if (isDemo) {
|
// 使用演示数据
|
||||||
// 使用演示数据
|
const mockUsers: User[] = [
|
||||||
const result = await getDemoData.users(filters);
|
{
|
||||||
setUsers(result.data);
|
id: '1',
|
||||||
setTotalCount(result.total);
|
name: '张三',
|
||||||
setTotalPages(Math.ceil(result.total / pageSize));
|
email: 'zhangsan@example.com',
|
||||||
setCurrentPage(page);
|
phone: '13800138001',
|
||||||
} else {
|
company: 'ABC科技有限公司',
|
||||||
// 使用真实数据
|
role: 'user',
|
||||||
let query = supabase
|
status: 'active',
|
||||||
.from(TABLES.USERS)
|
created_at: '2024-01-15T10:30:00Z',
|
||||||
.select('*', { count: 'exact' });
|
last_login: '2024-01-20T14:25:00Z',
|
||||||
|
total_calls: 25,
|
||||||
// 搜索过滤
|
total_spent: 1250
|
||||||
if (filters.search) {
|
},
|
||||||
query = query.or(`full_name.ilike.%${filters.search}%,email.ilike.%${filters.search}%`);
|
{
|
||||||
|
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') {
|
let filteredUsers = mockUsers;
|
||||||
const isActive = filters.status === 'active';
|
|
||||||
query = query.eq('is_active', isActive);
|
if (filters.search) {
|
||||||
}
|
filteredUsers = filteredUsers.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||||
// 排序
|
user.email.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||||
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' });
|
user.company.toLowerCase().includes(filters.search.toLowerCase())
|
||||||
|
);
|
||||||
// 分页
|
}
|
||||||
const from = (page - 1) * pageSize;
|
|
||||||
const to = from + pageSize - 1;
|
if (filters.role) {
|
||||||
query = query.range(from, to);
|
filteredUsers = filteredUsers.filter(user => user.role === filters.role);
|
||||||
|
}
|
||||||
const { data, error, count } = await query;
|
|
||||||
|
if (filters.status) {
|
||||||
if (error) throw error;
|
filteredUsers = filteredUsers.filter(user => user.status === filters.status);
|
||||||
|
}
|
||||||
setUsers(data || []);
|
|
||||||
setTotalCount(count || 0);
|
if (filters.company) {
|
||||||
setTotalPages(Math.ceil((count || 0) / pageSize));
|
filteredUsers = filteredUsers.filter(user =>
|
||||||
setCurrentPage(page);
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching users:', error);
|
console.error('Failed to fetch users:', error);
|
||||||
toast.error('获取用户列表失败');
|
toast.error('加载用户数据失败');
|
||||||
|
|
||||||
// 如果真实数据获取失败,切换到演示模式
|
|
||||||
if (!isDemoMode) {
|
|
||||||
setIsDemoMode(true);
|
|
||||||
const result = await getDemoData.users(filters);
|
|
||||||
setUsers(result.data);
|
|
||||||
setTotalCount(result.total);
|
|
||||||
setTotalPages(Math.ceil(result.total / pageSize));
|
|
||||||
setCurrentPage(page);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 处理筛选变更
|
const handleSearch = (value: string) => {
|
||||||
const handleFilterChange = (key: keyof UserFilters, value: any) => {
|
setFilters(prev => ({ ...prev, search: value }));
|
||||||
setFilters(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
// 应用筛选
|
|
||||||
const applyFilters = () => {
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
fetchUsers(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置筛选
|
const handleFilterChange = (key: keyof UserFilters, value: string) => {
|
||||||
const resetFilters = () => {
|
setFilters(prev => ({ ...prev, [key]: value }));
|
||||||
setFilters({
|
|
||||||
search: '',
|
|
||||||
userType: 'all',
|
|
||||||
status: 'all',
|
|
||||||
sortBy: 'created_at',
|
|
||||||
sortOrder: 'desc'
|
|
||||||
});
|
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
fetchUsers(1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const handleSelectUser = (userId: string) => {
|
||||||
fetchUsers();
|
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 (
|
return (
|
||||||
<Layout>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
<title>用户管理 - 口译服务管理后台</title>
|
<title>用户管理 - 翻译服务管理系统</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
<DashboardLayout title="用户管理">
|
||||||
<div className="px-4 py-6 sm:px-0">
|
<div className="space-y-6">
|
||||||
{/* 页面标题 */}
|
{/* 页面标题和操作 */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="sm:flex sm:items-center sm:justify-between">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
<div>
|
||||||
</div>
|
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-700">
|
||||||
{/* 搜索和筛选 */}
|
管理系统中的所有用户账户,包括用户、翻译员和管理员。
|
||||||
<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">
|
|
||||||
调整筛选条件或检查数据源
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="mt-4 sm:mt-0 sm:flex sm:space-x-3">
|
||||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
<button
|
||||||
<div className="px-4 py-5 sm:p-6">
|
onClick={handleExport}
|
||||||
<div className="flex items-center justify-between mb-4">
|
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"
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
>
|
||||||
用户列表 ({totalCount} 个用户)
|
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
|
||||||
</h3>
|
导出
|
||||||
</div>
|
</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 className="bg-white shadow rounded-lg p-6">
|
||||||
<div
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
key={user.id}
|
<div>
|
||||||
className="p-4 border border-gray-200 rounded-lg"
|
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
>
|
搜索
|
||||||
<div className="flex items-center justify-between">
|
</label>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="relative">
|
||||||
<img
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
className="h-10 w-10 rounded-full"
|
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||||
src={user.avatar_url || `https://ui-avatars.com/api/?name=${user.full_name || user.email}`}
|
</div>
|
||||||
alt={user.full_name || user.email}
|
<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>
|
</th>
|
||||||
<h4 className="text-sm font-medium text-gray-900">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
{user.full_name || user.email}
|
用户信息
|
||||||
</h4>
|
</th>
|
||||||
<p className="text-sm text-gray-500">{user.email}</p>
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<p className="text-xs text-gray-400">
|
角色/状态
|
||||||
注册时间: {formatTime(user.created_at)}
|
</th>
|
||||||
</p>
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
</div>
|
公司
|
||||||
</div>
|
</th>
|
||||||
<div className="flex items-center space-x-4">
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
统计数据
|
||||||
user.is_active
|
</th>
|
||||||
? 'bg-green-100 text-green-800'
|
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
: 'bg-red-100 text-red-800'
|
最后登录
|
||||||
}`}>
|
</th>
|
||||||
{getUserStatusText(user.is_active)}
|
<th scope="col" className="relative px-6 py-3">
|
||||||
</span>
|
<span className="sr-only">操作</span>
|
||||||
</div>
|
</th>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
</thead>
|
||||||
))}
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{users.map((user) => (
|
||||||
|
<tr key={user.id} className="hover:bg-gray-50">
|
||||||
|
<td className="relative px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
checked={selectedUsers.includes(user.id)}
|
||||||
|
onChange={() => handleSelectUser(user.id)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="flex-shrink-0 h-10 w-10">
|
||||||
|
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
|
||||||
|
<UserIcon className="h-6 w-6 text-gray-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{user.name}</div>
|
||||||
|
<div className="text-sm text-gray-500 flex items-center">
|
||||||
|
<EnvelopeIcon className="h-4 w-4 mr-1" />
|
||||||
|
{user.email}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 flex items-center">
|
||||||
|
<PhoneIcon className="h-4 w-4 mr-1" />
|
||||||
|
{user.phone}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}>
|
||||||
|
{getRoleText(user.role)}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}>
|
||||||
|
{getStatusText(user.status)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center text-sm text-gray-900">
|
||||||
|
<BuildingOfficeIcon className="h-4 w-4 mr-2 text-gray-400" />
|
||||||
|
{user.company}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<div>通话: {user.total_calls} 次</div>
|
||||||
|
<div>消费: ¥{user.total_spent}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<CalendarIcon className="h-4 w-4 mr-1" />
|
||||||
|
{user.last_login ? formatTime(user.last_login) : '从未登录'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/dashboard/users/${user.id}`)}
|
||||||
|
className="text-blue-600 hover:text-blue-900"
|
||||||
|
>
|
||||||
|
<EyeIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push(`/dashboard/users/${user.id}/edit`)}
|
||||||
|
className="text-yellow-600 hover:text-yellow-900"
|
||||||
|
>
|
||||||
|
<PencilIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm('确定要删除这个用户吗?')) {
|
||||||
|
toast.success('用户删除成功');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 分页 */}
|
{/* 分页 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-6 flex items-center justify-between">
|
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
<div className="flex-1 flex justify-between sm:hidden">
|
<div className="flex-1 flex justify-between sm:hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchUsers(currentPage - 1)}
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
上一页
|
上一页
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchUsers(currentPage + 1)}
|
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
下一页
|
下一页
|
||||||
</button>
|
</button>
|
||||||
@ -317,54 +613,49 @@ export default function UsersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-gray-700">
|
<p className="text-sm text-gray-700">
|
||||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 到{' '}
|
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 到{' '}
|
||||||
<span className="font-medium">
|
<span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span> 项,
|
||||||
{Math.min(currentPage * pageSize, totalCount)}
|
共 <span className="font-medium">{totalCount}</span> 项
|
||||||
</span>{' '}
|
|
||||||
条,共 <span className="font-medium">{totalCount}</span> 条记录
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchUsers(currentPage - 1)}
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<ChevronLeftIcon className="h-5 w-5" />
|
上一页
|
||||||
</button>
|
</button>
|
||||||
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
|
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||||
const page = i + 1;
|
<button
|
||||||
return (
|
key={page}
|
||||||
<button
|
onClick={() => setCurrentPage(page)}
|
||||||
key={page}
|
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||||
onClick={() => fetchUsers(page)}
|
page === currentPage
|
||||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||||
page === currentPage
|
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||||
? '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>
|
||||||
{page}
|
))}
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<button
|
<button
|
||||||
onClick={() => fetchUsers(currentPage + 1)}
|
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="h-5 w-5" />
|
下一页
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DashboardLayout>
|
||||||
</Layout>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
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