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

This commit is contained in:
mars 2025-06-29 16:13:50 +08:00
parent 114bf81fcb
commit 0b8be9377a
26 changed files with 15153 additions and 0 deletions

44
.env.example Normal file
View File

@ -0,0 +1,44 @@
# Supabase 配置
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
# Twilio 配置
TWILIO_ACCOUNT_SID=your_twilio_account_sid
TWILIO_AUTH_TOKEN=your_twilio_auth_token
TWILIO_API_KEY=your_twilio_api_key
TWILIO_API_SECRET=your_twilio_api_secret
# Stripe 配置
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
STRIPE_SECRET_KEY=your_stripe_secret_key
STRIPE_WEBHOOK_SECRET=your_stripe_webhook_secret
# ElevenLabs 配置
ELEVENLABS_API_KEY=your_elevenlabs_api_key
# JWT 密钥
JWT_SECRET=your_jwt_secret
# 应用配置
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your_nextauth_secret
# 数据库配置
DATABASE_URL=your_database_url
# 邮件配置 (可选,用于通知)
SMTP_HOST=your_smtp_host
SMTP_PORT=587
SMTP_USER=your_smtp_user
SMTP_PASS=your_smtp_password
# WebSocket 配置
NEXT_PUBLIC_WS_URL=ws://localhost:3001
# 文件上传配置
MAX_FILE_SIZE=10485760 # 10MB
# 支付配置
PAYMENT_SUCCESS_URL=http://localhost:3000/payment/success
PAYMENT_CANCEL_URL=http://localhost:3000/payment/cancel

258
components/Layout.tsx Normal file
View File

@ -0,0 +1,258 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import {
HomeIcon,
UsersIcon,
PhoneIcon,
CalendarIcon,
DocumentTextIcon,
CurrencyDollarIcon,
ChartBarIcon,
CogIcon,
BellIcon,
UserGroupIcon,
ClipboardDocumentListIcon,
Bars3Icon,
XMarkIcon,
BuildingOfficeIcon,
FolderIcon,
DocumentIcon,
ReceiptPercentIcon,
ChevronDownIcon,
ChevronRightIcon
} from '@heroicons/react/24/outline';
interface LayoutProps {
children: React.ReactNode;
user?: any;
}
const navigation = [
{ name: '仪表盘', href: '/dashboard', icon: HomeIcon },
{ name: '用户管理', href: '/dashboard/users', icon: UsersIcon },
{ name: '翻译员管理', href: '/dashboard/interpreters', icon: UserGroupIcon },
{
name: '订单管理',
icon: DocumentTextIcon,
children: [
{ name: '订单列表', href: '/dashboard/orders' },
{ name: '发票管理', href: '/dashboard/invoices' }
]
},
{ name: '通话记录', href: '/dashboard/calls', icon: PhoneIcon },
{ name: '企业服务', href: '/dashboard/enterprise', icon: BuildingOfficeIcon },
{ name: '文档管理', href: '/dashboard/documents', icon: FolderIcon },
{ name: '系统设置', href: '/dashboard/settings', icon: CogIcon },
];
export default function Layout({ children, user }: LayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [isDemoMode, setIsDemoMode] = useState(false);
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const router = useRouter();
useEffect(() => {
const checkDemoMode = () => {
const isDemo = !process.env.NEXT_PUBLIC_SUPABASE_URL ||
process.env.NEXT_PUBLIC_SUPABASE_URL === 'https://demo.supabase.co' ||
process.env.NEXT_PUBLIC_SUPABASE_URL === '';
setIsDemoMode(isDemo);
};
checkDemoMode();
}, []);
const handleLogout = () => {
if (isDemoMode) {
router.push('/auth/login');
} else {
// 实际的登出逻辑
router.push('/auth/login');
}
};
const toggleExpanded = (itemName: string) => {
setExpandedItems(prev =>
prev.includes(itemName)
? prev.filter(name => name !== itemName)
: [...prev, itemName]
);
};
const isItemActive = (item: any) => {
if (item.href) {
return router.pathname === item.href;
}
if (item.children) {
return item.children.some((child: any) => router.pathname === child.href);
}
return false;
};
const renderNavItem = (item: any) => {
const hasChildren = item.children && item.children.length > 0;
const isActive = isItemActive(item);
const isExpanded = expandedItems.includes(item.name);
if (hasChildren) {
return (
<div key={item.name}>
<button
onClick={() => toggleExpanded(item.name)}
className={`${
isActive
? 'bg-blue-100 text-blue-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center w-full px-2 py-2 text-sm font-medium rounded-md`}
>
<item.icon
className={`${
isActive ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
} mr-3 flex-shrink-0 h-6 w-6`}
/>
{item.name}
{isExpanded ? (
<ChevronDownIcon className="ml-auto h-4 w-4" />
) : (
<ChevronRightIcon className="ml-auto h-4 w-4" />
)}
</button>
{isExpanded && (
<div className="ml-8 mt-1 space-y-1">
{item.children.map((child: any) => (
<Link
key={child.name}
href={child.href}
className={`${
router.pathname === child.href
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-500'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
>
{child.name}
</Link>
))}
</div>
)}
</div>
);
}
return (
<Link
key={item.name}
href={item.href}
className={`${
isActive
? 'bg-blue-100 text-blue-900'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'
} group flex items-center px-2 py-2 text-sm font-medium rounded-md`}
>
<item.icon
className={`${
isActive ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
} mr-3 flex-shrink-0 h-6 w-6`}
/>
{item.name}
</Link>
);
};
return (
<div className="h-screen flex overflow-hidden bg-gray-100">
{/* 移动端侧边栏 */}
<div className={`fixed inset-0 flex z-40 md:hidden ${sidebarOpen ? '' : 'hidden'}`}>
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
<div className="relative flex-1 flex flex-col max-w-xs w-full pt-5 pb-4 bg-white">
<div className="absolute top-0 right-0 -mr-12 pt-2">
<button
type="button"
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
onClick={() => setSidebarOpen(false)}
>
<XMarkIcon className="h-6 w-6 text-white" />
</button>
</div>
<div className="flex-shrink-0 flex items-center px-4">
<h1 className="text-xl font-bold text-gray-900"></h1>
</div>
<div className="mt-5 flex-1 h-0 overflow-y-auto">
<nav className="px-2 space-y-1">
{navigation.map(renderNavItem)}
</nav>
</div>
</div>
</div>
{/* 桌面端侧边栏 */}
<div className="hidden md:flex md:flex-shrink-0">
<div className="flex flex-col w-64">
<div className="flex flex-col h-0 flex-1">
<div className="flex items-center h-16 flex-shrink-0 px-4 bg-white border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900"></h1>
{isDemoMode && (
<span className="ml-2 px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
</span>
)}
</div>
<div className="flex-1 flex flex-col overflow-y-auto bg-white border-r border-gray-200">
<nav className="flex-1 px-2 py-4 space-y-1">
{navigation.map(renderNavItem)}
</nav>
<div className="flex-shrink-0 p-4 border-t border-gray-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className="h-8 w-8 rounded-full bg-blue-500 flex items-center justify-center">
<span className="text-sm font-medium text-white"></span>
</div>
</div>
<div className="ml-3">
<p className="text-sm font-medium text-gray-700"></p>
<p className="text-xs text-gray-500">admin@demo.com</p>
</div>
</div>
<button
onClick={handleLogout}
className="mt-3 w-full bg-gray-100 text-gray-700 hover:bg-gray-200 px-3 py-2 rounded-md text-sm font-medium"
>
退
</button>
</div>
</div>
</div>
</div>
</div>
{/* 主内容区域 */}
<div className="flex flex-col w-0 flex-1 overflow-hidden">
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white shadow md:hidden">
<button
type="button"
className="px-4 border-r border-gray-200 text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500 md:hidden"
onClick={() => setSidebarOpen(true)}
>
<Bars3Icon className="h-6 w-6" />
</button>
<div className="flex-1 px-4 flex justify-between items-center">
<h1 className="text-lg font-semibold text-gray-900"></h1>
{isDemoMode && (
<span className="px-2 py-1 text-xs bg-green-100 text-green-800 rounded-full">
</span>
)}
</div>
</div>
<main className="flex-1 relative overflow-y-auto focus:outline-none">
<div className="py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
{children}
</div>
</div>
</main>
</div>
</div>
);
}

742
lib/demo-data.ts Normal file
View File

@ -0,0 +1,742 @@
// 演示模式的模拟数据
export const demoUsers = [
{
id: '1',
email: 'john.doe@example.com',
full_name: '张三',
avatar_url: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150',
user_type: 'individual' as const,
phone: '+86 138 0000 0001',
created_at: '2024-01-15T08:00:00Z',
updated_at: '2024-01-20T10:30:00Z',
is_active: true,
last_login: '2024-01-20T10:30:00Z',
},
{
id: '2',
email: 'jane.smith@company.com',
full_name: '李四',
avatar_url: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150',
user_type: 'enterprise' as const,
phone: '+86 138 0000 0002',
created_at: '2024-01-10T09:15:00Z',
updated_at: '2024-01-19T14:20:00Z',
is_active: true,
last_login: '2024-01-19T14:20:00Z',
},
{
id: '3',
email: 'mike.wilson@example.com',
full_name: '王五',
avatar_url: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150',
user_type: 'individual' as const,
phone: '+86 138 0000 0003',
created_at: '2024-01-12T11:45:00Z',
updated_at: '2024-01-18T16:10:00Z',
is_active: false,
last_login: '2024-01-18T16:10:00Z',
},
{
id: '4',
email: 'sarah.johnson@enterprise.com',
full_name: '赵六',
avatar_url: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150',
user_type: 'enterprise' as const,
phone: '+86 138 0000 0004',
created_at: '2024-01-08T13:20:00Z',
updated_at: '2024-01-17T09:45:00Z',
is_active: true,
last_login: '2024-01-17T09:45:00Z',
},
];
export const demoCalls = [
{
id: '1',
user_id: '1',
interpreter_id: '101',
from_language: 'zh',
to_language: 'en',
status: 'active' as const,
start_time: new Date(Date.now() - 15 * 60 * 1000).toISOString(), // 15分钟前开始
created_at: new Date(Date.now() - 16 * 60 * 1000).toISOString(),
cost: 45.50,
duration: 15 * 60, // 15分钟
},
{
id: '2',
user_id: '2',
interpreter_id: '102',
from_language: 'en',
to_language: 'zh',
status: 'active' as const,
start_time: new Date(Date.now() - 8 * 60 * 1000).toISOString(), // 8分钟前开始
created_at: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
cost: 32.00,
duration: 8 * 60, // 8分钟
},
{
id: '3',
user_id: '1',
interpreter_id: '103',
from_language: 'zh',
to_language: 'ja',
status: 'ended' as const,
start_time: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2小时前
end_time: new Date(Date.now() - 90 * 60 * 1000).toISOString(), // 1.5小时前结束
created_at: new Date(Date.now() - 125 * 60 * 1000).toISOString(),
cost: 89.75,
duration: 30 * 60, // 30分钟
},
];
export const demoInterpreters = [
{
id: '101',
name: '翻译员A',
avatar_url: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=150',
languages: ['zh', 'en'],
rating: 4.9,
status: 'busy' as 'online' | 'offline' | 'busy',
specialties: ['商务', '法律'],
},
{
id: '102',
name: '翻译员B',
avatar_url: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=150',
languages: ['en', 'zh', 'ja'],
rating: 4.8,
status: 'busy' as 'online' | 'offline' | 'busy',
specialties: ['技术', '医疗'],
},
{
id: '103',
name: '翻译员C',
avatar_url: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=150',
languages: ['zh', 'ja', 'ko'],
rating: 4.7,
status: 'online' as 'online' | 'offline' | 'busy',
specialties: ['旅游', '教育'],
},
{
id: '104',
name: '翻译员D',
avatar_url: 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?w=150',
languages: ['en', 'fr', 'es'],
rating: 4.9,
status: 'online' as 'online' | 'offline' | 'busy',
specialties: ['商务', '艺术'],
},
];
export const demoStats = {
todayCalls: 12,
activeCalls: 2,
onlineInterpreters: 8,
todayRevenue: 1250.75,
avgResponseTime: 45, // 秒
};
// 订单演示数据
const demoOrders = [
{
id: 'order-1',
order_number: 'ORD-2024-001',
user_id: 'user-1',
user_name: '张三',
user_email: 'zhangsan@email.com',
service_type: 'ai_voice_translation' as const,
service_name: 'AI语音翻译',
source_language: '中文',
target_language: '英文',
duration: 30,
status: 'completed' as const,
priority: 'normal' as const,
cost: 180.00,
currency: 'CNY',
scheduled_at: '2024-01-15T14:00:00Z',
started_at: '2024-01-15T14:00:00Z',
completed_at: '2024-01-15T14:30:00Z',
created_at: '2024-01-15T10:00:00Z',
updated_at: '2024-01-15T14:30:00Z',
notes: '客户会议翻译'
},
{
id: 'order-2',
order_number: 'ORD-2024-002',
user_id: 'user-2',
user_name: '李四',
user_email: 'lisi@alibaba.com',
service_type: 'human_interpretation' as const,
service_name: '人工口译',
source_language: '中文',
target_language: '英文',
duration: 240,
status: 'processing' as const,
priority: 'high' as const,
cost: 2400.00,
currency: 'CNY',
scheduled_at: '2024-01-20T09:00:00Z',
started_at: '2024-01-20T09:00:00Z',
created_at: '2024-01-18T16:00:00Z',
updated_at: '2024-01-20T10:30:00Z',
interpreter_id: 'interpreter-1',
interpreter_name: '王译员',
notes: '重要商务谈判'
},
{
id: 'order-3',
order_number: 'ORD-2024-003',
user_id: 'user-3',
user_name: '王五',
user_email: 'wangwu@tencent.com',
service_type: 'document_translation' as const,
service_name: '文档翻译',
source_language: '中文',
target_language: '英文',
pages: 20,
status: 'pending' as const,
priority: 'normal' as const,
cost: 1200.00,
currency: 'CNY',
created_at: '2024-01-25T11:00:00Z',
updated_at: '2024-01-25T11:00:00Z',
notes: '技术文档翻译'
},
{
id: 'order-4',
order_number: 'ORD-2024-004',
user_id: 'user-4',
user_name: '赵六',
user_email: 'zhaoliu@bytedance.com',
service_type: 'ai_video_translation' as const,
service_name: 'AI视频翻译',
source_language: '中文',
target_language: '英文',
duration: 90,
status: 'completed' as const,
priority: 'urgent' as const,
cost: 3600.00,
currency: 'CNY',
scheduled_at: '2024-01-18T13:00:00Z',
started_at: '2024-01-18T13:00:00Z',
completed_at: '2024-01-18T14:30:00Z',
created_at: '2024-01-18T10:00:00Z',
updated_at: '2024-01-18T14:30:00Z',
notes: '产品发布会视频翻译'
},
{
id: 'order-5',
order_number: 'ORD-2024-005',
user_id: 'user-5',
user_name: '孙七',
user_email: 'sunqi@email.com',
service_type: 'sign_language_translation' as const,
service_name: '手语翻译',
source_language: '中文',
target_language: '手语',
duration: 90,
status: 'cancelled' as const,
priority: 'low' as const,
cost: 450.00,
currency: 'CNY',
created_at: '2024-01-22T13:00:00Z',
updated_at: '2024-01-23T09:00:00Z',
interpreter_id: 'interpreter-2',
interpreter_name: '李手语师',
notes: '客户取消服务'
},
{
id: 'order-6',
order_number: 'ORD-2024-006',
user_id: 'user-6',
user_name: '周八',
user_email: 'zhouba@email.com',
service_type: 'ai_voice_translation' as const,
service_name: 'AI语音翻译',
source_language: '英文',
target_language: '中文',
duration: 45,
status: 'failed' as const,
priority: 'normal' as const,
cost: 270.00,
currency: 'CNY',
scheduled_at: '2024-01-28T16:00:00Z',
started_at: '2024-01-28T16:00:00Z',
created_at: '2024-01-28T15:00:00Z',
updated_at: '2024-01-28T16:30:00Z',
notes: '音频质量问题导致翻译失败'
}
];
// 企业合同演示数据
const demoEnterpriseContracts = [
{
id: 'contract-001',
enterprise_id: 'ent-001',
enterprise_name: '阿里巴巴集团',
contract_number: 'ALI-2024-001',
contract_type: 'annual' as const,
start_date: '2024-01-01T00:00:00Z',
end_date: '2024-12-31T23:59:59Z',
total_amount: 500000,
currency: 'CNY',
status: 'active' as const,
service_rates: {
ai_voice: 1.8, // 企业优惠价格
ai_video: 2.5,
sign_language: 4.0,
human_interpreter: 6.5,
document_translation: 0.08,
},
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-01T00:00:00Z'
},
{
id: 'contract-002',
enterprise_id: 'ent-002',
enterprise_name: '腾讯科技',
contract_number: 'TX-2024-002',
contract_type: 'monthly' as const,
start_date: '2024-02-01T00:00:00Z',
end_date: '2024-07-31T23:59:59Z',
total_amount: 120000,
currency: 'CNY',
status: 'active' as const,
service_rates: {
ai_voice: 1.9,
ai_video: 2.7,
sign_language: 4.2,
human_interpreter: 7.0,
document_translation: 0.09,
},
created_at: '2024-02-01T00:00:00Z',
updated_at: '2024-02-01T00:00:00Z'
},
{
id: 'contract-003',
enterprise_id: 'ent-003',
enterprise_name: '字节跳动',
contract_number: 'BD-2024-003',
contract_type: 'annual' as const,
start_date: '2024-03-01T00:00:00Z',
end_date: '2025-02-28T23:59:59Z',
total_amount: 800000,
currency: 'CNY',
status: 'active' as const,
service_rates: {
ai_voice: 1.6, // 大客户更优惠的价格
ai_video: 2.3,
sign_language: 3.8,
human_interpreter: 6.0,
document_translation: 0.07,
},
created_at: '2024-03-01T00:00:00Z',
updated_at: '2024-03-01T00:00:00Z'
}
];
// 企业员工演示数据
const demoEnterpriseEmployees = [
{
id: 'emp-001',
enterprise_id: 'ent-001',
enterprise_name: '阿里巴巴集团',
name: '张三',
email: 'zhangsan@alibaba.com',
department: '技术部',
position: '高级工程师',
status: 'active' as const,
total_calls: 45,
total_cost: 1350.00,
created_at: '2024-01-15T08:00:00Z'
},
{
id: 'emp-002',
enterprise_id: 'ent-001',
enterprise_name: '阿里巴巴集团',
name: '李四',
email: 'lisi@alibaba.com',
department: '产品部',
position: '产品经理',
status: 'active' as const,
total_calls: 32,
total_cost: 960.00,
created_at: '2024-01-20T09:30:00Z'
},
{
id: 'emp-003',
enterprise_id: 'ent-002',
enterprise_name: '腾讯科技',
name: '王五',
email: 'wangwu@tencent.com',
department: '运营部',
position: '运营专员',
status: 'inactive' as const,
total_calls: 18,
total_cost: 540.00,
created_at: '2024-02-01T10:15:00Z'
},
{
id: 'emp-004',
enterprise_id: 'ent-003',
enterprise_name: '字节跳动',
name: '赵六',
email: 'zhaoliu@bytedance.com',
department: '市场部',
position: '市场总监',
status: 'active' as const,
total_calls: 67,
total_cost: 2010.00,
created_at: '2024-02-10T11:45:00Z'
}
];
// 企业结算记录演示数据
const demoEnterpriseBilling = [
{
id: '1',
enterprise_id: 'ent_001',
enterprise_name: '华为技术有限公司',
period: '2024年1月',
total_calls: 128,
total_duration: 7680,
total_amount: 6400.00,
currency: 'CNY',
status: 'paid' as const,
due_date: '2024-02-15',
paid_date: '2024-02-10',
created_at: '2024-02-01T00:00:00Z'
},
{
id: '2',
enterprise_id: 'ent_002',
enterprise_name: '腾讯科技有限公司',
period: '2024年1月',
total_calls: 85,
total_duration: 5100,
total_amount: 4250.00,
currency: 'CNY',
status: 'pending' as const,
due_date: '2024-02-15',
created_at: '2024-02-01T00:00:00Z'
},
{
id: '3',
enterprise_id: 'ent_003',
enterprise_name: '阿里巴巴集团',
period: '2023年12月',
total_calls: 156,
total_duration: 9360,
total_amount: 7800.00,
currency: 'CNY',
status: 'overdue' as const,
due_date: '2024-01-15',
created_at: '2024-01-01T00:00:00Z'
}
];
// 文档演示数据
const demoDocuments = [
{
id: '1',
user_id: 'user_001',
original_name: '商业合同_中英对照.pdf',
file_size: 2048576,
file_type: 'pdf',
source_language: '中文',
target_language: '英文',
status: 'completed' as const,
progress: 100,
translated_url: '/documents/translated/商业合同_中英对照_translated.pdf',
cost: 150.00,
created_at: '2024-01-15T10:30:00Z',
updated_at: '2024-01-15T11:45:00Z',
user_name: '张三'
},
{
id: '2',
user_id: 'user_002',
original_name: '技术文档_API说明.docx',
file_size: 1536000,
file_type: 'docx',
source_language: '英文',
target_language: '中文',
status: 'processing' as const,
progress: 65,
cost: 120.00,
created_at: '2024-01-16T09:15:00Z',
updated_at: '2024-01-16T10:30:00Z',
user_name: '李四'
},
{
id: '3',
user_id: 'user_003',
original_name: '产品说明书_多语言版本.txt',
file_size: 512000,
file_type: 'txt',
source_language: '中文',
target_language: '日文',
status: 'pending' as const,
progress: 0,
cost: 80.00,
created_at: '2024-01-17T14:20:00Z',
updated_at: '2024-01-17T14:20:00Z',
user_name: '王五'
},
{
id: '4',
user_id: 'user_004',
original_name: '财务报告_季度总结.xlsx',
file_size: 3072000,
file_type: 'xlsx',
source_language: '中文',
target_language: '英文',
status: 'failed' as const,
progress: 0,
cost: 200.00,
created_at: '2024-01-18T08:45:00Z',
updated_at: '2024-01-18T09:00:00Z',
user_name: '赵六'
},
{
id: '5',
user_id: 'user_005',
original_name: '营销方案_品牌推广.pptx',
file_size: 4096000,
file_type: 'pptx',
source_language: '中文',
target_language: '韩文',
status: 'completed' as const,
progress: 100,
translated_url: '/documents/translated/营销方案_品牌推广_translated.pptx',
cost: 250.00,
created_at: '2024-01-19T16:30:00Z',
updated_at: '2024-01-19T18:15:00Z',
user_name: '钱七'
}
];
// 发票演示数据
const demoInvoices = [
{
id: 'invoice-1',
invoice_number: 'INV-2024-001',
user_id: 'user-1',
user_name: '张三',
user_email: 'zhangsan@email.com',
order_id: 'order-1',
invoice_type: 'individual' as const,
personal_name: '张三',
subtotal: 180.00,
tax_amount: 32.40,
total_amount: 212.40,
currency: 'CNY',
status: 'paid' as const,
issue_date: '2024-01-15T10:00:00Z',
due_date: '2024-02-15T23:59:59Z',
paid_date: '2024-01-16T14:30:00Z',
items: [
{
service_type: 'ai_voice_translation',
service_name: 'AI语音翻译',
quantity: 30,
unit: '分钟',
unit_price: 6.00,
amount: 180.00
}
],
created_at: '2024-01-15T09:00:00Z',
updated_at: '2024-01-16T14:30:00Z'
},
{
id: 'invoice-2',
invoice_number: 'INV-2024-002',
user_id: 'user-2',
user_name: '李四',
user_email: 'lisi@alibaba.com',
order_id: 'order-2',
invoice_type: 'enterprise' as const,
company_name: '阿里巴巴集团',
tax_number: '91330000MA27XF6Q2X',
company_address: '杭州市余杭区文一西路969号',
company_phone: '0571-85022088',
bank_name: '中国工商银行杭州分行',
bank_account: '1202026209900012345',
subtotal: 2400.00,
tax_amount: 432.00,
total_amount: 2832.00,
currency: 'CNY',
status: 'issued' as const,
issue_date: '2024-01-20T15:00:00Z',
due_date: '2024-02-20T23:59:59Z',
items: [
{
service_type: 'human_interpretation',
service_name: '人工口译',
quantity: 4,
unit: '小时',
unit_price: 600.00,
amount: 2400.00
}
],
created_at: '2024-01-20T14:00:00Z',
updated_at: '2024-01-20T15:00:00Z'
},
{
id: 'invoice-3',
invoice_number: 'INV-2024-003',
user_id: 'user-3',
user_name: '王五',
user_email: 'wangwu@tencent.com',
order_id: 'order-3',
invoice_type: 'enterprise' as const,
company_name: '腾讯科技',
tax_number: '91440300708461136T',
company_address: '深圳市南山区科技园',
company_phone: '0755-86013388',
bank_name: '招商银行深圳分行',
bank_account: '755987654321098765',
subtotal: 1200.00,
tax_amount: 216.00,
total_amount: 1416.00,
currency: 'CNY',
status: 'draft' as const,
items: [
{
service_type: 'document_translation',
service_name: '文档翻译',
quantity: 20,
unit: '页',
unit_price: 60.00,
amount: 1200.00
}
],
created_at: '2024-01-25T11:00:00Z',
updated_at: '2024-01-25T11:00:00Z'
},
{
id: 'invoice-4',
invoice_number: 'INV-2024-004',
user_id: 'user-4',
user_name: '赵六',
user_email: 'zhaoliu@bytedance.com',
order_id: 'order-4',
invoice_type: 'enterprise' as const,
company_name: '字节跳动',
tax_number: '91110108396826581T',
company_address: '北京市海淀区知春路63号',
company_phone: '010-82600000',
bank_name: '中国银行北京分行',
bank_account: '104100123456789012',
subtotal: 3600.00,
tax_amount: 648.00,
total_amount: 4248.00,
currency: 'CNY',
status: 'paid' as const,
issue_date: '2024-01-18T16:00:00Z',
due_date: '2024-02-18T23:59:59Z',
paid_date: '2024-01-19T10:15:00Z',
items: [
{
service_type: 'ai_video_translation',
service_name: 'AI视频翻译',
quantity: 90,
unit: '分钟',
unit_price: 40.00,
amount: 3600.00
}
],
created_at: '2024-01-18T15:00:00Z',
updated_at: '2024-01-19T10:15:00Z'
},
{
id: 'invoice-5',
invoice_number: 'INV-2024-005',
user_id: 'user-5',
user_name: '孙七',
user_email: 'sunqi@email.com',
order_id: 'order-5',
invoice_type: 'individual' as const,
personal_name: '孙七',
subtotal: 450.00,
tax_amount: 81.00,
total_amount: 531.00,
currency: 'CNY',
status: 'cancelled' as const,
items: [
{
service_type: 'sign_language_translation',
service_name: '手语翻译',
quantity: 1.5,
unit: '小时',
unit_price: 300.00,
amount: 450.00
}
],
created_at: '2024-01-22T13:00:00Z',
updated_at: '2024-01-23T09:00:00Z'
}
];
// 演示模式的数据获取函数
export const getDemoData = {
users: (filters?: any) => {
let filteredUsers = [...demoUsers];
if (filters?.search) {
const search = filters.search.toLowerCase();
filteredUsers = filteredUsers.filter(user =>
user.full_name.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search)
);
}
if (filters?.userType && filters.userType !== 'all') {
filteredUsers = filteredUsers.filter(user => user.user_type === filters.userType);
}
if (filters?.status && filters.status !== 'all') {
const isActive = filters.status === 'active';
filteredUsers = filteredUsers.filter(user => user.is_active === isActive);
}
return Promise.resolve({
data: filteredUsers,
total: filteredUsers.length,
page: 1,
limit: 10,
has_more: false,
});
},
calls: () => Promise.resolve(demoCalls),
interpreters: () => Promise.resolve(demoInterpreters),
orders: async () => {
await new Promise(resolve => setTimeout(resolve, 500));
return demoOrders;
},
stats: () => Promise.resolve(demoStats),
// 企业服务数据
enterprise: async () => {
await new Promise(resolve => setTimeout(resolve, 500)); // 模拟网络延迟
return {
contracts: demoEnterpriseContracts,
employees: demoEnterpriseEmployees,
billing: demoEnterpriseBilling
};
},
// 文档数据
documents: async () => {
await new Promise(resolve => setTimeout(resolve, 500)); // 模拟网络延迟
return demoDocuments;
},
// 发票管理
invoices: async () => {
await new Promise(resolve => setTimeout(resolve, 500));
return demoInvoices;
}
};

290
lib/supabase.ts Normal file
View File

@ -0,0 +1,290 @@
import { createClient } from '@supabase/supabase-js';
import { createClientComponentClient } from '@supabase/auth-helpers-nextjs';
// 环境变量检查和默认值
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supabase.co';
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'demo-key';
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'demo-service-key';
// 检查是否在开发环境中使用默认配置
const isDemoMode = supabaseUrl === 'https://demo.supabase.co';
// 客户端使用的 Supabase 客户端
export const supabase = isDemoMode
? createClient(supabaseUrl, supabaseAnonKey, {
realtime: {
params: {
eventsPerSecond: 0,
},
},
auth: {
persistSession: false,
autoRefreshToken: false,
},
})
: createClient(supabaseUrl, supabaseAnonKey);
// 组件中使用的 Supabase 客户端
export const createSupabaseClient = () => {
if (isDemoMode) {
// 在演示模式下返回一个模拟客户端
return {
auth: {
getUser: () => Promise.resolve({ data: { user: null }, error: null }),
signInWithPassword: () => Promise.resolve({ data: null, error: { message: '演示模式:请配置 Supabase 环境变量' } }),
signOut: () => Promise.resolve({ error: null }),
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
}
} as any;
}
return createClientComponentClient();
};
// 服务端使用的 Supabase 客户端(具有管理员权限)
export const supabaseAdmin = isDemoMode
? createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
},
realtime: {
params: {
eventsPerSecond: 0,
},
},
})
: createClient(supabaseUrl, supabaseServiceKey, {
auth: {
autoRefreshToken: false,
},
});
// 数据库表名常量
export const TABLES = {
USERS: 'users',
CALLS: 'calls',
APPOINTMENTS: 'appointments',
INTERPRETERS: 'interpreters',
DOCUMENTS: 'document_translations',
ORDERS: 'orders',
INVOICES: 'invoices',
ENTERPRISE_EMPLOYEES: 'enterprise_employees',
PRICING_RULES: 'pricing_rules',
NOTIFICATIONS: 'notifications',
SYSTEM_SETTINGS: 'system_settings',
ACCOUNT_BALANCES: 'account_balances',
} as const;
// 实时订阅配置
export const REALTIME_CHANNELS = {
CALLS: 'calls:*',
NOTIFICATIONS: 'notifications:*',
APPOINTMENTS: 'appointments:*',
} as const;
// 用户认证相关函数
export const auth = {
// 获取当前用户
getCurrentUser: async () => {
const { data: { user }, error } = await supabase.auth.getUser();
if (error) throw error;
return user;
},
// 登录
signIn: async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) throw error;
return data;
},
// 注册
signUp: async (email: string, password: string, metadata?: any) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: metadata,
},
});
if (error) throw error;
return data;
},
// 登出
signOut: async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
},
// 重置密码
resetPassword: async (email: string) => {
const { data, error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) throw error;
return data;
},
// 更新密码
updatePassword: async (password: string) => {
const { data, error } = await supabase.auth.updateUser({
password,
});
if (error) throw error;
return data;
},
};
// 数据库操作辅助函数
export const db = {
// 通用查询函数
select: async <T>(table: string, query?: any) => {
let queryBuilder = supabase.from(table).select(query || '*');
const { data, error } = await queryBuilder;
if (error) throw error;
return data as T[];
},
// 通用插入函数
insert: async <T>(table: string, data: any) => {
const { data: result, error } = await supabase
.from(table)
.insert(data)
.select()
.single();
if (error) throw error;
return result as T;
},
// 通用更新函数
update: async <T>(table: string, id: string, data: any) => {
const { data: result, error } = await supabase
.from(table)
.update(data)
.eq('id', id)
.select()
.single();
if (error) throw error;
return result as T;
},
// 通用删除函数
delete: async (table: string, id: string) => {
const { error } = await supabase
.from(table)
.delete()
.eq('id', id);
if (error) throw error;
},
// 分页查询函数
paginate: async <T>(
table: string,
page: number = 1,
limit: number = 10,
query?: any,
orderBy?: { column: string; ascending?: boolean }
) => {
const from = (page - 1) * limit;
const to = from + limit - 1;
let queryBuilder = supabase
.from(table)
.select(query || '*', { count: 'exact' })
.range(from, to);
if (orderBy) {
queryBuilder = queryBuilder.order(orderBy.column, {
ascending: orderBy.ascending ?? true,
});
}
const { data, error, count } = await queryBuilder;
if (error) throw error;
return {
data: data as T[],
total: count || 0,
page,
limit,
has_more: (count || 0) > page * limit,
};
},
};
// 文件上传函数
export const storage = {
// 上传文件
upload: async (bucket: string, path: string, file: File) => {
const { data, error } = await supabase.storage
.from(bucket)
.upload(path, file, {
cacheControl: '3600',
upsert: false,
});
if (error) throw error;
return data;
},
// 获取文件公共URL
getPublicUrl: (bucket: string, path: string) => {
const { data } = supabase.storage
.from(bucket)
.getPublicUrl(path);
return data.publicUrl;
},
// 删除文件
remove: async (bucket: string, paths: string[]) => {
const { data, error } = await supabase.storage
.from(bucket)
.remove(paths);
if (error) throw error;
return data;
},
};
// 实时订阅函数
export const realtime = {
// 订阅表变化
subscribe: (
table: string,
callback: (payload: any) => void,
filter?: string
) => {
if (isDemoMode) {
// 演示模式下返回模拟的订阅对象
return {
unsubscribe: () => {},
};
}
const channel = supabase
.channel(`${table}-changes`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table,
filter,
},
callback
)
.subscribe();
return channel;
},
// 取消订阅
unsubscribe: (channel: any) => {
if (isDemoMode) {
return;
}
supabase.removeChannel(channel);
},
};

14
next.config.js Normal file
View File

@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
swcMinify: true,
env: {
SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
},
images: {
domains: ['images.unsplash.com', 'avatars.githubusercontent.com'],
},
}
module.exports = nextConfig

7057
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
package.json Normal file
View File

@ -0,0 +1,52 @@
{
"name": "interpreter-admin-dashboard",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"@hookform/resolvers": "^3.3.2",
"@stripe/stripe-js": "^2.2.2",
"@supabase/auth-helpers-nextjs": "^0.8.7",
"@supabase/supabase-js": "^2.38.5",
"@tailwindcss/forms": "^0.5.10",
"@types/node": "^20.10.5",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@types/react-table": "^7.7.17",
"autoprefixer": "^10.4.16",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"lucide-react": "^0.294.0",
"next": "^14.0.4",
"postcss": "^8.4.32",
"react": "^18.2.0",
"react-calendar": "^4.6.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-hook-form": "^7.48.2",
"react-hot-toast": "^2.4.1",
"react-table": "^7.8.0",
"react-use": "^17.4.2",
"recharts": "^2.8.0",
"socket.io-client": "^4.7.4",
"stripe": "^14.9.0",
"tailwindcss": "^3.3.6",
"twilio": "^4.19.0",
"typescript": "^5.3.3",
"zod": "^3.22.4",
"zustand": "^4.4.7"
},
"devDependencies": {
"@types/react-calendar": "^3.9.0",
"eslint": "^8.56.0",
"eslint-config-next": "^14.0.4"
}
}

99
pages/_app.tsx Normal file
View File

@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { AppProps } from 'next/app';
import { Toaster } from 'react-hot-toast';
import { supabase } from '../lib/supabase';
import { User } from '@supabase/supabase-js';
import '../styles/globals.css';
export default function App({ Component, pageProps }: AppProps) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const router = useRouter();
useEffect(() => {
// 检查是否为演示模式
const isDemoMode = !process.env.NEXT_PUBLIC_SUPABASE_URL ||
process.env.NEXT_PUBLIC_SUPABASE_URL === 'https://demo.supabase.co' ||
process.env.NEXT_PUBLIC_SUPABASE_URL === '';
const checkUser = async () => {
try {
if (isDemoMode) {
// 演示模式下不检查用户认证
setUser(null);
setLoading(false);
return;
}
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
} catch (error) {
console.error('Auth check error:', error);
setUser(null);
} finally {
setLoading(false);
}
};
checkUser();
if (!isDemoMode) {
// 只在非演示模式下监听认证状态变化
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (event: any, session: any) => {
setUser(session?.user ?? null);
if (event === 'SIGNED_OUT' || !session?.user) {
router.push('/auth/login');
} else if (event === 'SIGNED_IN' && session?.user) {
router.push('/dashboard');
}
}
);
return () => {
subscription.unsubscribe();
};
}
}, [router]);
// 显示加载状态
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="loading-spinner"></div>
</div>
);
}
return (
<>
<Component {...pageProps} user={user} />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
success: {
duration: 3000,
iconTheme: {
primary: '#10b981',
secondary: '#fff',
},
},
error: {
duration: 5000,
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
</>
);
}

208
pages/auth/login.tsx Normal file
View File

@ -0,0 +1,208 @@
import { useState } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import Link from 'next/link';
import { toast } from 'react-hot-toast';
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
import { auth } from '@/lib/supabase';
interface LoginForm {
email: string;
password: string;
}
export default function Login() {
const router = useRouter();
const [form, setForm] = useState<LoginForm>({
email: '',
password: '',
});
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setForm(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.email || !form.password) {
toast.error('请填写所有必填字段');
return;
}
setLoading(true);
try {
// 检查是否为演示模式
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
if (isDemoMode) {
// 演示模式:检查测试账号
if (form.email === 'admin@demo.com' && form.password === 'admin123') {
toast.success('登录成功!');
// 在演示模式下直接跳转到仪表盘
router.push('/dashboard');
} else {
toast.error('演示模式:请使用测试账号 admin@demo.com / admin123');
}
} else {
// 真实模式:使用 Supabase 认证
try {
await auth.signIn(form.email, form.password);
toast.success('登录成功!');
router.push('/dashboard');
} catch (authError: any) {
console.error('Supabase auth error:', authError);
toast.error(authError.message || '登录失败,请检查邮箱和密码');
}
}
} catch (error: any) {
console.error('Login error:', error);
toast.error('登录过程中发生错误,请稍后重试');
} finally {
setLoading(false);
}
};
// 填入测试账号
const fillTestAccount = () => {
setForm({
email: 'admin@demo.com',
password: 'admin123'
});
};
return (
<>
<Head>
<title> - </title>
</Head>
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
</p>
</div>
{/* 测试账号提示 */}
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<div className="flex">
<div className="ml-3">
<h3 className="text-sm font-medium text-blue-800">
</h3>
<div className="mt-2 text-sm text-blue-700">
<p>admin@demo.com</p>
<p>admin123</p>
<button
type="button"
onClick={fillTestAccount}
className="mt-2 text-xs text-blue-600 hover:text-blue-500 underline"
>
</button>
</div>
</div>
</div>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="rounded-md shadow-sm -space-y-px">
<div>
<label htmlFor="email" className="sr-only">
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="管理员邮箱"
value={form.email}
onChange={handleInputChange}
/>
</div>
<div className="relative">
<label htmlFor="password" className="sr-only">
</label>
<input
id="password"
name="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
placeholder="管理员密码"
value={form.password}
onChange={handleInputChange}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
) : (
<EyeIcon className="h-5 w-5 text-gray-400" />
)}
</button>
</div>
</div>
<div className="flex items-center justify-between">
<div className="text-sm">
<Link
href="/"
className="font-medium text-blue-600 hover:text-blue-500"
>
</Link>
</div>
<div className="text-sm">
<a
href="#"
className="font-medium text-blue-600 hover:text-blue-500"
>
</a>
</div>
</div>
<div>
<button
type="submit"
disabled={loading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (
<div className="flex items-center">
<div className="loading-spinner-sm mr-2"></div>
...
</div>
) : (
'登录'
)}
</button>
</div>
</form>
</div>
</div>
</>
);
}

556
pages/dashboard/calls.tsx Normal file
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>
);
}

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>
);
}

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
pages/dashboard/index.tsx Normal file
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>
);
}

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>
);
}

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
pages/dashboard/orders.tsx Normal file
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>
);
}

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
pages/dashboard/users.tsx Normal file
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>
);
}

75
pages/index.tsx Normal file
View File

@ -0,0 +1,75 @@
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
export default function Home() {
const router = useRouter();
useEffect(() => {
// 检查是否有用户登录,如果有则重定向到仪表盘
const checkAuth = async () => {
try {
// 这里可以添加用户认证检查
// 暂时跳过自动重定向
} catch (error) {
console.error('Auth check error:', error);
}
};
checkAuth();
}, [router]);
return (
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100">
<div className="container mx-auto px-4 py-16">
<div className="text-center">
<h1 className="text-4xl font-bold text-gray-900 mb-8">
</h1>
<p className="text-xl text-gray-600 mb-12">
</p>
<div className="grid md:grid-cols-2 gap-8 max-w-4xl mx-auto">
<div className="bg-white rounded-lg shadow-lg p-8">
<h2 className="text-2xl font-semibold text-gray-800 mb-4">
</h2>
<p className="text-gray-600 mb-6">
访
</p>
<Link
href="/auth/login"
className="inline-block bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
</Link>
</div>
<div className="bg-white rounded-lg shadow-lg p-8">
<h2 className="text-2xl font-semibold text-gray-800 mb-4">
</h2>
<ul className="text-left text-gray-600 space-y-2">
<li> </li>
<li> </li>
<li> </li>
<li> </li>
<li> </li>
</ul>
</div>
</div>
<div className="mt-12">
<Link
href="/dashboard"
className="inline-block bg-green-600 text-white px-8 py-4 rounded-lg hover:bg-green-700 transition-colors text-lg"
>
</Link>
</div>
</div>
</div>
</div>
);
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

402
styles/components.css Normal file
View File

@ -0,0 +1,402 @@
/* 加载动画 */
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f4f6;
border-top: 4px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 小型加载动画 */
.loading-spinner-sm {
width: 20px;
height: 20px;
border: 2px solid #f3f4f6;
border-top: 2px solid #3b82f6;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 表格加载效果 */
.skeleton-loader {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* 通话状态指示器 */
.call-status {
position: relative;
display: inline-flex;
align-items: center;
}
.call-status.active::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
background-color: #22c55e;
border-radius: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s infinite;
}
.call-status.pending::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
background-color: #f59e0b;
border-radius: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s infinite;
}
.call-status.ended::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
background-color: #ef4444;
border-radius: 50%;
transform: translate(-50%, -50%);
}
@keyframes pulse {
0%, 100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.1);
opacity: 0.8;
}
}
/* 状态徽章 */
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.status-badge.active {
background-color: #d1fae5;
color: #065f46;
}
.status-badge.pending {
background-color: #fef3c7;
color: #92400e;
}
.status-badge.ended {
background-color: #fee2e2;
color: #991b1b;
}
.status-badge.paused {
background-color: #e5e7eb;
color: #374151;
}
/* 卡片悬停效果 */
.card-hover {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
}
/* 按钮样式 */
.btn {
display: inline-flex;
align-items: center;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
line-height: 1.25rem;
cursor: pointer;
transition: all 0.2s ease-in-out;
text-decoration: none;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background-color: #3b82f6;
color: white;
}
.btn-primary:hover:not(:disabled) {
background-color: #2563eb;
}
.btn-secondary {
background-color: #6b7280;
color: white;
}
.btn-secondary:hover:not(:disabled) {
background-color: #4b5563;
}
.btn-success {
background-color: #10b981;
color: white;
}
.btn-success:hover:not(:disabled) {
background-color: #059669;
}
.btn-warning {
background-color: #f59e0b;
color: white;
}
.btn-warning:hover:not(:disabled) {
background-color: #d97706;
}
.btn-danger {
background-color: #ef4444;
color: white;
}
.btn-danger:hover:not(:disabled) {
background-color: #dc2626;
}
.btn-outline {
border: 1px solid #d1d5db;
background-color: transparent;
color: #374151;
}
.btn-outline:hover:not(:disabled) {
background-color: #f9fafb;
border-color: #9ca3af;
}
/* 表单样式 */
.form-input {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
line-height: 1.25rem;
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.form-input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-input.error {
border-color: #ef4444;
}
.form-input.error:focus {
border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
}
.form-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
margin-bottom: 0.25rem;
}
.form-error {
margin-top: 0.25rem;
font-size: 0.75rem;
color: #ef4444;
}
.form-help {
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
/* 模态框样式 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal-content {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
max-width: 90vw;
max-height: 90vh;
overflow: auto;
}
/* 通知样式 */
.notification {
position: fixed;
top: 1rem;
right: 1rem;
max-width: 320px;
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
border-left: 4px solid #3b82f6;
padding: 1rem;
z-index: 40;
animation: slideIn 0.3s ease-out;
}
.notification.success {
border-left-color: #10b981;
}
.notification.warning {
border-left-color: #f59e0b;
}
.notification.error {
border-left-color: #ef4444;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 进度条 */
.progress-bar {
width: 100%;
height: 0.5rem;
background-color: #e5e7eb;
border-radius: 9999px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #3b82f6;
transition: width 0.3s ease-in-out;
}
/* 数据表格样式 */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th,
.data-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.data-table th {
background-color: #f9fafb;
font-weight: 500;
color: #374151;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.data-table tr:hover {
background-color: #f9fafb;
}
.data-table .sortable {
cursor: pointer;
user-select: none;
}
.data-table .sortable:hover {
background-color: #f3f4f6;
}
/* 响应式设计 */
@media (max-width: 640px) {
.card-hover {
transform: none;
}
.card-hover:hover {
transform: none;
box-shadow: none;
}
.btn {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
}
.modal-content {
margin: 1rem;
max-width: calc(100vw - 2rem);
}
}
/* 打印样式 */
@media print {
.btn,
.loading-spinner,
.modal-overlay {
display: none !important;
}
.card-hover {
box-shadow: none !important;
}
}

435
styles/globals.css Normal file
View File

@ -0,0 +1,435 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 导入组件样式 */
@import './components.css';
/* 全局样式重置 */
html,
body {
padding: 0;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
line-height: 1.6;
font-size: 14px;
}
* {
box-sizing: border-box;
}
/* 改进滚动条样式 */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 选择文本颜色 */
::selection {
background-color: #3b82f6;
color: white;
}
::-moz-selection {
background-color: #3b82f6;
color: white;
}
/* 聚焦时的轮廓样式 */
:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
/* 隐藏聚焦轮廓当不需要时 */
:focus:not(:focus-visible) {
outline: none;
}
/* 改进表单元素的默认样式 */
input, textarea, select {
font-family: inherit;
font-size: inherit;
}
/* 按钮重置 */
button {
font-family: inherit;
font-size: inherit;
border: none;
background: none;
cursor: pointer;
}
/* 链接样式 */
a {
color: inherit;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* 图片响应式 */
img {
max-width: 100%;
height: auto;
}
/* 表格样式改进 */
table {
border-collapse: collapse;
width: 100%;
}
/* 代码块样式 */
pre, code {
background-color: #f6f8fa;
border-radius: 0.25rem;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
pre {
padding: 1rem;
overflow-x: auto;
}
code {
padding: 0.125rem 0.25rem;
font-size: 0.875em;
}
/* 无障碍改进 */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* 打印样式 */
@media print {
* {
background: transparent !important;
color: black !important;
box-shadow: none !important;
text-shadow: none !important;
}
a,
a:visited {
text-decoration: underline;
}
a[href]:after {
content: " (" attr(href) ")";
}
abbr[title]:after {
content: " (" attr(title) ")";
}
.no-print {
display: none !important;
}
@page {
margin: 2cm;
}
p,
h2,
h3 {
orphans: 3;
widows: 3;
}
h2,
h3 {
page-break-after: avoid;
}
}
/* 暗色主题支持 */
@media (prefers-color-scheme: dark) {
:root {
--bg-primary: #1f2937;
--bg-secondary: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--border-color: #4b5563;
}
}
/* 动画偏好设置 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 高对比度模式支持 */
@media (prefers-contrast: high) {
:root {
--border-color: #000000;
}
.btn {
border: 2px solid currentColor;
}
}
/* 实用工具类 */
.text-balance {
text-wrap: balance;
}
.text-pretty {
text-wrap: pretty;
}
.full-bleed {
width: 100vw;
margin-left: calc(50% - 50vw);
}
.container-query {
container-type: inline-size;
}
/* 现代CSS重置补充 */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
-moz-text-size-adjust: none;
-webkit-text-size-adjust: none;
text-size-adjust: none;
}
body,
h1,
h2,
h3,
h4,
p,
figure,
blockquote,
dl,
dd {
margin-block-end: 0;
}
ul[role='list'],
ol[role='list'] {
list-style: none;
}
h1,
h2,
h3,
h4,
button,
input,
label {
line-height: 1.1;
}
h1,
h2,
h3,
h4 {
text-wrap: balance;
}
p {
text-wrap: pretty;
max-width: 70ch;
}
input,
button,
textarea,
select {
font: inherit;
}
textarea:not([rows]) {
min-height: 10em;
}
:target {
scroll-margin-block: 5ex;
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* 通话状态指示器 */
.call-status {
position: relative;
}
.call-status::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 50%;
animation: call-pulse 1.5s infinite;
}
.call-status.active::before {
background-color: #10b981;
}
.call-status.busy::before {
background-color: #f59e0b;
}
.call-status.ended::before {
background-color: #ef4444;
}
@keyframes call-pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.7;
}
100% {
transform: scale(1);
opacity: 1;
}
}
/* 加载动画 */
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 渐变背景 */
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gradient-bg-alt {
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}

57
tailwind.config.js Normal file
View File

@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("@tailwindcss/forms")],
}

34
tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./*"],
"@/components/*": ["components/*"],
"@/lib/*": ["lib/*"],
"@/hooks/*": ["hooks/*"],
"@/types/*": ["types/*"],
"@/utils/*": ["utils/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

246
types/index.ts Normal file
View File

@ -0,0 +1,246 @@
// 用户相关类型
export interface User {
id: string;
email: string;
full_name?: string;
avatar_url?: string;
user_type: 'individual' | 'enterprise';
phone?: string;
created_at: string;
updated_at: string;
is_active: boolean;
last_login?: string;
}
// 企业用户扩展信息
export interface EnterpriseUser extends User {
company_name: string;
company_id: string;
employee_count?: number;
contract_type: 'monthly' | 'yearly' | 'custom';
billing_address?: string;
tax_id?: string;
}
// 账户余额
export interface AccountBalance {
user_id: string;
balance: number;
currency: 'CNY' | 'USD';
last_updated: string;
frozen_amount?: number;
}
// 通话相关类型
export interface Call {
id: string;
caller_id: string;
callee_id?: string;
call_type: 'audio' | 'video';
call_mode: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpreter';
status: 'pending' | 'active' | 'ended' | 'cancelled' | 'failed';
start_time: string;
end_time?: string;
duration?: number; // 秒
cost: number;
currency: 'CNY' | 'USD';
room_sid?: string;
twilio_call_sid?: string;
quality_rating?: number;
created_at: string;
updated_at: string;
}
// 通话统计
export interface CallStats {
total_calls_today: number;
active_calls: number;
average_response_time: number; // 秒
online_interpreters: number;
total_revenue_today: number;
currency: 'CNY' | 'USD';
}
// 翻译员信息
export interface Interpreter {
id: string;
user_id: string;
name: string;
avatar_url?: string;
languages: string[];
specializations: string[];
hourly_rate: number;
currency: 'CNY' | 'USD';
rating: number;
total_calls: number;
status: 'online' | 'offline' | 'busy';
is_certified: boolean;
created_at: string;
updated_at: string;
}
// 预约信息
export interface Appointment {
id: string;
user_id: string;
interpreter_id?: string;
call_type: 'audio' | 'video';
call_mode: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpreter';
scheduled_time: string;
duration_minutes: number;
estimated_cost: number;
currency: 'CNY' | 'USD';
status: 'scheduled' | 'confirmed' | 'started' | 'completed' | 'cancelled';
notes?: string;
reminder_sent: boolean;
created_at: string;
updated_at: string;
}
// 文档翻译
export interface DocumentTranslation {
id: string;
user_id: string;
original_filename: string;
file_url: string;
file_size: number;
file_type: string;
source_language: string;
target_language: string;
status: 'uploaded' | 'processing' | 'completed' | 'failed';
translated_file_url?: string;
cost: number;
currency: 'CNY' | 'USD';
progress_percentage: number;
estimated_completion?: string;
created_at: string;
updated_at: string;
}
// 订单信息
export interface Order {
id: string;
user_id: string;
order_type: 'call' | 'document' | 'subscription' | 'recharge';
related_id?: string; // 关联的通话ID、文档ID等
amount: number;
currency: 'CNY' | 'USD';
status: 'pending' | 'completed' | 'cancelled' | 'refunded';
payment_method: 'stripe' | 'alipay' | 'wechat' | 'enterprise_billing';
payment_intent_id?: string;
invoice_url?: string;
created_at: string;
updated_at: string;
}
// 发票信息
export interface Invoice {
id: string;
user_id: string;
order_id: string;
invoice_number: string;
amount: number;
currency: 'CNY' | 'USD';
tax_amount?: number;
status: 'draft' | 'sent' | 'paid' | 'overdue' | 'cancelled';
issued_date: string;
due_date: string;
paid_date?: string;
download_url?: string;
created_at: string;
updated_at: string;
}
// 企业员工管理
export interface EnterpriseEmployee {
id: string;
enterprise_id: string;
user_id: string;
employee_id: string;
name: string;
email: string;
department?: string;
position?: string;
call_limit_per_month?: number;
is_active: boolean;
created_at: string;
updated_at: string;
}
// 费率规则
export interface PricingRule {
id: string;
name: string;
service_type: 'audio_call' | 'video_call' | 'document_translation' | 'ai_service';
call_mode?: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpreter';
base_rate: number;
currency: 'CNY' | 'USD';
billing_unit: 'minute' | 'word' | 'page' | 'session';
minimum_charge: number;
user_type: 'individual' | 'enterprise' | 'all';
is_active: boolean;
created_at: string;
updated_at: string;
}
// 通知类型
export interface Notification {
id: string;
user_id: string;
title: string;
message: string;
type: 'info' | 'warning' | 'error' | 'success';
category: 'system' | 'billing' | 'call' | 'appointment' | 'document';
is_read: boolean;
action_url?: string;
created_at: string;
}
// 系统设置
export interface SystemSettings {
id: string;
key: string;
value: string;
description?: string;
category: 'general' | 'billing' | 'twilio' | 'stripe' | 'elevenlabs';
is_public: boolean;
updated_at: string;
}
// API 响应包装类型
export interface ApiResponse<T = any> {
success: boolean;
data?: T;
error?: string;
message?: string;
}
// 分页响应类型
export interface PaginatedResponse<T = any> {
data: T[];
total: number;
page: number;
limit: number;
has_more: boolean;
}
// 表格列配置
export interface TableColumn<T = any> {
key: keyof T;
title: string;
render?: (value: any, record: T) => any;
sortable?: boolean;
width?: string;
align?: 'left' | 'center' | 'right';
}
// 表单字段类型
export interface FormField {
name: string;
label: string;
type: 'text' | 'email' | 'password' | 'number' | 'select' | 'textarea' | 'file' | 'date' | 'time';
required?: boolean;
placeholder?: string;
options?: Array<{ label: string; value: string | number }>;
validation?: any;
}

260
utils/index.ts Normal file
View File

@ -0,0 +1,260 @@
import { type ClassValue, clsx } from 'clsx';
// 合并 CSS 类名
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
// 格式化货币
export function formatCurrency(amount: number, currency: 'CNY' | 'USD' = 'CNY') {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: currency,
}).format(amount);
}
// 格式化时间
export function formatTime(date: string | Date) {
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(date));
}
// 格式化相对时间
export function formatRelativeTime(date: string | Date) {
const now = new Date();
const target = new Date(date);
const diffInSeconds = Math.floor((now.getTime() - target.getTime()) / 1000);
if (diffInSeconds < 60) {
return `${diffInSeconds}秒前`;
} else if (diffInSeconds < 3600) {
return `${Math.floor(diffInSeconds / 60)}分钟前`;
} else if (diffInSeconds < 86400) {
return `${Math.floor(diffInSeconds / 3600)}小时前`;
} else if (diffInSeconds < 2592000) {
return `${Math.floor(diffInSeconds / 86400)}天前`;
} else {
return formatTime(date);
}
}
// 格式化通话时长
export function formatDuration(seconds: number) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = seconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
} else {
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
}
// 格式化文件大小
export function formatFileSize(bytes: number) {
const sizes = ['B', 'KB', 'MB', 'GB'];
if (bytes === 0) return '0 B';
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
// 生成随机ID
export function generateId() {
return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
}
// 防抖函数
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return (...args: Parameters<T>) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
// 节流函数
export function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return (...args: Parameters<T>) => {
if (!inThrottle) {
func(...args);
inThrottle = true;
setTimeout(() => (inThrottle = false), limit);
}
};
}
// 深拷贝
export function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (obj instanceof Date) return new Date(obj.getTime()) as any;
if (obj instanceof Array) return obj.map(item => deepClone(item)) as any;
if (typeof obj === 'object') {
const clonedObj = {} as any;
for (const key in obj) {
if (obj.hasOwnProperty(key)) {
clonedObj[key] = deepClone(obj[key]);
}
}
return clonedObj;
}
return obj;
}
// 验证邮箱
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
// 验证手机号
export function isValidPhone(phone: string): boolean {
const phoneRegex = /^1[3-9]\d{9}$/;
return phoneRegex.test(phone);
}
// 获取通话状态的中文描述
export function getCallStatusText(status: string): string {
const statusMap: Record<string, string> = {
pending: '等待中',
active: '通话中',
ended: '已结束',
cancelled: '已取消',
failed: '失败',
};
return statusMap[status] || status;
}
// 获取通话模式的中文描述
export function getCallModeText(mode: string): string {
const modeMap: Record<string, string> = {
ai_voice: 'AI语音',
ai_video: 'AI视频',
sign_language: '手语翻译',
human_interpreter: '真人翻译',
};
return modeMap[mode] || mode;
}
// 获取用户类型的中文描述
export function getUserTypeText(type: string): string {
const typeMap: Record<string, string> = {
individual: '个人用户',
enterprise: '企业用户',
};
return typeMap[type] || type;
}
// 获取订单状态的中文描述
export function getOrderStatusText(status: string): string {
const statusMap: Record<string, string> = {
pending: '待处理',
completed: '已完成',
cancelled: '已取消',
refunded: '已退款',
};
return statusMap[status] || status;
}
// 获取文档翻译状态的中文描述
export function getDocumentStatusText(status: string): string {
const statusMap: Record<string, string> = {
uploaded: '已上传',
processing: '处理中',
completed: '已完成',
failed: '失败',
};
return statusMap[status] || status;
}
// 计算通话费用(按分钟向上取整)
export function calculateCallCost(durationInSeconds: number, ratePerMinute: number): number {
const minutes = Math.ceil(durationInSeconds / 60);
return minutes * ratePerMinute;
}
// 检查余额是否足够
export function checkBalanceSufficient(balance: number, requiredAmount: number): boolean {
return balance >= requiredAmount;
}
// 获取颜色类名基于状态
export function getStatusColor(status: string): string {
const colorMap: Record<string, string> = {
active: 'text-green-600 bg-green-100',
pending: 'text-yellow-600 bg-yellow-100',
ended: 'text-gray-600 bg-gray-100',
cancelled: 'text-red-600 bg-red-100',
failed: 'text-red-600 bg-red-100',
completed: 'text-green-600 bg-green-100',
processing: 'text-blue-600 bg-blue-100',
uploaded: 'text-purple-600 bg-purple-100',
};
return colorMap[status] || 'text-gray-600 bg-gray-100';
}
// 安全地解析JSON
export function safeJsonParse<T>(str: string, fallback: T): T {
try {
return JSON.parse(str);
} catch (error) {
return fallback;
}
}
// 生成头像URL
export function generateAvatarUrl(name: string): string {
const firstChar = name.charAt(0).toUpperCase();
return `https://ui-avatars.com/api/?name=${encodeURIComponent(firstChar)}&background=random&color=fff&size=40`;
}
// 导出所有状态文本映射
export const STATUS_TEXTS = {
CALL_STATUS: {
pending: '等待中',
active: '通话中',
ended: '已结束',
cancelled: '已取消',
failed: '失败',
},
CALL_MODE: {
ai_voice: 'AI语音',
ai_video: 'AI视频',
sign_language: '手语翻译',
human_interpreter: '真人翻译',
},
USER_TYPE: {
individual: '个人用户',
enterprise: '企业用户',
},
ORDER_STATUS: {
pending: '待处理',
completed: '已完成',
cancelled: '已取消',
refunded: '已退款',
},
DOCUMENT_STATUS: {
uploaded: '已上传',
processing: '处理中',
completed: '已完成',
failed: '失败',
},
NOTIFICATION_TYPE: {
info: '信息',
warning: '警告',
error: '错误',
success: '成功',
},
} as const;