feat: 完成所有页面的演示模式实现
- 更新 DashboardLayout 组件,统一使用演示模式布局 - 实现仪表盘页面的完整演示数据和功能 - 完成用户管理页面的演示模式,包含搜索、过滤、分页等功能 - 实现通话记录页面的演示数据和录音播放功能 - 完成翻译员管理页面的演示模式 - 实现订单管理页面的完整功能 - 完成发票管理页面的演示数据 - 更新文档管理页面 - 添加 utils.ts 工具函数库 - 完善 API 路由和数据库结构 - 修复各种 TypeScript 类型错误 - 统一界面风格和用户体验
This commit is contained in:
+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