556 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import { toast } from 'react-hot-toast';
import {
MagnifyingGlassIcon,
PhoneIcon,
ChevronLeftIcon,
ChevronRightIcon,
PlayIcon,
StopIcon,
EyeIcon
} from '@heroicons/react/24/outline';
import { supabase, TABLES } from '@/lib/supabase';
import { getDemoData } from '@/lib/demo-data';
import { Call } from '@/types';
import { formatTime } from '@/utils';
import Layout from '@/components/Layout';
interface CallFilters {
search: string;
status: 'all' | 'pending' | 'active' | 'ended' | 'cancelled' | 'failed';
call_type: 'all' | 'audio' | 'video';
call_mode: 'all' | 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpreter';
sortBy: 'created_at' | 'duration' | 'cost';
sortOrder: 'asc' | 'desc';
}
export default function CallsPage() {
const [calls, setCalls] = useState<Call[]>([]);
const [loading, setLoading] = useState(true);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [isDemoMode, setIsDemoMode] = useState(false);
const [filters, setFilters] = useState<CallFilters>({
search: '',
status: 'all',
call_type: 'all',
call_mode: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
const router = useRouter();
const pageSize = 20;
// 获取通话记录列表
const fetchCalls = async (page = 1) => {
try {
setLoading(true);
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
setIsDemoMode(isDemo);
if (isDemo) {
// 使用演示数据
const result = await getDemoData.calls();
// 转换数据格式以匹配 Call 类型
const formattedResult = result.map(item => ({
...item,
caller_id: item.user_id,
callee_id: item.interpreter_id,
call_type: 'audio' as const,
call_mode: 'human_interpreter' as const,
end_time: item.end_time || undefined,
room_sid: undefined,
twilio_call_sid: undefined,
quality_rating: undefined,
currency: 'CNY' as const,
updated_at: item.created_at
}));
setCalls(formattedResult);
setTotalCount(formattedResult.length);
setTotalPages(Math.ceil(formattedResult.length / pageSize));
setCurrentPage(page);
} else {
// 使用真实数据
let query = supabase
.from(TABLES.CALLS)
.select('*', { count: 'exact' });
// 搜索过滤
if (filters.search) {
query = query.or(`caller_id.ilike.%${filters.search}%,callee_id.ilike.%${filters.search}%`);
}
// 状态过滤
if (filters.status !== 'all') {
query = query.eq('status', filters.status);
}
// 通话类型过滤
if (filters.call_type !== 'all') {
query = query.eq('call_type', filters.call_type);
}
// 通话模式过滤
if (filters.call_mode !== 'all') {
query = query.eq('call_mode', filters.call_mode);
}
// 排序
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;
setCalls(data || []);
setTotalCount(count || 0);
setTotalPages(Math.ceil((count || 0) / pageSize));
setCurrentPage(page);
}
} catch (error) {
console.error('Error fetching calls:', error);
toast.error('获取通话记录失败');
// 如果真实数据获取失败,切换到演示模式
if (!isDemoMode) {
setIsDemoMode(true);
const result = await getDemoData.calls();
const formattedResult = result.map(item => ({
...item,
caller_id: item.user_id,
callee_id: item.interpreter_id,
call_type: 'audio' as const,
call_mode: 'human_interpreter' as const,
end_time: item.end_time || undefined,
room_sid: undefined,
twilio_call_sid: undefined,
quality_rating: undefined,
currency: 'CNY' as const,
updated_at: item.created_at
}));
setCalls(formattedResult);
setTotalCount(formattedResult.length);
setTotalPages(Math.ceil(formattedResult.length / pageSize));
setCurrentPage(page);
}
} finally {
setLoading(false);
}
};
// 处理筛选变更
const handleFilterChange = (key: keyof CallFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value
}));
};
// 应用筛选
const applyFilters = () => {
setCurrentPage(1);
fetchCalls(1);
};
// 重置筛选
const resetFilters = () => {
setFilters({
search: '',
status: 'all',
call_type: 'all',
call_mode: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
setCurrentPage(1);
fetchCalls(1);
};
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return 'bg-green-100 text-green-800';
case 'pending':
return 'bg-yellow-100 text-yellow-800';
case 'ended':
return 'bg-blue-100 text-blue-800';
case 'cancelled':
return 'bg-red-100 text-red-800';
case 'failed':
return 'bg-red-100 text-red-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return '进行中';
case 'pending':
return '待接听';
case 'ended':
return '已结束';
case 'cancelled':
return '已取消';
case 'failed':
return '失败';
default:
return '未知';
}
};
// 获取通话类型文本
const getCallTypeText = (type: string) => {
switch (type) {
case 'audio':
return '语音通话';
case 'video':
return '视频通话';
default:
return '未知';
}
};
// 获取通话模式文本
const getCallModeText = (mode: string) => {
switch (mode) {
case 'ai_voice':
return 'AI语音';
case 'ai_video':
return 'AI视频';
case 'sign_language':
return '手语翻译';
case 'human_interpreter':
return '人工翻译';
default:
return '未知';
}
};
// 格式化时长
const formatDuration = (seconds?: number) => {
if (!seconds) return '-';
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}${remainingSeconds}`;
};
useEffect(() => {
fetchCalls();
}, []);
return (
<Layout>
<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="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-5">
{/* 搜索框 */}
<div className="lg:col-span-1">
<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="搜索用户ID..."
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="pending"></option>
<option value="ended"></option>
<option value="cancelled"></option>
<option value="failed"></option>
</select>
</div>
{/* 通话类型筛选 */}
<div>
<select
value={filters.call_type}
onChange={(e) => handleFilterChange('call_type', 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="audio"></option>
<option value="video"></option>
</select>
</div>
{/* 通话模式筛选 */}
<div>
<select
value={filters.call_mode}
onChange={(e) => handleFilterChange('call_mode', 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="ai_voice">AI语音</option>
<option value="ai_video">AI视频</option>
<option value="sign_language"></option>
<option value="human_interpreter"></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="duration-desc"> ()</option>
<option value="duration-asc"> ()</option>
<option value="cost-desc"> ()</option>
<option value="cost-asc"> ()</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>
) : calls.length === 0 ? (
<div className="text-center py-12">
<PhoneIcon className="mx-auto h-12 w-12 text-gray-400" />
<h3 className="mt-2 text-sm font-medium text-gray-900"></h3>
<p className="mt-1 text-sm text-gray-500">
</p>
</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="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
/
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{calls.map((call) => (
<tr key={call.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
{call.id}
</div>
<div className="text-sm text-gray-500">
: {call.caller_id}
</div>
{call.callee_id && (
<div className="text-sm text-gray-500">
: {call.callee_id}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{getCallTypeText(call.call_type)}
</div>
<div className="text-sm text-gray-500">
{getCallModeText(call.call_mode)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDuration(call.duration)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="text-sm font-medium text-gray-900">
¥{call.cost.toFixed(2)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}>
{call.status === 'active' && <PlayIcon className="h-3 w-3 mr-1" />}
{call.status === 'ended' && <StopIcon className="h-3 w-3 mr-1" />}
{getStatusText(call.status)}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{formatTime(call.start_time)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
className="text-blue-600 hover:text-blue-900 mr-3"
onClick={() => {
// 查看通话详情
toast.success('查看通话详情功能待实现');
}}
>
<EyeIcon className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => fetchCalls(currentPage - 1)}
disabled={currentPage === 1}
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
>
</button>
<button
onClick={() => fetchCalls(currentPage + 1)}
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"
>
</button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<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>
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => fetchCalls(currentPage - 1)}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
>
<ChevronLeftIcon className="h-5 w-5" />
</button>
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
const page = i + 1;
return (
<button
key={page}
onClick={() => fetchCalls(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={() => fetchCalls(currentPage + 1)}
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"
>
<ChevronRightIcon className="h-5 w-5" />
</button>
</nav>
</div>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
</Layout>
);
}