feat: 完成所有页面的演示模式实现
- 更新 DashboardLayout 组件,统一使用演示模式布局 - 实现仪表盘页面的完整演示数据和功能 - 完成用户管理页面的演示模式,包含搜索、过滤、分页等功能 - 实现通话记录页面的演示数据和录音播放功能 - 完成翻译员管理页面的演示模式 - 实现订单管理页面的完整功能 - 完成发票管理页面的演示数据 - 更新文档管理页面 - 添加 utils.ts 工具函数库 - 完善 API 路由和数据库结构 - 修复各种 TypeScript 类型错误 - 统一界面风格和用户体验
This commit is contained in:
@@ -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}`
|
||||
: '服务器内部错误'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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 : '未知错误'}`
|
||||
: '服务器内部错误'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 : '未知错误'}`
|
||||
: '服务器内部错误'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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: '方法不允许'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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: '连接测试失败'
|
||||
});
|
||||
}
|
||||
}
|
||||
+116
-116
@@ -1,90 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import { auth } from '@/lib/supabase';
|
||||
|
||||
interface LoginForm {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const LoginPage = () => {
|
||||
const router = useRouter();
|
||||
const [form, setForm] = useState<LoginForm>({
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: '',
|
||||
password: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 防止重复提交
|
||||
if (loading || isRedirecting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/admin-login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.success) {
|
||||
// 设置重定向状态,防止重复提交
|
||||
setIsRedirecting(true);
|
||||
|
||||
// 存储用户信息和令牌
|
||||
localStorage.setItem('user', JSON.stringify(data.user));
|
||||
localStorage.setItem('access_token', data.token);
|
||||
|
||||
// 使用 window.location 进行重定向,避免 Next.js 路由问题
|
||||
window.location.href = '/dashboard';
|
||||
} else {
|
||||
setError(data.error || '登录失败');
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录错误:', error);
|
||||
setError('网络错误,请稍后重试');
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setForm(prev => ({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.email || !form.password) {
|
||||
toast.error('请填写所有必填字段');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
|
||||
if (isDemoMode) {
|
||||
// 演示模式:检查测试账号
|
||||
if (form.email === 'admin@demo.com' && form.password === 'admin123') {
|
||||
toast.success('登录成功!');
|
||||
// 在演示模式下直接跳转到仪表盘
|
||||
router.push('/dashboard');
|
||||
} else {
|
||||
toast.error('演示模式:请使用测试账号 admin@demo.com / admin123');
|
||||
}
|
||||
} else {
|
||||
// 真实模式:使用 Supabase 认证
|
||||
try {
|
||||
await auth.signIn(form.email, form.password);
|
||||
toast.success('登录成功!');
|
||||
router.push('/dashboard');
|
||||
} catch (authError: any) {
|
||||
console.error('Supabase auth error:', authError);
|
||||
toast.error(authError.message || '登录失败,请检查邮箱和密码');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Login error:', error);
|
||||
toast.error('登录过程中发生错误,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
// 预设账号快速填充
|
||||
const fillDemoAccount = (email: string, password: string) => {
|
||||
if (loading || isRedirecting) return;
|
||||
setFormData({ email, password });
|
||||
setError('');
|
||||
};
|
||||
|
||||
// 填入测试账号
|
||||
const fillTestAccount = () => {
|
||||
setForm({
|
||||
email: 'admin@demo.com',
|
||||
password: 'admin123'
|
||||
});
|
||||
};
|
||||
// 如果正在重定向,显示加载状态
|
||||
if (isRedirecting) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<div className="mt-4 text-lg text-gray-600">登录成功,正在跳转...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>管理员登录 - 口译服务管理后台</title>
|
||||
<title>管理员登录 - 口译服务管理平台</title>
|
||||
</Head>
|
||||
|
||||
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
@@ -92,33 +97,35 @@ export default function Login() {
|
||||
管理员登录
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
口译服务后台管理系统
|
||||
口译服务管理后台系统
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 测试账号提示 */}
|
||||
|
||||
{/* 预设账号提示 */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-blue-800">
|
||||
测试账号
|
||||
</h3>
|
||||
<div className="mt-2 text-sm text-blue-700">
|
||||
<p>邮箱:admin@demo.com</p>
|
||||
<p>密码:admin123</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fillTestAccount}
|
||||
className="mt-2 text-xs text-blue-600 hover:text-blue-500 underline"
|
||||
>
|
||||
点击自动填入
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-blue-800 mb-2">测试管理员账号</h3>
|
||||
<div className="space-y-2 text-xs text-blue-700">
|
||||
<div className="flex justify-between items-center">
|
||||
<span>系统管理员:admin@example.com / admin123</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fillDemoAccount('admin@example.com', 'admin123')}
|
||||
className="text-blue-600 hover:text-blue-800 underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || isRedirecting}
|
||||
>
|
||||
使用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="email" className="sr-only">
|
||||
@@ -130,9 +137,10 @@ export default function Login() {
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="管理员邮箱"
|
||||
value={form.email}
|
||||
disabled={loading || isRedirecting}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm disabled:bg-gray-100"
|
||||
placeholder="邮箱地址"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
@@ -146,15 +154,17 @@ export default function Login() {
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||
placeholder="管理员密码"
|
||||
value={form.password}
|
||||
disabled={loading || isRedirecting}
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm disabled:bg-gray-100"
|
||||
placeholder="密码"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
disabled={loading || isRedirecting}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
@@ -165,44 +175,34 @@ export default function Login() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm">
|
||||
<Link
|
||||
href="/"
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
返回首页
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-sm">
|
||||
<a
|
||||
href="#"
|
||||
className="font-medium text-blue-600 hover:text-blue-500"
|
||||
>
|
||||
忘记密码?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading || isRedirecting}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex items-center">
|
||||
<div className="loading-spinner-sm mr-2"></div>
|
||||
<span className="flex items-center">
|
||||
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
登录中...
|
||||
</div>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</span>
|
||||
) : '登录'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
如需添加新的管理员账号,请联系系统管理员
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -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
|
||||
+472
-394
File diff suppressed because it is too large
Load Diff
+743
-426
File diff suppressed because it is too large
Load Diff
+613
-788
File diff suppressed because it is too large
Load Diff
+331
-442
@@ -1,484 +1,373 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
PhoneIcon,
|
||||
VideoCameraIcon,
|
||||
UserGroupIcon,
|
||||
ClockIcon,
|
||||
import DashboardLayout from '../../components/Layout/DashboardLayout';
|
||||
import { getDemoData } from '../../lib/demo-data';
|
||||
import {
|
||||
UsersIcon,
|
||||
PhoneIcon,
|
||||
DocumentTextIcon,
|
||||
CurrencyDollarIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
UserPlusIcon,
|
||||
ArrowRightOnRectangleIcon
|
||||
ArrowUpIcon,
|
||||
ArrowDownIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { auth, db, TABLES, realtime, supabase } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { Call, CallStats, Interpreter, User } from '@/types';
|
||||
import {
|
||||
formatCurrency,
|
||||
formatTime,
|
||||
formatDuration,
|
||||
getCallStatusText,
|
||||
getCallModeText,
|
||||
getStatusColor
|
||||
} from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface DashboardProps {
|
||||
user?: User;
|
||||
interface DashboardStats {
|
||||
totalUsers: number;
|
||||
activeUsers: number;
|
||||
totalCalls: number;
|
||||
activeCalls: number;
|
||||
totalOrders: number;
|
||||
pendingOrders: number;
|
||||
completedOrders: number;
|
||||
totalRevenue: number;
|
||||
monthlyRevenue: number;
|
||||
activeInterpreters: number;
|
||||
}
|
||||
|
||||
export default function Dashboard({ user }: DashboardProps) {
|
||||
const router = useRouter();
|
||||
interface RecentActivity {
|
||||
id: string;
|
||||
type: 'call' | 'order' | 'user' | 'system';
|
||||
title: string;
|
||||
description: string;
|
||||
time: string;
|
||||
status: 'success' | 'warning' | 'error' | 'info';
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [activities, setActivities] = useState<RecentActivity[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<CallStats>({
|
||||
total_calls_today: 0,
|
||||
active_calls: 0,
|
||||
average_response_time: 0,
|
||||
online_interpreters: 0,
|
||||
total_revenue_today: 0,
|
||||
currency: 'CNY',
|
||||
});
|
||||
const [activeCalls, setActiveCalls] = useState<Call[]>([]);
|
||||
const [onlineInterpreters, setOnlineInterpreters] = useState<Interpreter[]>([]);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
|
||||
// 获取仪表盘数据
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
setIsDemoMode(isDemoMode);
|
||||
|
||||
if (isDemoMode) {
|
||||
// 使用演示数据
|
||||
const [statsData, callsData, interpretersData] = await Promise.all([
|
||||
getDemoData.stats(),
|
||||
getDemoData.calls(),
|
||||
getDemoData.interpreters(),
|
||||
]);
|
||||
|
||||
// 转换演示数据格式以匹配类型定义
|
||||
setStats({
|
||||
total_calls_today: statsData.todayCalls,
|
||||
active_calls: statsData.activeCalls,
|
||||
average_response_time: statsData.avgResponseTime,
|
||||
online_interpreters: statsData.onlineInterpreters,
|
||||
total_revenue_today: statsData.todayRevenue,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
// 转换通话数据格式
|
||||
const formattedCalls = callsData
|
||||
.filter(call => call.status === 'active')
|
||||
.map(call => ({
|
||||
id: call.id,
|
||||
caller_id: call.user_id,
|
||||
callee_id: call.interpreter_id,
|
||||
call_type: 'audio' as const,
|
||||
call_mode: 'human_interpreter' as const,
|
||||
status: call.status as 'active',
|
||||
start_time: call.start_time,
|
||||
end_time: call.end_time,
|
||||
duration: call.duration,
|
||||
cost: call.cost,
|
||||
currency: 'CNY' as const,
|
||||
created_at: call.created_at,
|
||||
updated_at: call.created_at,
|
||||
}));
|
||||
|
||||
// 转换翻译员数据格式
|
||||
const formattedInterpreters = interpretersData
|
||||
.filter(interpreter => interpreter.status !== 'offline')
|
||||
.map(interpreter => ({
|
||||
id: interpreter.id,
|
||||
user_id: interpreter.id,
|
||||
name: interpreter.name,
|
||||
avatar_url: interpreter.avatar_url,
|
||||
languages: interpreter.languages,
|
||||
specializations: interpreter.specialties,
|
||||
hourly_rate: 100,
|
||||
currency: 'CNY' as const,
|
||||
rating: interpreter.rating,
|
||||
total_calls: 50,
|
||||
status: interpreter.status === 'busy' ? 'busy' as const : 'online' as const,
|
||||
is_certified: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
setActiveCalls(formattedCalls);
|
||||
setOnlineInterpreters(formattedInterpreters);
|
||||
} else {
|
||||
// 使用真实数据
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 获取今日通话统计
|
||||
const { data: todayCalls } = await supabase
|
||||
.from(TABLES.CALLS)
|
||||
.select('*')
|
||||
.gte('created_at', today.toISOString());
|
||||
|
||||
// 获取活跃通话
|
||||
const { data: activeCallsData } = await supabase
|
||||
.from(TABLES.CALLS)
|
||||
.select(`
|
||||
*,
|
||||
user:users(full_name, email),
|
||||
interpreter:interpreters(name, rating)
|
||||
`)
|
||||
.eq('status', 'active');
|
||||
|
||||
// 获取在线翻译员
|
||||
const { data: interpretersData } = await supabase
|
||||
.from(TABLES.INTERPRETERS)
|
||||
.select('*')
|
||||
.neq('status', 'offline');
|
||||
|
||||
// 计算统计数据
|
||||
const totalRevenue = todayCalls && todayCalls.length > 0
|
||||
? todayCalls
|
||||
.filter(call => call.status === 'ended')
|
||||
.reduce((sum, call) => sum + call.cost, 0)
|
||||
: 0;
|
||||
|
||||
const avgResponseTime = todayCalls && todayCalls.length > 0
|
||||
? todayCalls.reduce((sum, call) => {
|
||||
const startTime = new Date(call.start_time);
|
||||
const createdTime = new Date(call.created_at);
|
||||
return sum + (startTime.getTime() - createdTime.getTime()) / 1000;
|
||||
}, 0) / todayCalls.length
|
||||
: 0;
|
||||
|
||||
setStats({
|
||||
total_calls_today: todayCalls?.length || 0,
|
||||
active_calls: activeCallsData?.length || 0,
|
||||
average_response_time: Math.round(avgResponseTime),
|
||||
online_interpreters: interpretersData?.length || 0,
|
||||
total_revenue_today: totalRevenue,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
setActiveCalls(activeCallsData || []);
|
||||
setOnlineInterpreters(interpretersData || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取仪表盘数据失败:', error);
|
||||
toast.error('获取数据失败,请稍后重试');
|
||||
|
||||
// 如果获取真实数据失败,切换到演示模式
|
||||
setIsDemoMode(true);
|
||||
const [statsData, callsData, interpretersData] = await Promise.all([
|
||||
getDemoData.stats(),
|
||||
getDemoData.calls(),
|
||||
getDemoData.interpreters(),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
total_calls_today: statsData.todayCalls,
|
||||
active_calls: statsData.activeCalls,
|
||||
average_response_time: statsData.avgResponseTime,
|
||||
online_interpreters: statsData.onlineInterpreters,
|
||||
total_revenue_today: statsData.todayRevenue,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
// 设置空数组避免类型错误
|
||||
setActiveCalls([]);
|
||||
setOnlineInterpreters([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 强制结束通话
|
||||
const handleEndCall = async (callId: string) => {
|
||||
try {
|
||||
await db.update(TABLES.CALLS, callId, {
|
||||
status: 'ended',
|
||||
end_time: new Date().toISOString()
|
||||
});
|
||||
toast.success('通话已结束');
|
||||
fetchDashboardData();
|
||||
} catch (error) {
|
||||
console.error('Error ending call:', error);
|
||||
toast.error('结束通话失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 分配翻译员
|
||||
const handleAssignInterpreter = async (callId: string, interpreterId: string) => {
|
||||
try {
|
||||
await db.update(TABLES.CALLS, callId, {
|
||||
callee_id: interpreterId,
|
||||
call_mode: 'human_interpreter'
|
||||
});
|
||||
toast.success('翻译员已分配');
|
||||
fetchDashboardData();
|
||||
} catch (error) {
|
||||
console.error('Error assigning interpreter:', error);
|
||||
toast.error('分配翻译员失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 在演示模式下不检查用户认证
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
|
||||
if (!isDemoMode && !user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
const loadDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 模拟加载延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// 使用演示数据
|
||||
const mockStats: DashboardStats = {
|
||||
totalUsers: 1248,
|
||||
activeUsers: 856,
|
||||
totalCalls: 3456,
|
||||
activeCalls: 12,
|
||||
totalOrders: 2789,
|
||||
pendingOrders: 45,
|
||||
completedOrders: 2654,
|
||||
totalRevenue: 125000,
|
||||
monthlyRevenue: 15600,
|
||||
activeInterpreters: 23
|
||||
};
|
||||
|
||||
fetchDashboardData();
|
||||
const mockActivities: RecentActivity[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'call',
|
||||
title: '新通话开始',
|
||||
description: '张三开始了中英互译通话',
|
||||
time: '2分钟前',
|
||||
status: 'success'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'order',
|
||||
title: '订单完成',
|
||||
description: '订单ORD-2024-001已完成,费用¥180',
|
||||
time: '5分钟前',
|
||||
status: 'success'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'user',
|
||||
title: '新用户注册',
|
||||
description: 'ABC公司注册了企业账户',
|
||||
time: '10分钟前',
|
||||
status: 'info'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'system',
|
||||
title: '系统维护',
|
||||
description: '系统将在今晚22:00-23:00进行维护',
|
||||
time: '30分钟前',
|
||||
status: 'warning'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'call',
|
||||
title: '通话异常',
|
||||
description: '通话CALL-2024-003出现连接问题',
|
||||
time: '1小时前',
|
||||
status: 'error'
|
||||
}
|
||||
];
|
||||
|
||||
// 设置实时数据更新
|
||||
const callsChannel = realtime.subscribe(
|
||||
TABLES.CALLS,
|
||||
() => {
|
||||
fetchDashboardData();
|
||||
setStats(mockStats);
|
||||
setActivities(mockActivities);
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
);
|
||||
|
||||
const interpretersChannel = realtime.subscribe(
|
||||
TABLES.INTERPRETERS,
|
||||
() => {
|
||||
fetchDashboardData();
|
||||
}
|
||||
);
|
||||
|
||||
// 每30秒刷新一次数据
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
realtime.unsubscribe(callsChannel);
|
||||
realtime.unsubscribe(interpretersChannel);
|
||||
};
|
||||
}, [user, router]);
|
||||
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'text-green-600 bg-green-100';
|
||||
case 'warning':
|
||||
return 'text-yellow-600 bg-yellow-100';
|
||||
case 'error':
|
||||
return 'text-red-600 bg-red-100';
|
||||
default:
|
||||
return 'text-blue-600 bg-blue-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case 'warning':
|
||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
||||
case 'error':
|
||||
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
|
||||
default:
|
||||
return <ClockIcon className="h-5 w-5 text-blue-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="loading-spinner"></div>
|
||||
<DashboardLayout title="仪表盘">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
</Layout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<Head>
|
||||
<title>仪表盘 - 口译服务管理后台</title>
|
||||
</Head>
|
||||
<DashboardLayout title="仪表盘">
|
||||
<div className="space-y-6">
|
||||
{/* 欢迎区域 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">欢迎回来!</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
这里是您的管理仪表板,查看最新的业务数据和活动。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<PhoneIcon className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
今日通话总量
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.total_calls_today}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<UsersIcon className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">总用户数</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</div>
|
||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
||||
<span className="sr-only">增加了</span>
|
||||
12%
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<VideoCameraIcon className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
当前活跃通话
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.active_calls}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<UserGroupIcon className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
在线翻译员
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.online_interpreters}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CurrencyDollarIcon className="h-6 w-6 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
今日收入
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{formatCurrency(stats.total_revenue_today, 'CNY')}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-5 py-3">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-500">活跃用户: </span>
|
||||
<span className="text-gray-900">{stats?.activeUsers || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 活跃通话列表 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
实时通话列表
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{activeCalls.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
当前没有活跃通话
|
||||
</p>
|
||||
) : (
|
||||
activeCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`call-status ${call.status}`}>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{getCallModeText(call.call_mode)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatTime(call.start_time)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}>
|
||||
{getCallStatusText(call.status)}
|
||||
</span>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => handleEndCall(call.id)}
|
||||
className="p-1 text-red-600 hover:text-red-500"
|
||||
title="强制结束通话"
|
||||
>
|
||||
<StopIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {/* 跳转到通话详情 */}}
|
||||
className="p-1 text-blue-600 hover:text-blue-500"
|
||||
title="查看详情"
|
||||
>
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<PhoneIcon className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">总通话数</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-semibold text-gray-900">{stats?.totalCalls || 0}</div>
|
||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
||||
<span className="sr-only">增加了</span>
|
||||
8%
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-5 py-3">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-500">进行中: </span>
|
||||
<span className="text-gray-900">{stats?.activeCalls || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 在线翻译员 */}
|
||||
<div>
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
在线翻译员
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{onlineInterpreters.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">
|
||||
暂无翻译员在线
|
||||
</p>
|
||||
) : (
|
||||
onlineInterpreters.slice(0, 5).map((interpreter) => (
|
||||
<div
|
||||
key={interpreter.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
className="h-8 w-8 rounded-full"
|
||||
src={interpreter.avatar_url || `https://ui-avatars.com/api/?name=${interpreter.name}`}
|
||||
alt={interpreter.name}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{interpreter.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
评分: {interpreter.rating}/5
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
|
||||
<span className="text-xs text-green-600">在线</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<DocumentTextIcon className="h-6 w-6 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">总订单数</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-semibold text-gray-900">{stats?.totalOrders || 0}</div>
|
||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
||||
<span className="sr-only">增加了</span>
|
||||
15%
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-5 py-3">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-500">待处理: </span>
|
||||
<span className="text-gray-900">{stats?.pendingOrders || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CurrencyDollarIcon className="h-6 w-6 text-purple-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">总收入</dt>
|
||||
<dd className="flex items-baseline">
|
||||
<div className="text-2xl font-semibold text-gray-900">¥{stats?.totalRevenue?.toLocaleString() || 0}</div>
|
||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
||||
<span className="sr-only">增加了</span>
|
||||
22%
|
||||
</div>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-50 px-5 py-3">
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-gray-500">本月: </span>
|
||||
<span className="text-gray-900">¥{stats?.monthlyRevenue?.toLocaleString() || 0}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 最近活动和快速操作 */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
{/* 最近活动 */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">最近活动</h3>
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0">
|
||||
{getStatusIcon(activity.status)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{activity.title}</div>
|
||||
<div className="text-sm text-gray-500">{activity.description}</div>
|
||||
<div className="text-xs text-gray-400 mt-1">{activity.time}</div>
|
||||
</div>
|
||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(activity.status)}`}>
|
||||
{activity.status === 'success' && '成功'}
|
||||
{activity.status === 'warning' && '警告'}
|
||||
{activity.status === 'error' && '错误'}
|
||||
{activity.status === 'info' && '信息'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<button className="w-full bg-gray-50 border border-gray-300 rounded-md py-2 px-4 inline-flex justify-center items-center text-sm font-medium text-gray-700 hover:bg-gray-100">
|
||||
<EyeIcon className="h-4 w-4 mr-2" />
|
||||
查看所有活动
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快速操作 */}
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">快速操作</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-left hover:bg-blue-100 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<UsersIcon className="h-8 w-8 text-blue-600" />
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-blue-900">用户管理</div>
|
||||
<div className="text-xs text-blue-700">管理用户账户</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="bg-green-50 border border-green-200 rounded-lg p-4 text-left hover:bg-green-100 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<PhoneIcon className="h-8 w-8 text-green-600" />
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-green-900">通话监控</div>
|
||||
<div className="text-xs text-green-700">实时通话状态</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-left hover:bg-yellow-100 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<DocumentTextIcon className="h-8 w-8 text-yellow-600" />
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-yellow-900">订单管理</div>
|
||||
<div className="text-xs text-yellow-700">处理订单请求</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-left hover:bg-purple-100 transition-colors">
|
||||
<div className="flex items-center">
|
||||
<CurrencyDollarIcon className="h-8 w-8 text-purple-600" />
|
||||
<div className="ml-3">
|
||||
<div className="text-sm font-medium text-purple-900">财务报表</div>
|
||||
<div className="text-xs text-purple-700">查看收入统计</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
+685
-309
File diff suppressed because it is too large
Load Diff
+649
-395
File diff suppressed because it is too large
Load Diff
+689
-379
File diff suppressed because it is too large
Load Diff
+562
-271
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import DashboardLayout from '../../components/Layout/DashboardLayout';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
@@ -9,306 +10,601 @@ import {
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
UserIcon,
|
||||
BuildingOfficeIcon,
|
||||
PhoneIcon,
|
||||
EnvelopeIcon,
|
||||
CalendarIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
ExclamationTriangleIcon,
|
||||
ArrowDownTrayIcon,
|
||||
FunnelIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { User } from '@/types';
|
||||
import { formatTime } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
import { getDemoData } from '../../lib/demo-data';
|
||||
import { formatTime } from '../../lib/utils';
|
||||
|
||||
// 添加用户状态文本函数
|
||||
const getUserStatusText = (isActive: boolean): string => {
|
||||
return isActive ? '活跃' : '非活跃';
|
||||
};
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
company: string;
|
||||
role: 'admin' | 'user' | 'interpreter';
|
||||
status: 'active' | 'inactive' | 'pending';
|
||||
created_at: string;
|
||||
last_login: string;
|
||||
total_calls: number;
|
||||
total_spent: number;
|
||||
}
|
||||
|
||||
interface UserFilters {
|
||||
search: string;
|
||||
userType: 'all' | 'individual' | 'enterprise';
|
||||
status: 'all' | 'active' | 'inactive';
|
||||
sortBy: 'created_at' | 'full_name' | 'last_login';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
role: string;
|
||||
status: string;
|
||||
company: string;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
export default function Users() {
|
||||
const router = useRouter();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [filters, setFilters] = useState<UserFilters>({
|
||||
search: '',
|
||||
userType: 'all',
|
||||
status: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
role: '',
|
||||
status: '',
|
||||
company: ''
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
const pageSize = 10;
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = async (page = 1) => {
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [currentPage, filters]);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
setIsDemoMode(isDemo);
|
||||
// 模拟加载延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
|
||||
if (isDemo) {
|
||||
// 使用演示数据
|
||||
const result = await getDemoData.users(filters);
|
||||
setUsers(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(Math.ceil(result.total / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据
|
||||
let query = supabase
|
||||
.from(TABLES.USERS)
|
||||
.select('*', { count: 'exact' });
|
||||
|
||||
// 搜索过滤
|
||||
if (filters.search) {
|
||||
query = query.or(`full_name.ilike.%${filters.search}%,email.ilike.%${filters.search}%`);
|
||||
// 使用演示数据
|
||||
const mockUsers: User[] = [
|
||||
{
|
||||
id: '1',
|
||||
name: '张三',
|
||||
email: 'zhangsan@example.com',
|
||||
phone: '13800138001',
|
||||
company: 'ABC科技有限公司',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
created_at: '2024-01-15T10:30:00Z',
|
||||
last_login: '2024-01-20T14:25:00Z',
|
||||
total_calls: 25,
|
||||
total_spent: 1250
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '李四',
|
||||
email: 'lisi@example.com',
|
||||
phone: '13800138002',
|
||||
company: 'XYZ贸易公司',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
created_at: '2024-01-10T09:15:00Z',
|
||||
last_login: '2024-01-19T16:45:00Z',
|
||||
total_calls: 18,
|
||||
total_spent: 890
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '王五',
|
||||
email: 'wangwu@example.com',
|
||||
phone: '13800138003',
|
||||
company: '翻译服务中心',
|
||||
role: 'interpreter',
|
||||
status: 'active',
|
||||
created_at: '2024-01-05T11:20:00Z',
|
||||
last_login: '2024-01-20T10:30:00Z',
|
||||
total_calls: 156,
|
||||
total_spent: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '赵六',
|
||||
email: 'zhaoliu@example.com',
|
||||
phone: '13800138004',
|
||||
company: '管理员',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
created_at: '2024-01-01T08:00:00Z',
|
||||
last_login: '2024-01-20T18:00:00Z',
|
||||
total_calls: 5,
|
||||
total_spent: 0
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: '孙七',
|
||||
email: 'sunqi@example.com',
|
||||
phone: '13800138005',
|
||||
company: '新用户公司',
|
||||
role: 'user',
|
||||
status: 'pending',
|
||||
created_at: '2024-01-18T15:30:00Z',
|
||||
last_login: '',
|
||||
total_calls: 0,
|
||||
total_spent: 0
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: '周八',
|
||||
email: 'zhouba@example.com',
|
||||
phone: '13800138006',
|
||||
company: '暂停用户公司',
|
||||
role: 'user',
|
||||
status: 'inactive',
|
||||
created_at: '2024-01-12T13:45:00Z',
|
||||
last_login: '2024-01-15T09:20:00Z',
|
||||
total_calls: 8,
|
||||
total_spent: 320
|
||||
}
|
||||
];
|
||||
|
||||
// 状态过滤
|
||||
if (filters.status !== 'all') {
|
||||
const isActive = filters.status === 'active';
|
||||
query = query.eq('is_active', isActive);
|
||||
}
|
||||
|
||||
// 排序
|
||||
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' });
|
||||
|
||||
// 分页
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
query = query.range(from, to);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setUsers(data || []);
|
||||
setTotalCount(count || 0);
|
||||
setTotalPages(Math.ceil((count || 0) / pageSize));
|
||||
setCurrentPage(page);
|
||||
// 应用过滤器
|
||||
let filteredUsers = mockUsers;
|
||||
|
||||
if (filters.search) {
|
||||
filteredUsers = filteredUsers.filter(user =>
|
||||
user.name.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
user.email.toLowerCase().includes(filters.search.toLowerCase()) ||
|
||||
user.company.toLowerCase().includes(filters.search.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.role) {
|
||||
filteredUsers = filteredUsers.filter(user => user.role === filters.role);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
filteredUsers = filteredUsers.filter(user => user.status === filters.status);
|
||||
}
|
||||
|
||||
if (filters.company) {
|
||||
filteredUsers = filteredUsers.filter(user =>
|
||||
user.company.toLowerCase().includes(filters.company.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 分页
|
||||
const startIndex = (currentPage - 1) * pageSize;
|
||||
const endIndex = startIndex + pageSize;
|
||||
const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
|
||||
|
||||
setUsers(paginatedUsers);
|
||||
setTotalCount(filteredUsers.length);
|
||||
setTotalPages(Math.ceil(filteredUsers.length / pageSize));
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
toast.error('获取用户列表失败');
|
||||
|
||||
// 如果真实数据获取失败,切换到演示模式
|
||||
if (!isDemoMode) {
|
||||
setIsDemoMode(true);
|
||||
const result = await getDemoData.users(filters);
|
||||
setUsers(result.data);
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(Math.ceil(result.total / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error('加载用户数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof UserFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
const handleSearch = (value: string) => {
|
||||
setFilters(prev => ({ ...prev, search: value }));
|
||||
setCurrentPage(1);
|
||||
fetchUsers(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
userType: 'all',
|
||||
status: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const handleFilterChange = (key: keyof UserFilters, value: string) => {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
setCurrentPage(1);
|
||||
fetchUsers(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
const handleSelectUser = (userId: string) => {
|
||||
setSelectedUsers(prev =>
|
||||
prev.includes(userId)
|
||||
? prev.filter(id => id !== userId)
|
||||
: [...prev, userId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedUsers.length === users.length) {
|
||||
setSelectedUsers([]);
|
||||
} else {
|
||||
setSelectedUsers(users.map(user => user.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
if (selectedUsers.length === 0) {
|
||||
toast.error('请选择要操作的用户');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
switch (action) {
|
||||
case 'activate':
|
||||
toast.success(`已激活 ${selectedUsers.length} 个用户`);
|
||||
break;
|
||||
case 'deactivate':
|
||||
toast.success(`已停用 ${selectedUsers.length} 个用户`);
|
||||
break;
|
||||
case 'delete':
|
||||
toast.success(`已删除 ${selectedUsers.length} 个用户`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
setSelectedUsers([]);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
toast.error('操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
toast.loading('正在导出用户数据...', { id: 'export' });
|
||||
|
||||
// 模拟导出延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
toast.success('用户数据导出成功', { id: 'export' });
|
||||
} catch (error) {
|
||||
toast.error('导出失败', { id: 'export' });
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'text-green-800 bg-green-100';
|
||||
case 'inactive':
|
||||
return 'text-red-800 bg-red-100';
|
||||
case 'pending':
|
||||
return 'text-yellow-800 bg-yellow-100';
|
||||
default:
|
||||
return 'text-gray-800 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '活跃';
|
||||
case 'inactive':
|
||||
return '停用';
|
||||
case 'pending':
|
||||
return '待审核';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleText = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return '管理员';
|
||||
case 'user':
|
||||
return '用户';
|
||||
case 'interpreter':
|
||||
return '翻译员';
|
||||
default:
|
||||
return role;
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return 'text-purple-800 bg-purple-100';
|
||||
case 'user':
|
||||
return 'text-blue-800 bg-blue-100';
|
||||
case 'interpreter':
|
||||
return 'text-green-800 bg-green-100';
|
||||
default:
|
||||
return 'text-gray-800 bg-gray-100';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<>
|
||||
<Head>
|
||||
<title>用户管理 - 口译服务管理后台</title>
|
||||
<title>用户管理 - 翻译服务管理系统</title>
|
||||
</Head>
|
||||
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="bg-white shadow rounded-lg mb-6">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名或邮箱..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="inactive">非活跃</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<div>
|
||||
<select
|
||||
value={`${filters.sortBy}-${filters.sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [sortBy, sortOrder] = e.target.value.split('-');
|
||||
handleFilterChange('sortBy', sortBy);
|
||||
handleFilterChange('sortOrder', sortOrder);
|
||||
}}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="created_at-desc">创建时间 (新到旧)</option>
|
||||
<option value="created_at-asc">创建时间 (旧到新)</option>
|
||||
<option value="full_name-asc">姓名 (A-Z)</option>
|
||||
<option value="full_name-desc">姓名 (Z-A)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-3">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 用户列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<UserIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">暂无用户</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
调整筛选条件或检查数据源
|
||||
|
||||
<DashboardLayout title="用户管理">
|
||||
<div className="space-y-6">
|
||||
{/* 页面标题和操作 */}
|
||||
<div className="sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
管理系统中的所有用户账户,包括用户、翻译员和管理员。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
用户列表 ({totalCount} 个用户)
|
||||
</h3>
|
||||
</div>
|
||||
<div className="mt-4 sm:mt-0 sm:flex sm:space-x-3">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
|
||||
导出
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push('/dashboard/users/new')}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
添加用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img
|
||||
className="h-10 w-10 rounded-full"
|
||||
src={user.avatar_url || `https://ui-avatars.com/api/?name=${user.full_name || user.email}`}
|
||||
alt={user.full_name || user.email}
|
||||
{/* 搜索和过滤器 */}
|
||||
<div className="bg-white shadow rounded-lg p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
搜索
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="搜索用户名、邮箱或公司..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
角色
|
||||
</label>
|
||||
<select
|
||||
id="role"
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
value={filters.role}
|
||||
onChange={(e) => handleFilterChange('role', e.target.value)}
|
||||
>
|
||||
<option value="">全部角色</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="user">用户</option>
|
||||
<option value="interpreter">翻译员</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="inactive">停用</option>
|
||||
<option value="pending">待审核</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
公司
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company"
|
||||
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="过滤公司..."
|
||||
value={filters.company}
|
||||
onChange={(e) => handleFilterChange('company', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 批量操作 */}
|
||||
{selectedUsers.length > 0 && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm font-medium text-blue-900">
|
||||
已选择 {selectedUsers.length} 个用户
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleBulkAction('activate')}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200"
|
||||
>
|
||||
<CheckCircleIcon className="h-4 w-4 mr-1" />
|
||||
激活
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('deactivate')}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-yellow-700 bg-yellow-100 hover:bg-yellow-200"
|
||||
>
|
||||
<XCircleIcon className="h-4 w-4 mr-1" />
|
||||
停用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4 mr-1" />
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 用户列表 */}
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" className="relative px-6 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
checked={selectedUsers.length === users.length && users.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{user.full_name || user.email}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
注册时间: {formatTime(user.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
user.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{getUserStatusText(user.is_active)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
用户信息
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
角色/状态
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
公司
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
统计数据
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
最后登录
|
||||
</th>
|
||||
<th scope="col" className="relative px-6 py-3">
|
||||
<span className="sr-only">操作</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="relative px-6 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
checked={selectedUsers.includes(user.id)}
|
||||
onChange={() => handleSelectUser(user.id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0 h-10 w-10">
|
||||
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
|
||||
<UserIcon className="h-6 w-6 text-gray-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{user.name}</div>
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
<EnvelopeIcon className="h-4 w-4 mr-1" />
|
||||
{user.email}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
<PhoneIcon className="h-4 w-4 mr-1" />
|
||||
{user.phone}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="space-y-1">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}>
|
||||
{getRoleText(user.role)}
|
||||
</span>
|
||||
<br />
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}>
|
||||
{getStatusText(user.status)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center text-sm text-gray-900">
|
||||
<BuildingOfficeIcon className="h-4 w-4 mr-2 text-gray-400" />
|
||||
{user.company}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div>通话: {user.total_calls} 次</div>
|
||||
<div>消费: ¥{user.total_spent}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div className="flex items-center">
|
||||
<CalendarIcon className="h-4 w-4 mr-1" />
|
||||
{user.last_login ? formatTime(user.last_login) : '从未登录'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/users/${user.id}`)}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/dashboard/users/${user.id}/edit`)}
|
||||
className="text-yellow-600 hover:text-yellow-900"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (confirm('确定要删除这个用户吗?')) {
|
||||
toast.success('用户删除成功');
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchUsers(currentPage - 1)}
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchUsers(currentPage + 1)}
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
@@ -317,54 +613,49 @@ export default function UsersPage() {
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 到{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, totalCount)}
|
||||
</span>{' '}
|
||||
条,共 <span className="font-medium">{totalCount}</span> 条记录
|
||||
<span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span> 项,
|
||||
共 <span className="font-medium">{totalCount}</span> 项
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => fetchUsers(currentPage - 1)}
|
||||
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
上一页
|
||||
</button>
|
||||
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
|
||||
const page = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => fetchUsers(page)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => fetchUsers(currentPage + 1)}
|
||||
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
下一页
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
</DashboardLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user