Twilioapp-admin/pages/dashboard/interpreters.tsx

414 lines
16 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,
UserIcon,
StarIcon,
ChevronLeftIcon,
ChevronRightIcon
} from '@heroicons/react/24/outline';
import { supabase, TABLES } from '@/lib/supabase';
import { getDemoData } from '@/lib/demo-data';
import { Interpreter } from '@/types';
import { formatTime } from '@/utils';
import Layout from '@/components/Layout';
interface InterpreterFilters {
search: string;
status: 'all' | 'online' | 'busy' | 'offline';
sortBy: 'created_at' | 'name' | 'rating';
sortOrder: 'asc' | 'desc';
}
export default function InterpretersPage() {
const [interpreters, setInterpreters] = useState<Interpreter[]>([]);
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<InterpreterFilters>({
search: '',
status: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
const router = useRouter();
const pageSize = 20;
// 获取翻译员列表
const fetchInterpreters = 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.interpreters();
// 转换数据格式以匹配 Interpreter 类型
const formattedResult = result.map(item => ({
...item,
user_id: item.id,
specializations: item.specialties || [],
hourly_rate: 150,
currency: 'CNY' as const,
total_calls: Math.floor(Math.random() * 100),
is_certified: Math.random() > 0.5,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}));
setInterpreters(formattedResult);
setTotalCount(formattedResult.length);
setTotalPages(Math.ceil(formattedResult.length / pageSize));
setCurrentPage(page);
} else {
// 使用真实数据
let query = supabase
.from(TABLES.INTERPRETERS)
.select('*', { count: 'exact' });
// 搜索过滤
if (filters.search) {
query = query.or(`name.ilike.%${filters.search}%`);
}
// 状态过滤
if (filters.status !== 'all') {
query = query.eq('status', filters.status);
}
// 排序
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;
setInterpreters(data || []);
setTotalCount(count || 0);
setTotalPages(Math.ceil((count || 0) / pageSize));
setCurrentPage(page);
}
} catch (error) {
console.error('Error fetching interpreters:', error);
toast.error('获取翻译员列表失败');
// 如果真实数据获取失败,切换到演示模式
if (!isDemoMode) {
setIsDemoMode(true);
const result = await getDemoData.interpreters();
// 转换数据格式以匹配 Interpreter 类型
const formattedResult = result.map(item => ({
...item,
user_id: item.id,
specializations: item.specialties || [],
hourly_rate: 150,
currency: 'CNY' as const,
total_calls: Math.floor(Math.random() * 100),
is_certified: Math.random() > 0.5,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
}));
setInterpreters(formattedResult);
setTotalCount(formattedResult.length);
setTotalPages(Math.ceil(formattedResult.length / pageSize));
setCurrentPage(page);
}
} finally {
setLoading(false);
}
};
// 处理筛选变更
const handleFilterChange = (key: keyof InterpreterFilters, value: any) => {
setFilters(prev => ({
...prev,
[key]: value
}));
};
// 应用筛选
const applyFilters = () => {
setCurrentPage(1);
fetchInterpreters(1);
};
// 重置筛选
const resetFilters = () => {
setFilters({
search: '',
status: 'all',
sortBy: 'created_at',
sortOrder: 'desc'
});
setCurrentPage(1);
fetchInterpreters(1);
};
// 获取状态颜色
const getStatusColor = (status: string) => {
switch (status) {
case 'online':
return 'bg-green-100 text-green-800';
case 'busy':
return 'bg-yellow-100 text-yellow-800';
case 'offline':
return 'bg-gray-100 text-gray-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
// 获取状态文本
const getStatusText = (status: string) => {
switch (status) {
case 'online':
return '在线';
case 'busy':
return '忙碌';
case 'offline':
return '离线';
default:
return '未知';
}
};
useEffect(() => {
fetchInterpreters();
}, []);
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-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="online">线</option>
<option value="busy"></option>
<option value="offline">线</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="name-asc"> (A-Z)</option>
<option value="name-desc"> (Z-A)</option>
<option value="rating-desc"> ()</option>
<option value="rating-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>
) : interpreters.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">
</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="space-y-4">
{interpreters.map((interpreter) => (
<div
key={interpreter.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={interpreter.avatar_url || `https://ui-avatars.com/api/?name=${interpreter.name}`}
alt={interpreter.name}
/>
<div>
<h4 className="text-sm font-medium text-gray-900">
{interpreter.name}
</h4>
<p className="text-sm text-gray-500">
{interpreter.languages.join(', ')}
</p>
<p className="text-xs text-gray-400">
: {interpreter.specializations?.join(', ') || '无'}
</p>
</div>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-1">
<StarIcon className="h-4 w-4 text-yellow-400" />
<span className="text-sm text-gray-600">
{interpreter.rating || 0}/5
</span>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(interpreter.status)}`}>
{getStatusText(interpreter.status)}
</span>
</div>
</div>
</div>
))}
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<button
onClick={() => fetchInterpreters(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={() => fetchInterpreters(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={() => fetchInterpreters(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={() => fetchInterpreters(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={() => fetchInterpreters(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>
);
}