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

- 更新 DashboardLayout 组件,统一使用演示模式布局
- 实现仪表盘页面的完整演示数据和功能
- 完成用户管理页面的演示模式,包含搜索、过滤、分页等功能
- 实现通话记录页面的演示数据和录音播放功能
- 完成翻译员管理页面的演示模式
- 实现订单管理页面的完整功能
- 完成发票管理页面的演示数据
- 更新文档管理页面
- 添加 utils.ts 工具函数库
- 完善 API 路由和数据库结构
- 修复各种 TypeScript 类型错误
- 统一界面风格和用户体验
This commit is contained in:
2025-06-30 19:42:43 +08:00
parent 0b8be9377a
commit f20988b90c
36 changed files with 8752 additions and 3638 deletions
+472 -394
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+331 -442
View File
@@ -1,484 +1,373 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import {
PhoneIcon,
VideoCameraIcon,
UserGroupIcon,
ClockIcon,
import DashboardLayout from '../../components/Layout/DashboardLayout';
import { getDemoData } from '../../lib/demo-data';
import {
UsersIcon,
PhoneIcon,
DocumentTextIcon,
CurrencyDollarIcon,
CheckCircleIcon,
ClockIcon,
ExclamationTriangleIcon,
PlayIcon,
StopIcon,
UserPlusIcon,
ArrowRightOnRectangleIcon
ArrowUpIcon,
ArrowDownIcon,
EyeIcon
} from '@heroicons/react/24/outline';
import { auth, db, TABLES, realtime, supabase } from '@/lib/supabase';
import { getDemoData } from '@/lib/demo-data';
import { Call, CallStats, Interpreter, User } from '@/types';
import {
formatCurrency,
formatTime,
formatDuration,
getCallStatusText,
getCallModeText,
getStatusColor
} from '@/utils';
import Layout from '@/components/Layout';
interface DashboardProps {
user?: User;
interface DashboardStats {
totalUsers: number;
activeUsers: number;
totalCalls: number;
activeCalls: number;
totalOrders: number;
pendingOrders: number;
completedOrders: number;
totalRevenue: number;
monthlyRevenue: number;
activeInterpreters: number;
}
export default function Dashboard({ user }: DashboardProps) {
const router = useRouter();
interface RecentActivity {
id: string;
type: 'call' | 'order' | 'user' | 'system';
title: string;
description: string;
time: string;
status: 'success' | 'warning' | 'error' | 'info';
}
export default function Dashboard() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [activities, setActivities] = useState<RecentActivity[]>([]);
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<CallStats>({
total_calls_today: 0,
active_calls: 0,
average_response_time: 0,
online_interpreters: 0,
total_revenue_today: 0,
currency: 'CNY',
});
const [activeCalls, setActiveCalls] = useState<Call[]>([]);
const [onlineInterpreters, setOnlineInterpreters] = useState<Interpreter[]>([]);
const [isDemoMode, setIsDemoMode] = useState(false);
// 获取仪表盘数据
const fetchDashboardData = async () => {
try {
setLoading(true);
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemoMode);
if (isDemoMode) {
// 使用演示数据
const [statsData, callsData, interpretersData] = await Promise.all([
getDemoData.stats(),
getDemoData.calls(),
getDemoData.interpreters(),
]);
// 转换演示数据格式以匹配类型定义
setStats({
total_calls_today: statsData.todayCalls,
active_calls: statsData.activeCalls,
average_response_time: statsData.avgResponseTime,
online_interpreters: statsData.onlineInterpreters,
total_revenue_today: statsData.todayRevenue,
currency: 'CNY',
});
// 转换通话数据格式
const formattedCalls = callsData
.filter(call => call.status === 'active')
.map(call => ({
id: call.id,
caller_id: call.user_id,
callee_id: call.interpreter_id,
call_type: 'audio' as const,
call_mode: 'human_interpreter' as const,
status: call.status as 'active',
start_time: call.start_time,
end_time: call.end_time,
duration: call.duration,
cost: call.cost,
currency: 'CNY' as const,
created_at: call.created_at,
updated_at: call.created_at,
}));
// 转换翻译员数据格式
const formattedInterpreters = interpretersData
.filter(interpreter => interpreter.status !== 'offline')
.map(interpreter => ({
id: interpreter.id,
user_id: interpreter.id,
name: interpreter.name,
avatar_url: interpreter.avatar_url,
languages: interpreter.languages,
specializations: interpreter.specialties,
hourly_rate: 100,
currency: 'CNY' as const,
rating: interpreter.rating,
total_calls: 50,
status: interpreter.status === 'busy' ? 'busy' as const : 'online' as const,
is_certified: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}));
setActiveCalls(formattedCalls);
setOnlineInterpreters(formattedInterpreters);
} else {
// 使用真实数据
const today = new Date();
today.setHours(0, 0, 0, 0);
// 获取今日通话统计
const { data: todayCalls } = await supabase
.from(TABLES.CALLS)
.select('*')
.gte('created_at', today.toISOString());
// 获取活跃通话
const { data: activeCallsData } = await supabase
.from(TABLES.CALLS)
.select(`
*,
user:users(full_name, email),
interpreter:interpreters(name, rating)
`)
.eq('status', 'active');
// 获取在线翻译员
const { data: interpretersData } = await supabase
.from(TABLES.INTERPRETERS)
.select('*')
.neq('status', 'offline');
// 计算统计数据
const totalRevenue = todayCalls && todayCalls.length > 0
? todayCalls
.filter(call => call.status === 'ended')
.reduce((sum, call) => sum + call.cost, 0)
: 0;
const avgResponseTime = todayCalls && todayCalls.length > 0
? todayCalls.reduce((sum, call) => {
const startTime = new Date(call.start_time);
const createdTime = new Date(call.created_at);
return sum + (startTime.getTime() - createdTime.getTime()) / 1000;
}, 0) / todayCalls.length
: 0;
setStats({
total_calls_today: todayCalls?.length || 0,
active_calls: activeCallsData?.length || 0,
average_response_time: Math.round(avgResponseTime),
online_interpreters: interpretersData?.length || 0,
total_revenue_today: totalRevenue,
currency: 'CNY',
});
setActiveCalls(activeCallsData || []);
setOnlineInterpreters(interpretersData || []);
}
} catch (error) {
console.error('获取仪表盘数据失败:', error);
toast.error('获取数据失败,请稍后重试');
// 如果获取真实数据失败,切换到演示模式
setIsDemoMode(true);
const [statsData, callsData, interpretersData] = await Promise.all([
getDemoData.stats(),
getDemoData.calls(),
getDemoData.interpreters(),
]);
setStats({
total_calls_today: statsData.todayCalls,
active_calls: statsData.activeCalls,
average_response_time: statsData.avgResponseTime,
online_interpreters: statsData.onlineInterpreters,
total_revenue_today: statsData.todayRevenue,
currency: 'CNY',
});
// 设置空数组避免类型错误
setActiveCalls([]);
setOnlineInterpreters([]);
} finally {
setLoading(false);
}
};
// 强制结束通话
const handleEndCall = async (callId: string) => {
try {
await db.update(TABLES.CALLS, callId, {
status: 'ended',
end_time: new Date().toISOString()
});
toast.success('通话已结束');
fetchDashboardData();
} catch (error) {
console.error('Error ending call:', error);
toast.error('结束通话失败');
}
};
// 分配翻译员
const handleAssignInterpreter = async (callId: string, interpreterId: string) => {
try {
await db.update(TABLES.CALLS, callId, {
callee_id: interpreterId,
call_mode: 'human_interpreter'
});
toast.success('翻译员已分配');
fetchDashboardData();
} catch (error) {
console.error('Error assigning interpreter:', error);
toast.error('分配翻译员失败');
}
};
useEffect(() => {
// 在演示模式下不检查用户认证
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
if (!isDemoMode && !user) {
router.push('/auth/login');
return;
}
const loadDashboardData = async () => {
try {
setLoading(true);
// 模拟加载延迟
await new Promise(resolve => setTimeout(resolve, 1000));
// 使用演示数据
const mockStats: DashboardStats = {
totalUsers: 1248,
activeUsers: 856,
totalCalls: 3456,
activeCalls: 12,
totalOrders: 2789,
pendingOrders: 45,
completedOrders: 2654,
totalRevenue: 125000,
monthlyRevenue: 15600,
activeInterpreters: 23
};
fetchDashboardData();
const mockActivities: RecentActivity[] = [
{
id: '1',
type: 'call',
title: '新通话开始',
description: '张三开始了中英互译通话',
time: '2分钟前',
status: 'success'
},
{
id: '2',
type: 'order',
title: '订单完成',
description: '订单ORD-2024-001已完成,费用¥180',
time: '5分钟前',
status: 'success'
},
{
id: '3',
type: 'user',
title: '新用户注册',
description: 'ABC公司注册了企业账户',
time: '10分钟前',
status: 'info'
},
{
id: '4',
type: 'system',
title: '系统维护',
description: '系统将在今晚22:00-23:00进行维护',
time: '30分钟前',
status: 'warning'
},
{
id: '5',
type: 'call',
title: '通话异常',
description: '通话CALL-2024-003出现连接问题',
time: '1小时前',
status: 'error'
}
];
// 设置实时数据更新
const callsChannel = realtime.subscribe(
TABLES.CALLS,
() => {
fetchDashboardData();
setStats(mockStats);
setActivities(mockActivities);
} catch (error) {
console.error('Failed to load dashboard data:', error);
} finally {
setLoading(false);
}
);
const interpretersChannel = realtime.subscribe(
TABLES.INTERPRETERS,
() => {
fetchDashboardData();
}
);
// 每30秒刷新一次数据
const interval = setInterval(fetchDashboardData, 30000);
return () => {
clearInterval(interval);
realtime.unsubscribe(callsChannel);
realtime.unsubscribe(interpretersChannel);
};
}, [user, router]);
loadDashboardData();
}, []);
const getStatusColor = (status: string) => {
switch (status) {
case 'success':
return 'text-green-600 bg-green-100';
case 'warning':
return 'text-yellow-600 bg-yellow-100';
case 'error':
return 'text-red-600 bg-red-100';
default:
return 'text-blue-600 bg-blue-100';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'success':
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case 'warning':
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
case 'error':
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
default:
return <ClockIcon className="h-5 w-5 text-blue-500" />;
}
};
if (loading) {
return (
<Layout user={user}>
<div className="min-h-screen flex items-center justify-center">
<div className="loading-spinner"></div>
<DashboardLayout title="仪表盘">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</Layout>
</DashboardLayout>
);
}
return (
<Layout user={user}>
<Head>
<title> - </title>
</Head>
<DashboardLayout title="仪表盘">
<div className="space-y-6">
{/* 欢迎区域 */}
<div className="bg-white shadow rounded-lg p-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-600">
</p>
</div>
{/* 主要内容区域 */}
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* 统计卡片 */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<PhoneIcon className="h-6 w-6 text-gray-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.total_calls_today}
</dd>
</dl>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<UsersIcon className="h-6 w-6 text-blue-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
12%
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<VideoCameraIcon className="h-6 w-6 text-green-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.active_calls}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<UserGroupIcon className="h-6 w-6 text-blue-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
线
</dt>
<dd className="text-lg font-medium text-gray-900">
{stats.online_interpreters}
</dd>
</dl>
</div>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<CurrencyDollarIcon className="h-6 w-6 text-yellow-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">
</dt>
<dd className="text-lg font-medium text-gray-900">
{formatCurrency(stats.total_revenue_today, 'CNY')}
</dd>
</dl>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">{stats?.activeUsers || 0}</span>
</div>
</div>
</div>
{/* 主要内容区域 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 活跃通话列表 */}
<div className="lg:col-span-2">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
</h3>
<div className="space-y-4">
{activeCalls.length === 0 ? (
<p className="text-gray-500 text-center py-8">
</p>
) : (
activeCalls.map((call) => (
<div
key={call.id}
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
>
<div className="flex-1">
<div className="flex items-center space-x-3">
<div className={`call-status ${call.status}`}>
<div className="w-3 h-3 rounded-full bg-green-500"></div>
</div>
<div>
<p className="text-sm font-medium text-gray-900">
{getCallModeText(call.call_mode)}
</p>
<p className="text-xs text-gray-500">
{formatTime(call.start_time)}
</p>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}>
{getCallStatusText(call.status)}
</span>
<div className="flex space-x-1">
<button
onClick={() => handleEndCall(call.id)}
className="p-1 text-red-600 hover:text-red-500"
title="强制结束通话"
>
<StopIcon className="h-4 w-4" />
</button>
<button
onClick={() => {/* 跳转到通话详情 */}}
className="p-1 text-blue-600 hover:text-blue-500"
title="查看详情"
>
<PlayIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
))
)}
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<PhoneIcon className="h-6 w-6 text-green-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{stats?.totalCalls || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
8%
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">{stats?.activeCalls || 0}</span>
</div>
</div>
</div>
{/* 在线翻译员 */}
<div>
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
线
</h3>
<div className="space-y-3">
{onlineInterpreters.length === 0 ? (
<p className="text-gray-500 text-center py-4">
线
</p>
) : (
onlineInterpreters.slice(0, 5).map((interpreter) => (
<div
key={interpreter.id}
className="flex items-center justify-between"
>
<div className="flex items-center space-x-3">
<img
className="h-8 w-8 rounded-full"
src={interpreter.avatar_url || `https://ui-avatars.com/api/?name=${interpreter.name}`}
alt={interpreter.name}
/>
<div>
<p className="text-sm font-medium text-gray-900">
{interpreter.name}
</p>
<p className="text-xs text-gray-500">
: {interpreter.rating}/5
</p>
</div>
</div>
<div className="flex items-center space-x-1">
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
<span className="text-xs text-green-600">线</span>
</div>
</div>
))
)}
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<DocumentTextIcon className="h-6 w-6 text-yellow-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">{stats?.totalOrders || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
15%
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">{stats?.pendingOrders || 0}</span>
</div>
</div>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<CurrencyDollarIcon className="h-6 w-6 text-purple-400" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate"></dt>
<dd className="flex items-baseline">
<div className="text-2xl font-semibold text-gray-900">¥{stats?.totalRevenue?.toLocaleString() || 0}</div>
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
<span className="sr-only"></span>
22%
</div>
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="font-medium text-gray-500">: </span>
<span className="text-gray-900">¥{stats?.monthlyRevenue?.toLocaleString() || 0}</span>
</div>
</div>
</div>
</div>
{/* 最近活动和快速操作 */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
{/* 最近活动 */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"></h3>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-start space-x-3">
<div className="flex-shrink-0">
{getStatusIcon(activity.status)}
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900">{activity.title}</div>
<div className="text-sm text-gray-500">{activity.description}</div>
<div className="text-xs text-gray-400 mt-1">{activity.time}</div>
</div>
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(activity.status)}`}>
{activity.status === 'success' && '成功'}
{activity.status === 'warning' && '警告'}
{activity.status === 'error' && '错误'}
{activity.status === 'info' && '信息'}
</div>
</div>
))}
</div>
<div className="mt-6">
<button className="w-full bg-gray-50 border border-gray-300 rounded-md py-2 px-4 inline-flex justify-center items-center text-sm font-medium text-gray-700 hover:bg-gray-100">
<EyeIcon className="h-4 w-4 mr-2" />
</button>
</div>
</div>
</div>
{/* 快速操作 */}
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4"></h3>
<div className="grid grid-cols-2 gap-4">
<button className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-left hover:bg-blue-100 transition-colors">
<div className="flex items-center">
<UsersIcon className="h-8 w-8 text-blue-600" />
<div className="ml-3">
<div className="text-sm font-medium text-blue-900"></div>
<div className="text-xs text-blue-700"></div>
</div>
</div>
</button>
<button className="bg-green-50 border border-green-200 rounded-lg p-4 text-left hover:bg-green-100 transition-colors">
<div className="flex items-center">
<PhoneIcon className="h-8 w-8 text-green-600" />
<div className="ml-3">
<div className="text-sm font-medium text-green-900"></div>
<div className="text-xs text-green-700"></div>
</div>
</div>
</button>
<button className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-left hover:bg-yellow-100 transition-colors">
<div className="flex items-center">
<DocumentTextIcon className="h-8 w-8 text-yellow-600" />
<div className="ml-3">
<div className="text-sm font-medium text-yellow-900"></div>
<div className="text-xs text-yellow-700"></div>
</div>
</div>
</button>
<button className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-left hover:bg-purple-100 transition-colors">
<div className="flex items-center">
<CurrencyDollarIcon className="h-8 w-8 text-purple-600" />
<div className="ml-3">
<div className="text-sm font-medium text-purple-900"></div>
<div className="text-xs text-purple-700"></div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</div>
</Layout>
</DashboardLayout>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+689 -379
View File
File diff suppressed because it is too large Load Diff
+562 -271
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import DashboardLayout from '../../components/Layout/DashboardLayout';
import { toast } from 'react-hot-toast';
import {
MagnifyingGlassIcon,
@@ -9,306 +10,601 @@ import {
TrashIcon,
EyeIcon,
UserIcon,
BuildingOfficeIcon,
PhoneIcon,
EnvelopeIcon,
CalendarIcon,
CheckCircleIcon,
XCircleIcon,
ChevronLeftIcon,
ChevronRightIcon
ExclamationTriangleIcon,
ArrowDownTrayIcon,
FunnelIcon
} from '@heroicons/react/24/outline';
import { supabase, TABLES } from '@/lib/supabase';
import { getDemoData } from '@/lib/demo-data';
import { User } from '@/types';
import { formatTime } from '@/utils';
import Layout from '@/components/Layout';
import { getDemoData } from '../../lib/demo-data';
import { formatTime } from '../../lib/utils';
// 添加用户状态文本函数
const getUserStatusText = (isActive: boolean): string => {
return isActive ? '活跃' : '非活跃';
};
interface User {
id: string;
name: string;
email: string;
phone: string;
company: string;
role: 'admin' | 'user' | 'interpreter';
status: 'active' | 'inactive' | 'pending';
created_at: string;
last_login: string;
total_calls: number;
total_spent: number;
}
interface UserFilters {
search: string;
userType: 'all' | 'individual' | 'enterprise';
status: 'all' | 'active' | 'inactive';
sortBy: 'created_at' | 'full_name' | 'last_login';
sortOrder: 'asc' | 'desc';
role: string;
status: string;
company: string;
}
export default function UsersPage() {
export default function Users() {
const router = useRouter();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState<UserFilters>({
search: '',
userType: 'all',
status: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
role: '',
status: '',
company: ''
});
const router = useRouter();
const pageSize = 20;
const pageSize = 10;
// 获取用户列表
const fetchUsers = async (page = 1) => {
useEffect(() => {
fetchUsers();
}, [currentPage, filters]);
const fetchUsers = async () => {
try {
setLoading(true);
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemo);
// 模拟加载延迟
await new Promise(resolve => setTimeout(resolve, 800));
if (isDemo) {
// 使用演示数据
const result = await getDemoData.users(filters);
setUsers(result.data);
setTotalCount(result.total);
setTotalPages(Math.ceil(result.total / pageSize));
setCurrentPage(page);
} else {
// 使用真实数据
let query = supabase
.from(TABLES.USERS)
.select('*', { count: 'exact' });
// 搜索过滤
if (filters.search) {
query = query.or(`full_name.ilike.%${filters.search}%,email.ilike.%${filters.search}%`);
// 使用演示数据
const mockUsers: User[] = [
{
id: '1',
name: '张三',
email: 'zhangsan@example.com',
phone: '13800138001',
company: 'ABC科技有限公司',
role: 'user',
status: 'active',
created_at: '2024-01-15T10:30:00Z',
last_login: '2024-01-20T14:25:00Z',
total_calls: 25,
total_spent: 1250
},
{
id: '2',
name: '李四',
email: 'lisi@example.com',
phone: '13800138002',
company: 'XYZ贸易公司',
role: 'user',
status: 'active',
created_at: '2024-01-10T09:15:00Z',
last_login: '2024-01-19T16:45:00Z',
total_calls: 18,
total_spent: 890
},
{
id: '3',
name: '王五',
email: 'wangwu@example.com',
phone: '13800138003',
company: '翻译服务中心',
role: 'interpreter',
status: 'active',
created_at: '2024-01-05T11:20:00Z',
last_login: '2024-01-20T10:30:00Z',
total_calls: 156,
total_spent: 0
},
{
id: '4',
name: '赵六',
email: 'zhaoliu@example.com',
phone: '13800138004',
company: '管理员',
role: 'admin',
status: 'active',
created_at: '2024-01-01T08:00:00Z',
last_login: '2024-01-20T18:00:00Z',
total_calls: 5,
total_spent: 0
},
{
id: '5',
name: '孙七',
email: 'sunqi@example.com',
phone: '13800138005',
company: '新用户公司',
role: 'user',
status: 'pending',
created_at: '2024-01-18T15:30:00Z',
last_login: '',
total_calls: 0,
total_spent: 0
},
{
id: '6',
name: '周八',
email: 'zhouba@example.com',
phone: '13800138006',
company: '暂停用户公司',
role: 'user',
status: 'inactive',
created_at: '2024-01-12T13:45:00Z',
last_login: '2024-01-15T09:20:00Z',
total_calls: 8,
total_spent: 320
}
];
// 状态过滤
if (filters.status !== 'all') {
const isActive = filters.status === 'active';
query = query.eq('is_active', isActive);
}
// 排序
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' });
// 分页
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
query = query.range(from, to);
const { data, error, count } = await query;
if (error) throw error;
setUsers(data || []);
setTotalCount(count || 0);
setTotalPages(Math.ceil((count || 0) / pageSize));
setCurrentPage(page);
// 应用过滤
let filteredUsers = mockUsers;
if (filters.search) {
filteredUsers = filteredUsers.filter(user =>
user.name.toLowerCase().includes(filters.search.toLowerCase()) ||
user.email.toLowerCase().includes(filters.search.toLowerCase()) ||
user.company.toLowerCase().includes(filters.search.toLowerCase())
);
}
if (filters.role) {
filteredUsers = filteredUsers.filter(user => user.role === filters.role);
}
if (filters.status) {
filteredUsers = filteredUsers.filter(user => user.status === filters.status);
}
if (filters.company) {
filteredUsers = filteredUsers.filter(user =>
user.company.toLowerCase().includes(filters.company.toLowerCase())
);
}
// 分页
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedUsers = filteredUsers.slice(startIndex, endIndex);
setUsers(paginatedUsers);
setTotalCount(filteredUsers.length);
setTotalPages(Math.ceil(filteredUsers.length / pageSize));
} catch (error) {
console.error('Error fetching users:', error);
toast.error('获取用户列表失败');
// 如果真实数据获取失败,切换到演示模式
if (!isDemoMode) {
setIsDemoMode(true);
const result = await getDemoData.users(filters);
setUsers(result.data);
setTotalCount(result.total);
setTotalPages(Math.ceil(result.total / pageSize));
setCurrentPage(page);
}
console.error('Failed to fetch users:', error);
toast.error('加载用户数据失败');
} finally {
setLoading(false);
}
};
// 处理筛选变更
const handleFilterChange = (key: keyof UserFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value
}));
};
// 应用筛选
const applyFilters = () => {
const handleSearch = (value: string) => {
setFilters(prev => ({ ...prev, search: value }));
setCurrentPage(1);
fetchUsers(1);
};
// 重置筛选
const resetFilters = () => {
setFilters({
search: '',
userType: 'all',
status: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
const handleFilterChange = (key: keyof UserFilters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1);
fetchUsers(1);
};
useEffect(() => {
fetchUsers();
}, []);
const handleSelectUser = (userId: string) => {
setSelectedUsers(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
};
const handleSelectAll = () => {
if (selectedUsers.length === users.length) {
setSelectedUsers([]);
} else {
setSelectedUsers(users.map(user => user.id));
}
};
const handleBulkAction = async (action: string) => {
if (selectedUsers.length === 0) {
toast.error('请选择要操作的用户');
return;
}
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
switch (action) {
case 'activate':
toast.success(`已激活 ${selectedUsers.length} 个用户`);
break;
case 'deactivate':
toast.success(`已停用 ${selectedUsers.length} 个用户`);
break;
case 'delete':
toast.success(`已删除 ${selectedUsers.length} 个用户`);
break;
default:
break;
}
setSelectedUsers([]);
fetchUsers();
} catch (error) {
toast.error('操作失败');
}
};
const handleExport = async () => {
try {
toast.loading('正在导出用户数据...', { id: 'export' });
// 模拟导出延迟
await new Promise(resolve => setTimeout(resolve, 2000));
toast.success('用户数据导出成功', { id: 'export' });
} catch (error) {
toast.error('导出失败', { id: 'export' });
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'text-green-800 bg-green-100';
case 'inactive':
return 'text-red-800 bg-red-100';
case 'pending':
return 'text-yellow-800 bg-yellow-100';
default:
return 'text-gray-800 bg-gray-100';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return '活跃';
case 'inactive':
return '停用';
case 'pending':
return '待审核';
default:
return status;
}
};
const getRoleText = (role: string) => {
switch (role) {
case 'admin':
return '管理员';
case 'user':
return '用户';
case 'interpreter':
return '翻译员';
default:
return role;
}
};
const getRoleColor = (role: string) => {
switch (role) {
case 'admin':
return 'text-purple-800 bg-purple-100';
case 'user':
return 'text-blue-800 bg-blue-100';
case 'interpreter':
return 'text-green-800 bg-green-100';
default:
return 'text-gray-800 bg-gray-100';
}
};
return (
<Layout>
<>
<Head>
<title> - </title>
<title> - </title>
</Head>
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<div className="px-4 py-6 sm:px-0">
{/* 页面标题 */}
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-gray-900"></h1>
</div>
{/* 搜索和筛选 */}
<div className="bg-white shadow rounded-lg mb-6">
<div className="px-4 py-5 sm:p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{/* 搜索框 */}
<div className="lg:col-span-2">
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder="搜索用户名或邮箱..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* 状态筛选 */}
<div>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="all"></option>
<option value="active"></option>
<option value="inactive"></option>
</select>
</div>
{/* 排序 */}
<div>
<select
value={`${filters.sortBy}-${filters.sortOrder}`}
onChange={(e) => {
const [sortBy, sortOrder] = e.target.value.split('-');
handleFilterChange('sortBy', sortBy);
handleFilterChange('sortOrder', sortOrder);
}}
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
>
<option value="created_at-desc"> ()</option>
<option value="created_at-asc"> ()</option>
<option value="full_name-asc"> (A-Z)</option>
<option value="full_name-desc"> (Z-A)</option>
</select>
</div>
</div>
<div className="mt-4 flex space-x-3">
<button
onClick={applyFilters}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
>
</button>
<button
onClick={resetFilters}
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
>
</button>
</div>
</div>
</div>
{/* 用户列表 */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="loading-spinner"></div>
</div>
) : users.length === 0 ? (
<div className="text-center py-12">
<UserIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500">
<DashboardLayout title="用户管理">
<div className="space-y-6">
{/* 页面标题和操作 */}
<div className="sm:flex sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-700">
</p>
</div>
) : (
<div className="bg-white shadow overflow-hidden sm:rounded-md">
<div className="px-4 py-5 sm:p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
({totalCount} )
</h3>
</div>
<div className="mt-4 sm:mt-0 sm:flex sm:space-x-3">
<button
onClick={handleExport}
className="inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50"
>
<ArrowDownTrayIcon className="h-4 w-4 mr-2" />
</button>
<button
onClick={() => router.push('/dashboard/users/new')}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<PlusIcon className="h-4 w-4 mr-2" />
</button>
</div>
</div>
<div className="space-y-4">
{users.map((user) => (
<div
key={user.id}
className="p-4 border border-gray-200 rounded-lg"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<img
className="h-10 w-10 rounded-full"
src={user.avatar_url || `https://ui-avatars.com/api/?name=${user.full_name || user.email}`}
alt={user.full_name || user.email}
{/* 搜索和过滤器 */}
<div className="bg-white shadow rounded-lg p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<label htmlFor="search" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
id="search"
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="搜索用户名、邮箱或公司..."
value={filters.search}
onChange={(e) => handleSearch(e.target.value)}
/>
</div>
</div>
<div>
<label htmlFor="role" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="role"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={filters.role}
onChange={(e) => handleFilterChange('role', e.target.value)}
>
<option value=""></option>
<option value="admin"></option>
<option value="user"></option>
<option value="interpreter"></option>
</select>
</div>
<div>
<label htmlFor="status" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="status"
className="block w-full py-2 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
>
<option value=""></option>
<option value="active"></option>
<option value="inactive"></option>
<option value="pending"></option>
</select>
</div>
<div>
<label htmlFor="company" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
id="company"
className="block w-full py-2 px-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="过滤公司..."
value={filters.company}
onChange={(e) => handleFilterChange('company', e.target.value)}
/>
</div>
</div>
</div>
{/* 批量操作 */}
{selectedUsers.length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-sm font-medium text-blue-900">
{selectedUsers.length}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => handleBulkAction('activate')}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-green-700 bg-green-100 hover:bg-green-200"
>
<CheckCircleIcon className="h-4 w-4 mr-1" />
</button>
<button
onClick={() => handleBulkAction('deactivate')}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-yellow-700 bg-yellow-100 hover:bg-yellow-200"
>
<XCircleIcon className="h-4 w-4 mr-1" />
</button>
<button
onClick={() => handleBulkAction('delete')}
className="inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200"
>
<TrashIcon className="h-4 w-4 mr-1" />
</button>
</div>
</div>
</div>
)}
{/* 用户列表 */}
<div className="bg-white shadow rounded-lg overflow-hidden">
{loading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="relative px-6 py-3">
<input
type="checkbox"
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={selectedUsers.length === users.length && users.length > 0}
onChange={handleSelectAll}
/>
<div>
<h4 className="text-sm font-medium text-gray-900">
{user.full_name || user.email}
</h4>
<p className="text-sm text-gray-500">{user.email}</p>
<p className="text-xs text-gray-400">
: {formatTime(user.created_at)}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
user.is_active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{getUserStatusText(user.is_active)}
</span>
</div>
</div>
</div>
))}
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
/
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only"></span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="relative px-6 py-4">
<input
type="checkbox"
className="absolute left-4 top-1/2 -mt-2 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
checked={selectedUsers.includes(user.id)}
onChange={() => handleSelectUser(user.id)}
/>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="flex-shrink-0 h-10 w-10">
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<UserIcon className="h-6 w-6 text-gray-600" />
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">{user.name}</div>
<div className="text-sm text-gray-500 flex items-center">
<EnvelopeIcon className="h-4 w-4 mr-1" />
{user.email}
</div>
<div className="text-sm text-gray-500 flex items-center">
<PhoneIcon className="h-4 w-4 mr-1" />
{user.phone}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="space-y-1">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}>
{getRoleText(user.role)}
</span>
<br />
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}>
{getStatusText(user.status)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center text-sm text-gray-900">
<BuildingOfficeIcon className="h-4 w-4 mr-2 text-gray-400" />
{user.company}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div>: {user.total_calls} </div>
<div>: ¥{user.total_spent}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<CalendarIcon className="h-4 w-4 mr-1" />
{user.last_login ? formatTime(user.last_login) : '从未登录'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center space-x-2">
<button
onClick={() => router.push(`/dashboard/users/${user.id}`)}
className="text-blue-600 hover:text-blue-900"
>
<EyeIcon className="h-4 w-4" />
</button>
<button
onClick={() => router.push(`/dashboard/users/${user.id}/edit`)}
className="text-yellow-600 hover:text-yellow-900"
>
<PencilIcon className="h-4 w-4" />
</button>
<button
onClick={() => {
if (confirm('确定要删除这个用户吗?')) {
toast.success('用户删除成功');
}
}}
className="text-red-600 hover:text-red-900"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => fetchUsers(currentPage - 1)}
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => fetchUsers(currentPage + 1)}
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
@@ -317,54 +613,49 @@ export default function UsersPage() {
<div>
<p className="text-sm text-gray-700">
<span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> {' '}
<span className="font-medium">
{Math.min(currentPage * pageSize, totalCount)}
</span>{' '}
<span className="font-medium">{totalCount}</span>
<span className="font-medium">{Math.min(currentPage * pageSize, totalCount)}</span>
<span className="font-medium">{totalCount}</span>
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => fetchUsers(currentPage - 1)}
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
const page = i + 1;
return (
<button
key={page}
onClick={() => fetchUsers(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{page}
</button>
);
})}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
page === currentPage
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
}`}
>
{page}
</button>
))}
<button
onClick={() => fetchUsers(currentPage + 1)}
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
)}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
</Layout>
</DashboardLayout>
</>
);
}