feat: 完成口译服务管理后台核心功能开发

This commit is contained in:
2025-06-29 16:13:50 +08:00
parent 114bf81fcb
commit 0b8be9377a
26 changed files with 15153 additions and 0 deletions
+556
View File
@@ -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>
);
}
+496
View File
@@ -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>
);
}
+858
View File
@@ -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>
);
}
+484
View File
@@ -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>
);
}
+414
View File
@@ -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>
);
}
+542
View File
@@ -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>
);
}
+483
View File
@@ -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>
);
}
+671
View File
@@ -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>
);
}
+370
View File
@@ -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>
);
}