feat: 完成口译服务管理后台核心功能开发
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
DocumentTextIcon,
|
||||
CloudArrowUpIcon,
|
||||
CloudArrowDownIcon,
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
PencilIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationCircleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { formatTime } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
user_id: string;
|
||||
original_name: string;
|
||||
file_size: number;
|
||||
file_type: string;
|
||||
source_language: string;
|
||||
target_language: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
translated_url?: string;
|
||||
cost: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_name?: string;
|
||||
}
|
||||
|
||||
interface DocumentFilters {
|
||||
search: string;
|
||||
status: 'all' | 'pending' | 'processing' | 'completed' | 'failed';
|
||||
language: string;
|
||||
fileType: string;
|
||||
sortBy: 'created_at' | 'file_size' | 'cost' | 'progress';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
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<DocumentFilters>({
|
||||
search: '',
|
||||
status: 'all',
|
||||
language: 'all',
|
||||
fileType: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取文档数据
|
||||
const fetchDocuments = 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.documents();
|
||||
setDocuments(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据 - 这里需要根据实际数据库结构调整
|
||||
// 暂时使用演示数据
|
||||
const result = await getDemoData.documents();
|
||||
setDocuments(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching documents:', error);
|
||||
toast.error('获取文档列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof DocumentFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchDocuments(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
status: 'all',
|
||||
language: 'all',
|
||||
fileType: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchDocuments(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'processing':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-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 'completed':
|
||||
return '已完成';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
case 'pending':
|
||||
return '等待中';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-600" />;
|
||||
case 'processing':
|
||||
return <ClockIcon className="h-4 w-4 text-blue-600" />;
|
||||
case 'pending':
|
||||
return <ClockIcon className="h-4 w-4 text-yellow-600" />;
|
||||
case 'failed':
|
||||
return <ExclamationCircleIcon className="h-4 w-4 text-red-600" />;
|
||||
default:
|
||||
return <ClockIcon className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 删除文档
|
||||
const handleDeleteDocument = async (documentId: string) => {
|
||||
if (confirm('确定要删除此文档吗?')) {
|
||||
try {
|
||||
// 这里应该调用删除API
|
||||
toast.success('文档删除成功');
|
||||
fetchDocuments();
|
||||
} catch (error) {
|
||||
toast.error('删除文档失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文档
|
||||
const handleDownloadDocument = async (document: Document) => {
|
||||
try {
|
||||
// 这里应该调用下载API
|
||||
toast.success('开始下载文档');
|
||||
} catch (error) {
|
||||
toast.error('下载文档失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<button 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">
|
||||
<CloudArrowUpIcon className="h-4 w-4 mr-2" />
|
||||
上传文档
|
||||
</button>
|
||||
</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-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="pending">等待中</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语言筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.language}
|
||||
onChange={(e) => handleFilterChange('language', 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="zh-en">中文→英文</option>
|
||||
<option value="en-zh">英文→中文</option>
|
||||
<option value="zh-ja">中文→日文</option>
|
||||
<option value="ja-zh">日文→中文</option>
|
||||
<option value="zh-ko">中文→韩文</option>
|
||||
<option value="ko-zh">韩文→中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 文件类型筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.fileType}
|
||||
onChange={(e) => handleFilterChange('fileType', 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="pdf">PDF</option>
|
||||
<option value="docx">Word</option>
|
||||
<option value="txt">文本</option>
|
||||
<option value="pptx">PowerPoint</option>
|
||||
<option value="xlsx">Excel</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>
|
||||
) : (
|
||||
<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">
|
||||
{documents.map((document) => (
|
||||
<div key={document.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<DocumentTextIcon className="h-8 w-8 text-gray-400" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{document.original_name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{document.user_name} | {formatFileSize(document.file_size)} | {document.file_type.toUpperCase()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{document.source_language} → {document.target_language} | ¥{document.cost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 进度条 */}
|
||||
{document.status === 'processing' && (
|
||||
<div className="w-24">
|
||||
<div className="bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${document.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{document.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(document.status)}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(document.status)}`}>
|
||||
{getStatusText(document.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
title="查看详情"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{document.status === 'completed' && document.translated_url && (
|
||||
<button
|
||||
onClick={() => handleDownloadDocument(document)}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
title="下载译文"
|
||||
>
|
||||
<CloudArrowDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button className="text-yellow-600 hover:text-yellow-900">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteDocument(document.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
创建时间: {formatTime(document.created_at)} |
|
||||
更新时间: {formatTime(document.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{documents.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<DocumentTextIcon 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>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchDocuments(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={() => fetchDocuments(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={() => fetchDocuments(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={() => fetchDocuments(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={() => fetchDocuments(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,858 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
BuildingOfficeIcon,
|
||||
UsersIcon,
|
||||
DocumentTextIcon,
|
||||
CurrencyDollarIcon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { formatTime } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface EnterpriseContract {
|
||||
id: string;
|
||||
enterprise_id: string;
|
||||
enterprise_name: string;
|
||||
contract_number: string;
|
||||
contract_type: 'annual' | 'monthly' | 'project';
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
total_amount: number;
|
||||
currency: string;
|
||||
status: 'active' | 'expired' | 'terminated';
|
||||
service_rates: {
|
||||
ai_voice: number; // AI语音翻译费率(元/分钟)
|
||||
ai_video: number; // AI视频翻译费率(元/分钟)
|
||||
sign_language: number; // 手语翻译费率(元/分钟)
|
||||
human_interpreter: number; // 真人翻译费率(元/分钟)
|
||||
document_translation: number; // 文档翻译费率(元/字)
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface EnterpriseEmployee {
|
||||
id: string;
|
||||
enterprise_id: string;
|
||||
enterprise_name: string;
|
||||
name: string;
|
||||
email: string;
|
||||
department: string;
|
||||
position: string;
|
||||
status: 'active' | 'inactive';
|
||||
total_calls: number;
|
||||
total_cost: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface EnterpriseBilling {
|
||||
id: string;
|
||||
enterprise_id: string;
|
||||
enterprise_name: string;
|
||||
period: string;
|
||||
total_calls: number;
|
||||
total_duration: number;
|
||||
total_amount: number;
|
||||
currency: string;
|
||||
status: 'pending' | 'paid' | 'overdue';
|
||||
due_date: string;
|
||||
paid_date?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface EnterpriseFilters {
|
||||
search: string;
|
||||
tab: 'contracts' | 'employees' | 'billing';
|
||||
status: 'all' | 'active' | 'inactive' | 'expired' | 'pending' | 'paid' | 'overdue';
|
||||
enterprise: 'all' | string;
|
||||
sortBy: 'created_at' | 'name' | 'amount' | 'total_calls';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function EnterprisePage() {
|
||||
const [contracts, setContracts] = useState<EnterpriseContract[]>([]);
|
||||
const [employees, setEmployees] = useState<EnterpriseEmployee[]>([]);
|
||||
const [billing, setBilling] = useState<EnterpriseBilling[]>([]);
|
||||
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<EnterpriseFilters>({
|
||||
search: '',
|
||||
tab: 'contracts',
|
||||
status: 'all',
|
||||
enterprise: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取企业数据
|
||||
const fetchEnterpriseData = 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.enterprise();
|
||||
|
||||
switch (filters.tab) {
|
||||
case 'contracts':
|
||||
setContracts(result.contracts);
|
||||
setTotalCount(result.contracts.length);
|
||||
break;
|
||||
case 'employees':
|
||||
setEmployees(result.employees);
|
||||
setTotalCount(result.employees.length);
|
||||
break;
|
||||
case 'billing':
|
||||
setBilling(result.billing);
|
||||
setTotalCount(result.billing.length);
|
||||
break;
|
||||
}
|
||||
|
||||
setTotalPages(Math.ceil(totalCount / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据 - 这里需要根据实际数据库结构调整
|
||||
// 暂时使用演示数据
|
||||
const result = await getDemoData.enterprise();
|
||||
|
||||
switch (filters.tab) {
|
||||
case 'contracts':
|
||||
setContracts(result.contracts);
|
||||
setTotalCount(result.contracts.length);
|
||||
break;
|
||||
case 'employees':
|
||||
setEmployees(result.employees);
|
||||
setTotalCount(result.employees.length);
|
||||
break;
|
||||
case 'billing':
|
||||
setBilling(result.billing);
|
||||
setTotalCount(result.billing.length);
|
||||
break;
|
||||
}
|
||||
|
||||
setTotalPages(Math.ceil(totalCount / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching enterprise data:', error);
|
||||
toast.error('获取企业数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof EnterpriseFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchEnterpriseData(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
tab: filters.tab,
|
||||
status: 'all',
|
||||
enterprise: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchEnterpriseData(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'inactive':
|
||||
case 'expired':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'terminated':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'overdue':
|
||||
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 'inactive':
|
||||
return '非活跃';
|
||||
case 'expired':
|
||||
return '已过期';
|
||||
case 'terminated':
|
||||
return '已终止';
|
||||
case 'pending':
|
||||
return '待付款';
|
||||
case 'paid':
|
||||
return '已付款';
|
||||
case 'overdue':
|
||||
return '逾期';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 删除员工
|
||||
const handleDeleteEmployee = async (employeeId: string) => {
|
||||
if (confirm('确定要删除此员工吗?')) {
|
||||
try {
|
||||
// 这里应该调用删除API
|
||||
toast.success('员工删除成功');
|
||||
fetchEnterpriseData();
|
||||
} catch (error) {
|
||||
toast.error('删除员工失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 员工结算
|
||||
const handleEmployeeSettlement = async (employeeId: string) => {
|
||||
try {
|
||||
// 这里应该调用结算API
|
||||
toast.success('员工结算完成');
|
||||
fetchEnterpriseData();
|
||||
} catch (error) {
|
||||
toast.error('员工结算失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 获取唯一的企业列表(用于筛选下拉框)
|
||||
const getUniqueEnterprises = () => {
|
||||
const enterprises = new Set();
|
||||
employees.forEach(emp => enterprises.add(emp.enterprise_name));
|
||||
contracts.forEach(contract => enterprises.add(contract.enterprise_name));
|
||||
return Array.from(enterprises) as string[];
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchEnterpriseData();
|
||||
}, [filters.tab]);
|
||||
|
||||
const renderContracts = () => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">企业合同管理</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">管理企业合同信息和服务费率配置</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索企业名称或合同号..."
|
||||
className="pl-10 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
<option value="active">生效中</option>
|
||||
<option value="expired">已过期</option>
|
||||
<option value="terminated">已终止</option>
|
||||
</select>
|
||||
|
||||
<div></div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="flex-1 bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 合同列表 */}
|
||||
<div className="space-y-6">
|
||||
{contracts.map((contract) => (
|
||||
<div key={contract.id} className="bg-white border border-gray-200 rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-medium text-gray-900">{contract.enterprise_name}</h4>
|
||||
<p className="text-sm text-gray-500">合同号: {contract.contract_number}</p>
|
||||
</div>
|
||||
<span className={`inline-flex px-3 py-1 text-sm font-semibold rounded-full ${getStatusColor(contract.status)}`}>
|
||||
{getStatusText(contract.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">合同类型</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{contract.contract_type === 'annual' ? '年度合同' :
|
||||
contract.contract_type === 'monthly' ? '月度合同' : '项目合同'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">合同期限</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
{formatTime(contract.start_date)} - {formatTime(contract.end_date)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-gray-500">合同金额</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900">
|
||||
¥{contract.total_amount.toLocaleString()}
|
||||
</dd>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 服务费率配置 */}
|
||||
<div className="mt-4">
|
||||
<h5 className="text-sm font-medium text-gray-900 mb-3">服务费率配置</h5>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
<div className="bg-blue-50 p-3 rounded-lg">
|
||||
<div className="text-xs text-blue-600 font-medium">AI语音翻译</div>
|
||||
<div className="text-sm text-blue-900 font-semibold">¥{contract.service_rates.ai_voice}/分钟</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg">
|
||||
<div className="text-xs text-green-600 font-medium">AI视频翻译</div>
|
||||
<div className="text-sm text-green-900 font-semibold">¥{contract.service_rates.ai_video}/分钟</div>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-3 rounded-lg">
|
||||
<div className="text-xs text-purple-600 font-medium">手语翻译</div>
|
||||
<div className="text-sm text-purple-900 font-semibold">¥{contract.service_rates.sign_language}/分钟</div>
|
||||
</div>
|
||||
<div className="bg-orange-50 p-3 rounded-lg">
|
||||
<div className="text-xs text-orange-600 font-medium">真人翻译</div>
|
||||
<div className="text-sm text-orange-900 font-semibold">¥{contract.service_rates.human_interpreter}/分钟</div>
|
||||
</div>
|
||||
<div className="bg-indigo-50 p-3 rounded-lg">
|
||||
<div className="text-xs text-indigo-600 font-medium">文档翻译</div>
|
||||
<div className="text-sm text-indigo-900 font-semibold">¥{contract.service_rates.document_translation}/字</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<button className="text-indigo-600 hover:text-indigo-900 text-sm font-medium">
|
||||
<EyeIcon className="h-4 w-4 inline mr-1" />
|
||||
查看详情
|
||||
</button>
|
||||
<button className="text-green-600 hover:text-green-900 text-sm font-medium">
|
||||
<PencilIcon className="h-4 w-4 inline mr-1" />
|
||||
编辑费率
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
显示第 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalCount)} 条,共 {totalCount} 条
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => fetchEnterpriseData(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm font-medium text-gray-700">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fetchEnterpriseData(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEmployees = () => (
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="sm:flex sm:items-center sm:justify-between mb-6">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">企业员工管理</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">管理企业员工信息和通话记录</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索员工姓名或邮箱..."
|
||||
className="pl-10 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.enterprise}
|
||||
onChange={(e) => handleFilterChange('enterprise', e.target.value)}
|
||||
>
|
||||
<option value="all">所有企业</option>
|
||||
{getUniqueEnterprises().map(enterprise => (
|
||||
<option key={enterprise} value={enterprise}>{enterprise}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
<option value="active">活跃</option>
|
||||
<option value="inactive">停用</option>
|
||||
</select>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="flex-1 bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 员工列表 */}
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
员工信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
所属企业
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
部门/职位
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
通话统计
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{employees.map((employee) => (
|
||||
<tr key={employee.id} className="hover:bg-gray-50">
|
||||
<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-indigo-100 flex items-center justify-center">
|
||||
<span className="text-sm font-medium text-indigo-800">
|
||||
{employee.name.charAt(0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{employee.name}</div>
|
||||
<div className="text-sm text-gray-500">{employee.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{employee.enterprise_name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{employee.department}</div>
|
||||
<div className="text-sm text-gray-500">{employee.position}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{employee.total_calls} 次通话</div>
|
||||
<div className="text-sm text-gray-500">¥{employee.total_cost.toFixed(2)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(employee.status)}`}>
|
||||
{getStatusText(employee.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-indigo-600 hover:text-indigo-900">
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="text-green-600 hover:text-green-900" onClick={() => handleEmployeeSettlement(employee.id)}>
|
||||
<CurrencyDollarIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button className="text-red-600 hover:text-red-900" onClick={() => handleDeleteEmployee(employee.id)}>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderBilling = () => (
|
||||
<div className="space-y-4">
|
||||
{billing.map((bill) => (
|
||||
<div key={bill.id} className="p-4 border border-gray-200 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{bill.enterprise_name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
账单期间: {bill.period}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
通话次数: {bill.total_calls} | 总时长: {Math.floor(bill.total_duration / 60)}分钟
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900 mt-1">
|
||||
金额: ¥{bill.total_amount.toFixed(2)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
到期日期: {formatTime(bill.due_date)}
|
||||
{bill.paid_date && ` | 付款日期: ${formatTime(bill.paid_date)}`}
|
||||
</p>
|
||||
</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 ${getStatusColor(bill.status)}`}>
|
||||
{getStatusText(bill.status)}
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<button className="text-blue-600 hover:text-blue-900">
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
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>
|
||||
<button 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">
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
{filters.tab === 'contracts' ? '新增合同' : filters.tab === 'employees' ? '添加员工' : '生成账单'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 标签页 */}
|
||||
<div className="mb-6">
|
||||
<nav className="flex space-x-8">
|
||||
<button
|
||||
onClick={() => handleFilterChange('tab', 'contracts')}
|
||||
className={`${
|
||||
filters.tab === 'contracts'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
<BuildingOfficeIcon className="h-5 w-5 inline mr-2" />
|
||||
企业合同
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFilterChange('tab', 'employees')}
|
||||
className={`${
|
||||
filters.tab === 'employees'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
<UsersIcon className="h-5 w-5 inline mr-2" />
|
||||
企业员工
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFilterChange('tab', 'billing')}
|
||||
className={`${
|
||||
filters.tab === 'billing'
|
||||
? 'border-blue-500 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
} whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm`}
|
||||
>
|
||||
<CurrencyDollarIcon className="h-5 w-5 inline mr-2" />
|
||||
结算记录
|
||||
</button>
|
||||
</nav>
|
||||
</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>
|
||||
{filters.tab === 'contracts' && (
|
||||
<>
|
||||
<option value="active">活跃</option>
|
||||
<option value="expired">已过期</option>
|
||||
<option value="terminated">已终止</option>
|
||||
</>
|
||||
)}
|
||||
{filters.tab === 'employees' && (
|
||||
<>
|
||||
<option value="active">活跃</option>
|
||||
<option value="inactive">非活跃</option>
|
||||
</>
|
||||
)}
|
||||
{filters.tab === 'billing' && (
|
||||
<>
|
||||
<option value="pending">待付款</option>
|
||||
<option value="paid">已付款</option>
|
||||
<option value="overdue">逾期</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>
|
||||
{filters.tab !== 'contracts' && (
|
||||
<>
|
||||
<option value="amount-desc">金额 (高到低)</option>
|
||||
<option value="amount-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>
|
||||
) : (
|
||||
<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">
|
||||
{filters.tab === 'contracts' ? '企业合同' : filters.tab === 'employees' ? '企业员工' : '结算记录'} ({totalCount} 条记录)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{filters.tab === 'contracts' && renderContracts()}
|
||||
{filters.tab === 'employees' && renderEmployees()}
|
||||
{filters.tab === 'billing' && renderBilling()}
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchEnterpriseData(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={() => fetchEnterpriseData(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={() => fetchEnterpriseData(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={() => fetchEnterpriseData(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={() => fetchEnterpriseData(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
ReceiptPercentIcon,
|
||||
CloudArrowDownIcon,
|
||||
EyeIcon,
|
||||
PrinterIcon,
|
||||
DocumentTextIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationCircleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
DocumentArrowDownIcon,
|
||||
CalendarIcon,
|
||||
BuildingOfficeIcon,
|
||||
CurrencyDollarIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { formatTime, formatCurrency } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoice_number: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
order_id: string;
|
||||
invoice_type: 'individual' | 'enterprise';
|
||||
// 个人发票信息
|
||||
personal_name?: string;
|
||||
// 企业发票信息
|
||||
company_name?: string;
|
||||
tax_number?: string;
|
||||
company_address?: string;
|
||||
company_phone?: string;
|
||||
bank_name?: string;
|
||||
bank_account?: string;
|
||||
// 发票金额信息
|
||||
subtotal: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
currency: string;
|
||||
// 发票状态
|
||||
status: 'draft' | 'issued' | 'paid' | 'cancelled';
|
||||
issue_date?: string;
|
||||
due_date?: string;
|
||||
paid_date?: string;
|
||||
// 服务明细
|
||||
items: InvoiceItem[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface InvoiceItem {
|
||||
service_type: string;
|
||||
service_name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unit_price: number;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface InvoiceFilters {
|
||||
search: string;
|
||||
status: 'all' | 'draft' | 'issued' | 'paid' | 'cancelled';
|
||||
invoice_type: 'all' | 'individual' | 'enterprise';
|
||||
date_range: 'all' | 'today' | 'week' | 'month' | 'quarter';
|
||||
sortBy: 'created_at' | 'total_amount' | 'issue_date';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function InvoicesPage() {
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([]);
|
||||
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<InvoiceFilters>({
|
||||
search: '',
|
||||
status: 'all',
|
||||
invoice_type: 'all',
|
||||
date_range: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取发票数据
|
||||
const fetchInvoices = 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.invoices();
|
||||
setInvoices(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据 - 这里需要根据实际数据库结构调整
|
||||
// 暂时使用演示数据
|
||||
const result = await getDemoData.invoices();
|
||||
setInvoices(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching invoices:', error);
|
||||
toast.error('获取发票列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof InvoiceFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchInvoices(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
status: 'all',
|
||||
invoice_type: 'all',
|
||||
date_range: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchInvoices(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'issued':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'draft':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return '已付款';
|
||||
case 'issued':
|
||||
return '已开具';
|
||||
case 'draft':
|
||||
return '草稿';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-600" />;
|
||||
case 'issued':
|
||||
return <ClockIcon className="h-4 w-4 text-blue-600" />;
|
||||
case 'draft':
|
||||
return <DocumentTextIcon className="h-4 w-4 text-yellow-600" />;
|
||||
case 'cancelled':
|
||||
return <ExclamationCircleIcon className="h-4 w-4 text-red-600" />;
|
||||
default:
|
||||
return <ClockIcon className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 下载发票
|
||||
const handleDownloadInvoice = async (invoiceId: string) => {
|
||||
try {
|
||||
// 这里应该调用实际的发票下载API
|
||||
toast.success('发票下载已开始');
|
||||
|
||||
// 模拟下载过程
|
||||
const invoice = invoices.find(inv => inv.id === invoiceId);
|
||||
if (invoice) {
|
||||
// 创建一个虚拟的下载链接
|
||||
const element = document.createElement('a');
|
||||
const file = new Blob([`发票号: ${invoice.invoice_number}\n金额: ${formatCurrency(invoice.total_amount)}`],
|
||||
{ type: 'text/plain' });
|
||||
element.href = URL.createObjectURL(file);
|
||||
element.download = `invoice-${invoice.invoice_number}.txt`;
|
||||
document.body.appendChild(element);
|
||||
element.click();
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error downloading invoice:', error);
|
||||
toast.error('发票下载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 打印发票
|
||||
const handlePrintInvoice = (invoiceId: string) => {
|
||||
try {
|
||||
// 这里应该打开打印预览
|
||||
toast.success('正在准备打印...');
|
||||
window.print();
|
||||
} catch (error) {
|
||||
console.error('Error printing invoice:', error);
|
||||
toast.error('发票打印失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 生成发票
|
||||
const handleGenerateInvoice = async (orderId: string) => {
|
||||
try {
|
||||
// 这里应该调用生成发票API
|
||||
toast.success('发票生成成功');
|
||||
fetchInvoices();
|
||||
} catch (error) {
|
||||
toast.error('生成发票失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInvoices();
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<button
|
||||
onClick={() => handleGenerateInvoice('new')}
|
||||
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"
|
||||
>
|
||||
<ReceiptPercentIcon className="h-4 w-4 mr-2" />
|
||||
生成发票
|
||||
</button>
|
||||
</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-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="draft">草稿</option>
|
||||
<option value="issued">已开具</option>
|
||||
<option value="paid">已付款</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.invoice_type}
|
||||
onChange={(e) => handleFilterChange('invoice_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="individual">个人发票</option>
|
||||
<option value="enterprise">企业发票</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 日期范围筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.date_range}
|
||||
onChange={(e) => handleFilterChange('date_range', 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="today">今天</option>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
<option value="quarter">本季度</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>
|
||||
) : (
|
||||
<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">
|
||||
{invoices.map((invoice) => (
|
||||
<div key={invoice.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<ReceiptPercentIcon className="h-8 w-8 text-gray-400" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
发票号: {invoice.invoice_number}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{invoice.invoice_type === 'enterprise' ? (
|
||||
<span className="inline-flex items-center">
|
||||
<BuildingOfficeIcon className="h-4 w-4 mr-1" />
|
||||
{invoice.company_name}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center">
|
||||
<CalendarIcon className="h-4 w-4 mr-1" />
|
||||
{invoice.personal_name || invoice.user_name}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
金额: ¥{invoice.total_amount.toFixed(2)} | 税额: ¥{invoice.tax_amount.toFixed(2)} |
|
||||
总计: ¥{invoice.total_amount.toFixed(2)}
|
||||
</p>
|
||||
{invoice.tax_number && (
|
||||
<p className="text-xs text-gray-400">
|
||||
税号: {invoice.tax_number}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 状态 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(invoice.status)}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(invoice.status)}`}>
|
||||
{getStatusText(invoice.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
title="查看详情"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDownloadInvoice(invoice.id)}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
title="下载发票"
|
||||
>
|
||||
<DocumentArrowDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePrintInvoice(invoice.id)}
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
title="打印发票"
|
||||
>
|
||||
<PrinterIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日期信息 */}
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
<div>创建: {formatTime(invoice.created_at)}</div>
|
||||
{invoice.issue_date && (
|
||||
<div>开具: {formatTime(invoice.issue_date)}</div>
|
||||
)}
|
||||
{invoice.paid_date && (
|
||||
<div>付款: {formatTime(invoice.paid_date)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{invoices.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ReceiptPercentIcon 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>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchInvoices(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={() => fetchInvoices(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={() => fetchInvoices(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={() => fetchInvoices(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={() => fetchInvoices(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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
EyeIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PhoneIcon,
|
||||
VideoCameraIcon,
|
||||
DocumentTextIcon,
|
||||
HandRaisedIcon,
|
||||
SpeakerWaveIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { formatTime, formatCurrency } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
order_number: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
service_type: 'ai_voice_translation' | 'ai_video_translation' | 'sign_language_translation' | 'human_interpretation' | 'document_translation';
|
||||
service_name: string;
|
||||
source_language: string;
|
||||
target_language: string;
|
||||
duration?: number; // 分钟
|
||||
pages?: number; // 页数
|
||||
status: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed';
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
cost: number;
|
||||
currency: string;
|
||||
// 时间信息
|
||||
scheduled_at?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// 额外信息
|
||||
notes?: string;
|
||||
interpreter_id?: string;
|
||||
interpreter_name?: string;
|
||||
}
|
||||
|
||||
interface OrderFilters {
|
||||
search: string;
|
||||
status: 'all' | 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed';
|
||||
service_type: 'all' | 'ai_voice_translation' | 'ai_video_translation' | 'sign_language_translation' | 'human_interpretation' | 'document_translation';
|
||||
priority: 'all' | 'low' | 'normal' | 'high' | 'urgent';
|
||||
date_range: 'all' | 'today' | 'week' | 'month';
|
||||
sortBy: 'created_at' | 'cost' | 'scheduled_at';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
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<OrderFilters>({
|
||||
search: '',
|
||||
status: 'all',
|
||||
service_type: 'all',
|
||||
priority: 'all',
|
||||
date_range: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取订单数据
|
||||
const fetchOrders = 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.orders();
|
||||
setOrders(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据 - 这里需要根据实际数据库结构调整
|
||||
// 暂时使用演示数据
|
||||
const result = await getDemoData.orders();
|
||||
setOrders(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error);
|
||||
toast.error('获取订单数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders(1);
|
||||
}, []);
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof OrderFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchOrders(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
status: 'all',
|
||||
service_type: 'all',
|
||||
priority: 'all',
|
||||
date_range: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchOrders(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'processing':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'cancelled':
|
||||
return 'bg-gray-100 text-gray-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 'pending':
|
||||
return '待处理';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取优先级颜色
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'normal':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'low':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取优先级文本
|
||||
const getPriorityText = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return '紧急';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'normal':
|
||||
return '普通';
|
||||
case 'low':
|
||||
return '低';
|
||||
default:
|
||||
return priority;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取服务类型图标
|
||||
const getServiceIcon = (serviceType: string) => {
|
||||
switch (serviceType) {
|
||||
case 'ai_voice_translation':
|
||||
return <SpeakerWaveIcon className="h-4 w-4" />;
|
||||
case 'ai_video_translation':
|
||||
return <VideoCameraIcon className="h-4 w-4" />;
|
||||
case 'sign_language_translation':
|
||||
return <HandRaisedIcon className="h-4 w-4" />;
|
||||
case 'human_interpretation':
|
||||
return <PhoneIcon className="h-4 w-4" />;
|
||||
case 'document_translation':
|
||||
return <DocumentTextIcon className="h-4 w-4" />;
|
||||
default:
|
||||
return <DocumentTextIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>订单管理 - 口译服务管理系统</title>
|
||||
</Head>
|
||||
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">订单管理</h1>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
管理所有口译服务订单
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="mt-8 bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索订单号或用户..."
|
||||
className="pl-10 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.service_type}
|
||||
onChange={(e) => handleFilterChange('service_type', e.target.value)}
|
||||
>
|
||||
<option value="all">所有服务</option>
|
||||
<option value="ai_voice_translation">AI语音翻译</option>
|
||||
<option value="ai_video_translation">AI视频翻译</option>
|
||||
<option value="sign_language_translation">手语翻译</option>
|
||||
<option value="human_interpretation">人工口译</option>
|
||||
<option value="document_translation">文档翻译</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.priority}
|
||||
onChange={(e) => handleFilterChange('priority', e.target.value)}
|
||||
>
|
||||
<option value="all">所有优先级</option>
|
||||
<option value="urgent">紧急</option>
|
||||
<option value="high">高</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.date_range}
|
||||
onChange={(e) => handleFilterChange('date_range', e.target.value)}
|
||||
>
|
||||
<option value="all">所有时间</option>
|
||||
<option value="today">今天</option>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
</select>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="flex-1 bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 订单列表 */}
|
||||
<div className="mt-8 bg-white shadow rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">加载中...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
订单信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
客户信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
服务详情
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
优先级
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
费用
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{order.order_number}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
{getServiceIcon(order.service_type)}
|
||||
<span className="ml-1">{order.service_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{order.user_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{order.user_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">
|
||||
{order.source_language} → {order.target_language}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{order.duration && `时长: ${order.duration}分钟`}
|
||||
{order.pages && `页数: ${order.pages}页`}
|
||||
</div>
|
||||
{order.interpreter_name && (
|
||||
<div className="text-xs text-gray-400">
|
||||
译员: {order.interpreter_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(order.status)}`}>
|
||||
{getStatusText(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getPriorityColor(order.priority)}`}>
|
||||
{getPriorityText(order.priority)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(order.cost)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div>
|
||||
<div>创建: {formatTime(order.created_at)}</div>
|
||||
{order.scheduled_at && (
|
||||
<div>预约: {formatTime(order.scheduled_at)}</div>
|
||||
)}
|
||||
{order.completed_at && (
|
||||
<div>完成: {formatTime(order.completed_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
title="查看详情"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
显示第 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalCount)} 条,共 {totalCount} 条
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => fetchOrders(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm font-medium text-gray-700">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fetchOrders(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
CogIcon,
|
||||
ShieldCheckIcon,
|
||||
CurrencyDollarIcon,
|
||||
GlobeAltIcon,
|
||||
BellIcon,
|
||||
PhoneIcon,
|
||||
CloudIcon,
|
||||
KeyIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface Settings {
|
||||
// 基础设置
|
||||
siteName: string;
|
||||
siteDescription: string;
|
||||
adminEmail: string;
|
||||
supportEmail: string;
|
||||
|
||||
// 通话设置
|
||||
maxCallDuration: number; // 分钟
|
||||
callTimeout: number; // 秒
|
||||
enableRecording: boolean;
|
||||
enableVideoCall: boolean;
|
||||
|
||||
// 费用设置 - 修改为每个服务单独配置
|
||||
serviceRates: {
|
||||
ai_voice: number; // AI语音翻译费率(元/分钟)
|
||||
ai_video: number; // AI视频翻译费率(元/分钟)
|
||||
sign_language: number; // 手语翻译费率(元/分钟)
|
||||
human_interpreter: number; // 真人翻译费率(元/分钟)
|
||||
document_translation: number; // 文档翻译费率(元/字)
|
||||
};
|
||||
currency: string;
|
||||
taxRate: number;
|
||||
|
||||
// 通知设置
|
||||
emailNotifications: boolean;
|
||||
smsNotifications: boolean;
|
||||
pushNotifications: boolean;
|
||||
|
||||
// 安全设置
|
||||
enableTwoFactor: boolean;
|
||||
sessionTimeout: number; // 分钟
|
||||
maxLoginAttempts: number;
|
||||
|
||||
// API设置
|
||||
twilioAccountSid: string;
|
||||
twilioAuthToken: string;
|
||||
openaiApiKey: string;
|
||||
|
||||
// 语言设置
|
||||
defaultLanguage: string;
|
||||
supportedLanguages: string[];
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<Settings>({
|
||||
siteName: '口译服务管理系统',
|
||||
siteDescription: '专业的多语言口译服务平台',
|
||||
adminEmail: 'admin@interpreter.com',
|
||||
supportEmail: 'support@interpreter.com',
|
||||
maxCallDuration: 120,
|
||||
callTimeout: 30,
|
||||
enableRecording: true,
|
||||
enableVideoCall: true,
|
||||
serviceRates: {
|
||||
ai_voice: 2.0,
|
||||
ai_video: 3.0,
|
||||
sign_language: 5.0,
|
||||
human_interpreter: 8.0,
|
||||
document_translation: 0.1,
|
||||
},
|
||||
currency: 'CNY',
|
||||
taxRate: 0.06,
|
||||
emailNotifications: true,
|
||||
smsNotifications: false,
|
||||
pushNotifications: true,
|
||||
enableTwoFactor: false,
|
||||
sessionTimeout: 30,
|
||||
maxLoginAttempts: 5,
|
||||
twilioAccountSid: '',
|
||||
twilioAuthToken: '',
|
||||
openaiApiKey: '',
|
||||
defaultLanguage: 'zh-CN',
|
||||
supportedLanguages: ['zh-CN', 'en-US', 'ja-JP', 'ko-KR', 'fr-FR', 'de-DE', 'es-ES']
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 这里应该调用API保存设置
|
||||
// await api.updateSettings(settings);
|
||||
|
||||
// 模拟API调用
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toast.success('设置保存成功');
|
||||
} catch (error) {
|
||||
console.error('Error saving settings:', error);
|
||||
toast.error('保存设置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置设置
|
||||
const resetSettings = () => {
|
||||
setSettings({
|
||||
siteName: '口译服务管理系统',
|
||||
siteDescription: '专业的多语言口译服务平台',
|
||||
adminEmail: 'admin@interpreter.com',
|
||||
supportEmail: 'support@interpreter.com',
|
||||
maxCallDuration: 120,
|
||||
callTimeout: 30,
|
||||
enableRecording: true,
|
||||
enableVideoCall: true,
|
||||
serviceRates: {
|
||||
ai_voice: 2.0,
|
||||
ai_video: 3.0,
|
||||
sign_language: 5.0,
|
||||
human_interpreter: 8.0,
|
||||
document_translation: 0.1,
|
||||
},
|
||||
currency: 'CNY',
|
||||
taxRate: 0.06,
|
||||
emailNotifications: true,
|
||||
smsNotifications: false,
|
||||
pushNotifications: true,
|
||||
enableTwoFactor: false,
|
||||
sessionTimeout: 30,
|
||||
maxLoginAttempts: 5,
|
||||
twilioAccountSid: '',
|
||||
twilioAuthToken: '',
|
||||
openaiApiKey: '',
|
||||
defaultLanguage: 'zh-CN',
|
||||
supportedLanguages: ['zh-CN', 'en-US', 'ja-JP', 'ko-KR', 'fr-FR', 'de-DE', 'es-ES']
|
||||
});
|
||||
toast.success('设置已重置为默认值');
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: 'basic', name: '基础设置', icon: CogIcon },
|
||||
{ id: 'call', name: '通话设置', icon: PhoneIcon },
|
||||
{ id: 'billing', name: '费用设置', icon: CurrencyDollarIcon },
|
||||
{ id: 'notifications', name: '通知设置', icon: BellIcon },
|
||||
{ id: 'security', name: '安全设置', icon: ShieldCheckIcon },
|
||||
{ id: 'api', name: 'API设置', icon: KeyIcon },
|
||||
{ id: 'language', name: '语言设置', icon: GlobeAltIcon }
|
||||
];
|
||||
|
||||
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 className="flex space-x-3">
|
||||
<button
|
||||
onClick={resetSettings}
|
||||
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>
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
disabled={loading}
|
||||
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 disabled:opacity-50"
|
||||
>
|
||||
{loading ? '保存中...' : '保存设置'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="flex">
|
||||
{/* 侧边栏标签 */}
|
||||
<div className="w-64 border-r border-gray-200">
|
||||
<nav className="space-y-1 p-4">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`w-full flex items-center px-3 py-2 text-sm font-medium rounded-md ${
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-50 text-blue-700 border-blue-500'
|
||||
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-5 w-5 mr-3" />
|
||||
{tab.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* 主内容区域 */}
|
||||
<div className="flex-1 p-6">
|
||||
{/* 基础设置 */}
|
||||
{activeTab === 'basic' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">基础设置</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">站点名称</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.siteName}
|
||||
onChange={(e) => setSettings({...settings, siteName: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">管理员邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.adminEmail}
|
||||
onChange={(e) => setSettings({...settings, adminEmail: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-700">站点描述</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={settings.siteDescription}
|
||||
onChange={(e) => setSettings({...settings, siteDescription: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">客服邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={settings.supportEmail}
|
||||
onChange={(e) => setSettings({...settings, supportEmail: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通话设置 */}
|
||||
{activeTab === 'call' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">通话设置</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">最大通话时长 (分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxCallDuration}
|
||||
onChange={(e) => setSettings({...settings, maxCallDuration: parseInt(e.target.value)})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">呼叫超时时间 (秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.callTimeout}
|
||||
onChange={(e) => setSettings({...settings, callTimeout: parseInt(e.target.value)})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableRecording}
|
||||
onChange={(e) => setSettings({...settings, enableRecording: e.target.checked})}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">启用通话录音</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">启用视频通话</label>
|
||||
<div className="mt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableVideoCall}
|
||||
onChange={(e) => setSettings({...settings, enableVideoCall: e.target.checked})}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 费用设置 */}
|
||||
{activeTab === 'billing' && (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">服务费率设置</h3>
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
AI语音翻译费率
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pr-12 sm:text-sm border-gray-300 rounded-md"
|
||||
value={settings.serviceRates.ai_voice}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serviceRates: {
|
||||
...settings.serviceRates,
|
||||
ai_voice: parseFloat(e.target.value) || 0
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm">元/分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
AI视频翻译费率
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pr-12 sm:text-sm border-gray-300 rounded-md"
|
||||
value={settings.serviceRates.ai_video}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serviceRates: {
|
||||
...settings.serviceRates,
|
||||
ai_video: parseFloat(e.target.value) || 0
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm">元/分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
手语翻译费率
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pr-12 sm:text-sm border-gray-300 rounded-md"
|
||||
value={settings.serviceRates.sign_language}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serviceRates: {
|
||||
...settings.serviceRates,
|
||||
sign_language: parseFloat(e.target.value) || 0
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm">元/分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
真人翻译费率
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pr-12 sm:text-sm border-gray-300 rounded-md"
|
||||
value={settings.serviceRates.human_interpreter}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serviceRates: {
|
||||
...settings.serviceRates,
|
||||
human_interpreter: parseFloat(e.target.value) || 0
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm">元/分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
文档翻译费率
|
||||
</label>
|
||||
<div className="mt-1 relative rounded-md shadow-sm">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
className="focus:ring-indigo-500 focus:border-indigo-500 block w-full pr-12 sm:text-sm border-gray-300 rounded-md"
|
||||
value={settings.serviceRates.document_translation}
|
||||
onChange={(e) => setSettings({
|
||||
...settings,
|
||||
serviceRates: {
|
||||
...settings.serviceRates,
|
||||
document_translation: parseFloat(e.target.value) || 0
|
||||
}
|
||||
})}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||
<span className="text-gray-500 sm:text-sm">元/字</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
货币单位
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm rounded-md"
|
||||
value={settings.currency}
|
||||
onChange={(e) => setSettings({...settings, currency: e.target.value})}
|
||||
>
|
||||
<option value="CNY">人民币 (CNY)</option>
|
||||
<option value="USD">美元 (USD)</option>
|
||||
<option value="EUR">欧元 (EUR)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
税率 (%)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
max="1"
|
||||
className="mt-1 focus:ring-indigo-500 focus:border-indigo-500 block w-full shadow-sm sm:text-sm border-gray-300 rounded-md"
|
||||
value={settings.taxRate}
|
||||
onChange={(e) => setSettings({...settings, taxRate: parseFloat(e.target.value) || 0})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 通知设置 */}
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">通知设置</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.emailNotifications}
|
||||
onChange={(e) => setSettings({...settings, emailNotifications: e.target.checked})}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">邮件通知</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.smsNotifications}
|
||||
onChange={(e) => setSettings({...settings, smsNotifications: e.target.checked})}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">短信通知</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.pushNotifications}
|
||||
onChange={(e) => setSettings({...settings, pushNotifications: e.target.checked})}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">推送通知</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 安全设置 */}
|
||||
{activeTab === 'security' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">安全设置</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.enableTwoFactor}
|
||||
onChange={(e) => setSettings({...settings, enableTwoFactor: e.target.checked})}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">启用双因素认证</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">会话超时时间 (分钟)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.sessionTimeout}
|
||||
onChange={(e) => setSettings({...settings, sessionTimeout: parseInt(e.target.value)})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">最大登录尝试次数</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.maxLoginAttempts}
|
||||
onChange={(e) => setSettings({...settings, maxLoginAttempts: parseInt(e.target.value)})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API设置 */}
|
||||
{activeTab === 'api' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">API设置</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Twilio Account SID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.twilioAccountSid}
|
||||
onChange={(e) => setSettings({...settings, twilioAccountSid: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="ACxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">Twilio Auth Token</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.twilioAuthToken}
|
||||
onChange={(e) => setSettings({...settings, twilioAuthToken: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="••••••••••••••••••••••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">OpenAI API Key</label>
|
||||
<input
|
||||
type="password"
|
||||
value={settings.openaiApiKey}
|
||||
onChange={(e) => setSettings({...settings, openaiApiKey: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="sk-••••••••••••••••••••••••••••••••••••••••••••••••"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 语言设置 */}
|
||||
{activeTab === 'language' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-medium text-gray-900">语言设置</h3>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700">默认语言</label>
|
||||
<select
|
||||
value={settings.defaultLanguage}
|
||||
onChange={(e) => setSettings({...settings, defaultLanguage: e.target.value})}
|
||||
className="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="zh-CN">中文 (简体)</option>
|
||||
<option value="en-US">English (US)</option>
|
||||
<option value="ja-JP">日本語</option>
|
||||
<option value="ko-KR">한국어</option>
|
||||
<option value="es-ES">Español</option>
|
||||
<option value="fr-FR">Français</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">支持的语言</label>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ code: 'zh-CN', name: '中文 (简体)' },
|
||||
{ code: 'en-US', name: 'English (US)' },
|
||||
{ code: 'ja-JP', name: '日本語' },
|
||||
{ code: 'ko-KR', name: '한국어' },
|
||||
{ code: 'es-ES', name: 'Español' },
|
||||
{ code: 'fr-FR', name: 'Français' }
|
||||
].map((lang) => (
|
||||
<div key={lang.code} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={settings.supportedLanguages.includes(lang.code)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSettings({
|
||||
...settings,
|
||||
supportedLanguages: [...settings.supportedLanguages, lang.code]
|
||||
});
|
||||
} else {
|
||||
setSettings({
|
||||
...settings,
|
||||
supportedLanguages: settings.supportedLanguages.filter(l => l !== lang.code)
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-900">{lang.name}</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
EyeIcon,
|
||||
UserIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} 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';
|
||||
|
||||
// 添加用户状态文本函数
|
||||
const getUserStatusText = (isActive: boolean): string => {
|
||||
return isActive ? '活跃' : '非活跃';
|
||||
};
|
||||
|
||||
interface UserFilters {
|
||||
search: string;
|
||||
userType: 'all' | 'individual' | 'enterprise';
|
||||
status: 'all' | 'active' | 'inactive';
|
||||
sortBy: 'created_at' | 'full_name' | 'last_login';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
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'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取用户列表
|
||||
const fetchUsers = 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.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}%`);
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
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);
|
||||
}
|
||||
|
||||
} 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);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof UserFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchUsers(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
userType: 'all',
|
||||
status: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchUsers(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
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="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">
|
||||
调整筛选条件或检查数据源
|
||||
</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">
|
||||
{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>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchUsers(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={() => fetchUsers(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={() => fetchUsers(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={() => 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>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => fetchUsers(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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user