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

634 lines
26 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 DashboardLayout from '../../components/Layout/DashboardLayout';
import { toast } from 'react-hot-toast';
import {
PhoneIcon,
MagnifyingGlassIcon,
PlayIcon,
StopIcon,
PauseIcon,
DocumentTextIcon,
CalendarIcon,
ClockIcon,
UserIcon,
LanguageIcon,
SpeakerWaveIcon,
ArrowDownTrayIcon,
FunnelIcon,
EyeIcon,
CheckCircleIcon,
XCircleIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
import { getDemoData } from '../../lib/demo-data';
import { formatTime } from '../../lib/utils';
interface CallRecord {
id: string;
user_name: string;
interpreter_name: string;
language_pair: string;
start_time: string;
end_time: string;
duration: number;
status: 'active' | 'completed' | 'failed' | 'cancelled';
call_type: 'audio' | 'video';
recording_url?: string;
cost: number;
notes?: string;
}
interface CallFilters {
search: string;
status: string;
language: string;
date_range: string;
call_type: string;
}
export default function CallRecords() {
const router = useRouter();
const [calls, setCalls] = useState<CallRecord[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCalls, setSelectedCalls] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalCount, setTotalCount] = useState(0);
const [filters, setFilters] = useState<CallFilters>({
search: '',
status: '',
language: '',
date_range: '',
call_type: ''
});
const pageSize = 10;
useEffect(() => {
fetchCalls();
}, [currentPage, filters]);
const fetchCalls = async () => {
try {
setLoading(true);
// 模拟加载延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 使用演示数据
const mockCalls: CallRecord[] = [
{
id: '1',
user_name: '张三',
interpreter_name: '王五',
language_pair: '中文-英文',
start_time: '2024-01-20T14:30:00Z',
end_time: '2024-01-20T15:15:00Z',
duration: 2700, // 45分钟
status: 'completed',
call_type: 'video',
recording_url: 'https://example.com/recording1.mp4',
cost: 180,
notes: '商务会议翻译,客户满意度高'
},
{
id: '2',
user_name: '李四',
interpreter_name: '赵六',
language_pair: '中文-日文',
start_time: '2024-01-20T10:00:00Z',
end_time: '2024-01-20T10:30:00Z',
duration: 1800, // 30分钟
status: 'completed',
call_type: 'audio',
recording_url: 'https://example.com/recording2.mp3',
cost: 120,
notes: '技术文档翻译'
},
{
id: '3',
user_name: '王二',
interpreter_name: '孙七',
language_pair: '中文-韩文',
start_time: '2024-01-20T16:00:00Z',
end_time: '',
duration: 0,
status: 'active',
call_type: 'video',
cost: 0,
notes: '正在进行中的通话'
},
{
id: '4',
user_name: '陈五',
interpreter_name: '周八',
language_pair: '中文-法文',
start_time: '2024-01-19T09:30:00Z',
end_time: '2024-01-19T09:35:00Z',
duration: 300, // 5分钟
status: 'failed',
call_type: 'audio',
cost: 0,
notes: '连接失败,技术问题'
},
{
id: '5',
user_name: '刘六',
interpreter_name: '吴九',
language_pair: '中文-德文',
start_time: '2024-01-19T14:00:00Z',
end_time: '2024-01-19T14:05:00Z',
duration: 300, // 5分钟
status: 'cancelled',
call_type: 'video',
cost: 0,
notes: '用户取消通话'
},
{
id: '6',
user_name: '黄七',
interpreter_name: '郑十',
language_pair: '中文-西班牙文',
start_time: '2024-01-18T11:00:00Z',
end_time: '2024-01-18T12:30:00Z',
duration: 5400, // 90分钟
status: 'completed',
call_type: 'video',
recording_url: 'https://example.com/recording3.mp4',
cost: 360,
notes: '法律合同翻译,专业性强'
}
];
// 应用过滤器
let filteredCalls = mockCalls;
if (filters.search) {
filteredCalls = filteredCalls.filter(call =>
call.user_name.toLowerCase().includes(filters.search.toLowerCase()) ||
call.interpreter_name.toLowerCase().includes(filters.search.toLowerCase()) ||
call.language_pair.toLowerCase().includes(filters.search.toLowerCase())
);
}
if (filters.status) {
filteredCalls = filteredCalls.filter(call => call.status === filters.status);
}
if (filters.language) {
filteredCalls = filteredCalls.filter(call =>
call.language_pair.toLowerCase().includes(filters.language.toLowerCase())
);
}
if (filters.call_type) {
filteredCalls = filteredCalls.filter(call => call.call_type === filters.call_type);
}
// 分页
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const paginatedCalls = filteredCalls.slice(startIndex, endIndex);
setCalls(paginatedCalls);
setTotalCount(filteredCalls.length);
setTotalPages(Math.ceil(filteredCalls.length / pageSize));
} catch (error) {
console.error('Failed to fetch calls:', error);
toast.error('加载通话记录失败');
} finally {
setLoading(false);
}
};
const handleSearch = (value: string) => {
setFilters(prev => ({ ...prev, search: value }));
setCurrentPage(1);
};
const handleFilterChange = (key: keyof CallFilters, value: string) => {
setFilters(prev => ({ ...prev, [key]: value }));
setCurrentPage(1);
};
const handleSelectCall = (callId: string) => {
setSelectedCalls(prev =>
prev.includes(callId)
? prev.filter(id => id !== callId)
: [...prev, callId]
);
};
const handleSelectAll = () => {
if (selectedCalls.length === calls.length) {
setSelectedCalls([]);
} else {
setSelectedCalls(calls.map(call => call.id));
}
};
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 handlePlayRecording = (recordingUrl: string) => {
if (recordingUrl) {
toast.success('开始播放录音');
// 这里可以集成音频播放器
} else {
toast.error('录音文件不存在');
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed':
return 'text-green-800 bg-green-100';
case 'active':
return 'text-blue-800 bg-blue-100';
case 'failed':
return 'text-red-800 bg-red-100';
case 'cancelled':
return 'text-gray-800 bg-gray-100';
default:
return 'text-gray-800 bg-gray-100';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'completed':
return '已完成';
case 'active':
return '进行中';
case 'failed':
return '失败';
case 'cancelled':
return '已取消';
default:
return status;
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircleIcon className="h-4 w-4 text-green-500" />;
case 'active':
return <PlayIcon className="h-4 w-4 text-blue-500" />;
case 'failed':
return <XCircleIcon className="h-4 w-4 text-red-500" />;
case 'cancelled':
return <StopIcon className="h-4 w-4 text-gray-500" />;
default:
return <ClockIcon className="h-4 w-4 text-gray-500" />;
}
};
const formatDuration = (seconds: number) => {
if (seconds === 0) return '-';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
};
return (
<>
<Head>
<title> - </title>
</Head>
<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="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>
</div>
</div>
{/* 搜索和过滤器 */}
<div className="bg-white shadow rounded-lg p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<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="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="completed"></option>
<option value="failed"></option>
<option value="cancelled"></option>
</select>
</div>
<div>
<label htmlFor="language" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
id="language"
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.language}
onChange={(e) => handleFilterChange('language', e.target.value)}
/>
</div>
<div>
<label htmlFor="call_type" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="call_type"
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.call_type}
onChange={(e) => handleFilterChange('call_type', e.target.value)}
>
<option value=""></option>
<option value="audio"></option>
<option value="video"></option>
</select>
</div>
<div>
<label htmlFor="date_range" className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
id="date_range"
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.date_range}
onChange={(e) => handleFilterChange('date_range', e.target.value)}
>
<option value=""></option>
<option value="today"></option>
<option value="yesterday"></option>
<option value="week"></option>
<option value="month"></option>
</select>
</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={selectedCalls.length === calls.length && calls.length > 0}
onChange={handleSelectAll}
/>
</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="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">
{calls.map((call) => (
<tr key={call.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={selectedCalls.includes(call.id)}
onChange={() => handleSelectCall(call.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 flex items-center justify-center ${
call.call_type === 'video' ? 'bg-blue-100' : 'bg-green-100'
}`}>
<PhoneIcon className={`h-6 w-6 ${
call.call_type === 'video' ? 'text-blue-600' : 'text-green-600'
}`} />
</div>
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{call.call_type === 'video' ? '视频通话' : '音频通话'}
</div>
<div className="text-sm text-gray-500 flex items-center">
<CalendarIcon className="h-4 w-4 mr-1" />
{formatTime(call.start_time)}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="space-y-1">
<div className="flex items-center text-sm text-gray-900">
<UserIcon className="h-4 w-4 mr-1 text-gray-400" />
{call.user_name}
</div>
<div className="flex items-center text-sm text-gray-500">
<LanguageIcon className="h-4 w-4 mr-1 text-gray-400" />
{call.interpreter_name}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{call.language_pair}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<ClockIcon className="h-4 w-4 mr-1" />
{formatDuration(call.duration)}
</div>
<div className="text-sm font-medium text-gray-900">
¥{call.cost}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
{getStatusIcon(call.status)}
<span className={`ml-2 inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(call.status)}`}>
{getStatusText(call.status)}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{call.recording_url ? (
<button
onClick={() => handlePlayRecording(call.recording_url!)}
className="flex items-center text-blue-600 hover:text-blue-900"
>
<SpeakerWaveIcon className="h-4 w-4 mr-1" />
</button>
) : (
<span className="text-gray-400"></span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => router.push(`/dashboard/calls/${call.id}`)}
className="text-blue-600 hover:text-blue-900"
>
<EyeIcon className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<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={() => 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 disabled:cursor-not-allowed"
>
</button>
<button
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 disabled:cursor-not-allowed"
>
</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={() => 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 disabled:cursor-not-allowed"
>
</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={() => 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 disabled:cursor-not-allowed"
>
</button>
</nav>
</div>
</div>
</div>
)}
</>
)}
</div>
</div>
</DashboardLayout>
</>
);
}