484 lines
17 KiB
TypeScript

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,
CurrencyDollarIcon,
ExclamationTriangleIcon,
PlayIcon,
StopIcon,
UserPlusIcon,
ArrowRightOnRectangleIcon
} 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;
}
export default function Dashboard({ user }: DashboardProps) {
const router = useRouter();
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;
}
fetchDashboardData();
// 设置实时数据更新
const callsChannel = realtime.subscribe(
TABLES.CALLS,
() => {
fetchDashboardData();
}
);
const interpretersChannel = realtime.subscribe(
TABLES.INTERPRETERS,
() => {
fetchDashboardData();
}
);
// 每30秒刷新一次数据
const interval = setInterval(fetchDashboardData, 30000);
return () => {
clearInterval(interval);
realtime.unsubscribe(callsChannel);
realtime.unsubscribe(interpretersChannel);
};
}, [user, router]);
if (loading) {
return (
<Layout user={user}>
<div className="min-h-screen flex items-center justify-center">
<div className="loading-spinner"></div>
</div>
</Layout>
);
}
return (
<Layout user={user}>
<Head>
<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="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>
</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>
</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>
</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>
</div>
</div>
</div>
</div>
</div>
</Layout>
);
}