feat: 完成口译服务管理后台核心功能开发
This commit is contained in:
parent
114bf81fcb
commit
0b8be9377a
44
.env.example
Normal file
44
.env.example
Normal 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
258
components/Layout.tsx
Normal 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
742
lib/demo-data.ts
Normal 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
290
lib/supabase.ts
Normal 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
14
next.config.js
Normal 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
7057
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
package.json
Normal file
52
package.json
Normal 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
99
pages/_app.tsx
Normal 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
208
pages/auth/login.tsx
Normal 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
556
pages/dashboard/calls.tsx
Normal file
@ -0,0 +1,556 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
PhoneIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { Call } from '@/types';
|
||||
import { formatTime } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface CallFilters {
|
||||
search: string;
|
||||
status: 'all' | 'pending' | 'active' | 'ended' | 'cancelled' | 'failed';
|
||||
call_type: 'all' | 'audio' | 'video';
|
||||
call_mode: 'all' | 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpreter';
|
||||
sortBy: 'created_at' | 'duration' | 'cost';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function CallsPage() {
|
||||
const [calls, setCalls] = useState<Call[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [filters, setFilters] = useState<CallFilters>({
|
||||
search: '',
|
||||
status: 'all',
|
||||
call_type: 'all',
|
||||
call_mode: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取通话记录列表
|
||||
const fetchCalls = async (page = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
setIsDemoMode(isDemo);
|
||||
|
||||
if (isDemo) {
|
||||
// 使用演示数据
|
||||
const result = await getDemoData.calls();
|
||||
// 转换数据格式以匹配 Call 类型
|
||||
const formattedResult = result.map(item => ({
|
||||
...item,
|
||||
caller_id: item.user_id,
|
||||
callee_id: item.interpreter_id,
|
||||
call_type: 'audio' as const,
|
||||
call_mode: 'human_interpreter' as const,
|
||||
end_time: item.end_time || undefined,
|
||||
room_sid: undefined,
|
||||
twilio_call_sid: undefined,
|
||||
quality_rating: undefined,
|
||||
currency: 'CNY' as const,
|
||||
updated_at: item.created_at
|
||||
}));
|
||||
setCalls(formattedResult);
|
||||
setTotalCount(formattedResult.length);
|
||||
setTotalPages(Math.ceil(formattedResult.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据
|
||||
let query = supabase
|
||||
.from(TABLES.CALLS)
|
||||
.select('*', { count: 'exact' });
|
||||
|
||||
// 搜索过滤
|
||||
if (filters.search) {
|
||||
query = query.or(`caller_id.ilike.%${filters.search}%,callee_id.ilike.%${filters.search}%`);
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filters.status !== 'all') {
|
||||
query = query.eq('status', filters.status);
|
||||
}
|
||||
|
||||
// 通话类型过滤
|
||||
if (filters.call_type !== 'all') {
|
||||
query = query.eq('call_type', filters.call_type);
|
||||
}
|
||||
|
||||
// 通话模式过滤
|
||||
if (filters.call_mode !== 'all') {
|
||||
query = query.eq('call_mode', filters.call_mode);
|
||||
}
|
||||
|
||||
// 排序
|
||||
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' });
|
||||
|
||||
// 分页
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
query = query.range(from, to);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setCalls(data || []);
|
||||
setTotalCount(count || 0);
|
||||
setTotalPages(Math.ceil((count || 0) / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching calls:', error);
|
||||
toast.error('获取通话记录失败');
|
||||
|
||||
// 如果真实数据获取失败,切换到演示模式
|
||||
if (!isDemoMode) {
|
||||
setIsDemoMode(true);
|
||||
const result = await getDemoData.calls();
|
||||
const formattedResult = result.map(item => ({
|
||||
...item,
|
||||
caller_id: item.user_id,
|
||||
callee_id: item.interpreter_id,
|
||||
call_type: 'audio' as const,
|
||||
call_mode: 'human_interpreter' as const,
|
||||
end_time: item.end_time || undefined,
|
||||
room_sid: undefined,
|
||||
twilio_call_sid: undefined,
|
||||
quality_rating: undefined,
|
||||
currency: 'CNY' as const,
|
||||
updated_at: item.created_at
|
||||
}));
|
||||
setCalls(formattedResult);
|
||||
setTotalCount(formattedResult.length);
|
||||
setTotalPages(Math.ceil(formattedResult.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof CallFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchCalls(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
status: 'all',
|
||||
call_type: 'all',
|
||||
call_mode: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchCalls(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'ended':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return '进行中';
|
||||
case 'pending':
|
||||
return '待接听';
|
||||
case 'ended':
|
||||
return '已结束';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取通话类型文本
|
||||
const getCallTypeText = (type: string) => {
|
||||
switch (type) {
|
||||
case 'audio':
|
||||
return '语音通话';
|
||||
case 'video':
|
||||
return '视频通话';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取通话模式文本
|
||||
const getCallModeText = (mode: string) => {
|
||||
switch (mode) {
|
||||
case 'ai_voice':
|
||||
return 'AI语音';
|
||||
case 'ai_video':
|
||||
return 'AI视频';
|
||||
case 'sign_language':
|
||||
return '手语翻译';
|
||||
case 'human_interpreter':
|
||||
return '人工翻译';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化时长
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '-';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
return `${minutes}分${remainingSeconds}秒`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCalls();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>通话记录 - 口译服务管理后台</title>
|
||||
</Head>
|
||||
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">通话记录</h1>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="bg-white shadow rounded-lg mb-6">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{/* 搜索框 */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户ID..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="active">进行中</option>
|
||||
<option value="pending">待接听</option>
|
||||
<option value="ended">已结束</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 通话类型筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.call_type}
|
||||
onChange={(e) => handleFilterChange('call_type', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="audio">语音通话</option>
|
||||
<option value="video">视频通话</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 通话模式筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.call_mode}
|
||||
onChange={(e) => handleFilterChange('call_mode', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部模式</option>
|
||||
<option value="ai_voice">AI语音</option>
|
||||
<option value="ai_video">AI视频</option>
|
||||
<option value="sign_language">手语翻译</option>
|
||||
<option value="human_interpreter">人工翻译</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<div>
|
||||
<select
|
||||
value={`${filters.sortBy}-${filters.sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [sortBy, sortOrder] = e.target.value.split('-');
|
||||
handleFilterChange('sortBy', sortBy);
|
||||
handleFilterChange('sortOrder', sortOrder);
|
||||
}}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="created_at-desc">创建时间 (新到旧)</option>
|
||||
<option value="created_at-asc">创建时间 (旧到新)</option>
|
||||
<option value="duration-desc">时长 (长到短)</option>
|
||||
<option value="duration-asc">时长 (短到长)</option>
|
||||
<option value="cost-desc">费用 (高到低)</option>
|
||||
<option value="cost-asc">费用 (低到高)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-3">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 通话记录列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
) : calls.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<PhoneIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">暂无通话记录</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
调整筛选条件或检查数据源
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
通话记录 ({totalCount} 条记录)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
通话信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
类型/模式
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
时长
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
费用
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
开始时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{calls.map((call) => (
|
||||
<tr key={call.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{call.id}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
主叫: {call.caller_id}
|
||||
</div>
|
||||
{call.callee_id && (
|
||||
<div className="text-sm text-gray-500">
|
||||
被叫: {call.callee_id}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">
|
||||
{getCallTypeText(call.call_type)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{getCallModeText(call.call_mode)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{formatDuration(call.duration)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
¥{call.cost.toFixed(2)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}>
|
||||
{call.status === 'active' && <PlayIcon className="h-3 w-3 mr-1" />}
|
||||
{call.status === 'ended' && <StopIcon className="h-3 w-3 mr-1" />}
|
||||
{getStatusText(call.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{formatTime(call.start_time)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-900 mr-3"
|
||||
onClick={() => {
|
||||
// 查看通话详情
|
||||
toast.success('查看通话详情功能待实现');
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchCalls(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchCalls(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 到{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, totalCount)}
|
||||
</span>{' '}
|
||||
条,共 <span className="font-medium">{totalCount}</span> 条记录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => fetchCalls(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
|
||||
const page = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => fetchCalls(page)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => fetchCalls(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
496
pages/dashboard/documents.tsx
Normal file
496
pages/dashboard/documents.tsx
Normal file
@ -0,0 +1,496 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
DocumentTextIcon,
|
||||
CloudArrowUpIcon,
|
||||
CloudArrowDownIcon,
|
||||
EyeIcon,
|
||||
TrashIcon,
|
||||
PencilIcon,
|
||||
CheckCircleIcon,
|
||||
ClockIcon,
|
||||
ExclamationCircleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { formatTime } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
user_id: string;
|
||||
original_name: string;
|
||||
file_size: number;
|
||||
file_type: string;
|
||||
source_language: string;
|
||||
target_language: string;
|
||||
status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
translated_url?: string;
|
||||
cost: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user_name?: string;
|
||||
}
|
||||
|
||||
interface DocumentFilters {
|
||||
search: string;
|
||||
status: 'all' | 'pending' | 'processing' | 'completed' | 'failed';
|
||||
language: string;
|
||||
fileType: string;
|
||||
sortBy: 'created_at' | 'file_size' | 'cost' | 'progress';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [filters, setFilters] = useState<DocumentFilters>({
|
||||
search: '',
|
||||
status: 'all',
|
||||
language: 'all',
|
||||
fileType: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取文档数据
|
||||
const fetchDocuments = async (page = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
setIsDemoMode(isDemo);
|
||||
|
||||
if (isDemo) {
|
||||
// 使用演示数据
|
||||
const result = await getDemoData.documents();
|
||||
setDocuments(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据 - 这里需要根据实际数据库结构调整
|
||||
// 暂时使用演示数据
|
||||
const result = await getDemoData.documents();
|
||||
setDocuments(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching documents:', error);
|
||||
toast.error('获取文档列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof DocumentFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchDocuments(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
status: 'all',
|
||||
language: 'all',
|
||||
fileType: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchDocuments(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'processing':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
case 'pending':
|
||||
return '等待中';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态图标
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircleIcon className="h-4 w-4 text-green-600" />;
|
||||
case 'processing':
|
||||
return <ClockIcon className="h-4 w-4 text-blue-600" />;
|
||||
case 'pending':
|
||||
return <ClockIcon className="h-4 w-4 text-yellow-600" />;
|
||||
case 'failed':
|
||||
return <ExclamationCircleIcon className="h-4 w-4 text-red-600" />;
|
||||
default:
|
||||
return <ClockIcon className="h-4 w-4 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化文件大小
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// 删除文档
|
||||
const handleDeleteDocument = async (documentId: string) => {
|
||||
if (confirm('确定要删除此文档吗?')) {
|
||||
try {
|
||||
// 这里应该调用删除API
|
||||
toast.success('文档删除成功');
|
||||
fetchDocuments();
|
||||
} catch (error) {
|
||||
toast.error('删除文档失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 下载文档
|
||||
const handleDownloadDocument = async (document: Document) => {
|
||||
try {
|
||||
// 这里应该调用下载API
|
||||
toast.success('开始下载文档');
|
||||
} catch (error) {
|
||||
toast.error('下载文档失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>文档管理 - 口译服务管理后台</title>
|
||||
</Head>
|
||||
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">文档管理</h1>
|
||||
<button className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||
<CloudArrowUpIcon className="h-4 w-4 mr-2" />
|
||||
上传文档
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="bg-white shadow rounded-lg mb-6">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{/* 搜索框 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索文档名称或用户..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="pending">等待中</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 语言筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.language}
|
||||
onChange={(e) => handleFilterChange('language', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部语言</option>
|
||||
<option value="zh-en">中文→英文</option>
|
||||
<option value="en-zh">英文→中文</option>
|
||||
<option value="zh-ja">中文→日文</option>
|
||||
<option value="ja-zh">日文→中文</option>
|
||||
<option value="zh-ko">中文→韩文</option>
|
||||
<option value="ko-zh">韩文→中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 文件类型筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.fileType}
|
||||
onChange={(e) => handleFilterChange('fileType', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部类型</option>
|
||||
<option value="pdf">PDF</option>
|
||||
<option value="docx">Word</option>
|
||||
<option value="txt">文本</option>
|
||||
<option value="pptx">PowerPoint</option>
|
||||
<option value="xlsx">Excel</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-3">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 文档列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
文档列表 ({totalCount} 个文档)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{documents.map((document) => (
|
||||
<div key={document.id} className="border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center space-x-3">
|
||||
<DocumentTextIcon className="h-8 w-8 text-gray-400" />
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 truncate">
|
||||
{document.original_name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{document.user_name} | {formatFileSize(document.file_size)} | {document.file_type.toUpperCase()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
{document.source_language} → {document.target_language} | ¥{document.cost.toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 进度条 */}
|
||||
{document.status === 'processing' && (
|
||||
<div className="w-24">
|
||||
<div className="bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${document.progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{document.progress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 状态 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(document.status)}
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(document.status)}`}>
|
||||
{getStatusText(document.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-900"
|
||||
title="查看详情"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
{document.status === 'completed' && document.translated_url && (
|
||||
<button
|
||||
onClick={() => handleDownloadDocument(document)}
|
||||
className="text-green-600 hover:text-green-900"
|
||||
title="下载译文"
|
||||
>
|
||||
<CloudArrowDownIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button className="text-yellow-600 hover:text-yellow-900">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteDocument(document.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<div className="mt-3 text-xs text-gray-400">
|
||||
创建时间: {formatTime(document.created_at)} |
|
||||
更新时间: {formatTime(document.updated_at)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{documents.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<DocumentTextIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">暂无文档</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">开始上传您的第一个文档</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchDocuments(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchDocuments(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 到{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, totalCount)}
|
||||
</span>{' '}
|
||||
个,共 <span className="font-medium">{totalCount}</span> 个文档
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => fetchDocuments(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
|
||||
const page = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => fetchDocuments(page)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => fetchDocuments(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
858
pages/dashboard/enterprise.tsx
Normal file
858
pages/dashboard/enterprise.tsx
Normal 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
484
pages/dashboard/index.tsx
Normal file
@ -0,0 +1,484 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
PhoneIcon,
|
||||
VideoCameraIcon,
|
||||
UserGroupIcon,
|
||||
ClockIcon,
|
||||
CurrencyDollarIcon,
|
||||
ExclamationTriangleIcon,
|
||||
PlayIcon,
|
||||
StopIcon,
|
||||
UserPlusIcon,
|
||||
ArrowRightOnRectangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { auth, db, TABLES, realtime, supabase } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { Call, CallStats, Interpreter, User } from '@/types';
|
||||
import {
|
||||
formatCurrency,
|
||||
formatTime,
|
||||
formatDuration,
|
||||
getCallStatusText,
|
||||
getCallModeText,
|
||||
getStatusColor
|
||||
} from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface DashboardProps {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export default function Dashboard({ user }: DashboardProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [stats, setStats] = useState<CallStats>({
|
||||
total_calls_today: 0,
|
||||
active_calls: 0,
|
||||
average_response_time: 0,
|
||||
online_interpreters: 0,
|
||||
total_revenue_today: 0,
|
||||
currency: 'CNY',
|
||||
});
|
||||
const [activeCalls, setActiveCalls] = useState<Call[]>([]);
|
||||
const [onlineInterpreters, setOnlineInterpreters] = useState<Interpreter[]>([]);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
|
||||
// 获取仪表盘数据
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
setIsDemoMode(isDemoMode);
|
||||
|
||||
if (isDemoMode) {
|
||||
// 使用演示数据
|
||||
const [statsData, callsData, interpretersData] = await Promise.all([
|
||||
getDemoData.stats(),
|
||||
getDemoData.calls(),
|
||||
getDemoData.interpreters(),
|
||||
]);
|
||||
|
||||
// 转换演示数据格式以匹配类型定义
|
||||
setStats({
|
||||
total_calls_today: statsData.todayCalls,
|
||||
active_calls: statsData.activeCalls,
|
||||
average_response_time: statsData.avgResponseTime,
|
||||
online_interpreters: statsData.onlineInterpreters,
|
||||
total_revenue_today: statsData.todayRevenue,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
// 转换通话数据格式
|
||||
const formattedCalls = callsData
|
||||
.filter(call => call.status === 'active')
|
||||
.map(call => ({
|
||||
id: call.id,
|
||||
caller_id: call.user_id,
|
||||
callee_id: call.interpreter_id,
|
||||
call_type: 'audio' as const,
|
||||
call_mode: 'human_interpreter' as const,
|
||||
status: call.status as 'active',
|
||||
start_time: call.start_time,
|
||||
end_time: call.end_time,
|
||||
duration: call.duration,
|
||||
cost: call.cost,
|
||||
currency: 'CNY' as const,
|
||||
created_at: call.created_at,
|
||||
updated_at: call.created_at,
|
||||
}));
|
||||
|
||||
// 转换翻译员数据格式
|
||||
const formattedInterpreters = interpretersData
|
||||
.filter(interpreter => interpreter.status !== 'offline')
|
||||
.map(interpreter => ({
|
||||
id: interpreter.id,
|
||||
user_id: interpreter.id,
|
||||
name: interpreter.name,
|
||||
avatar_url: interpreter.avatar_url,
|
||||
languages: interpreter.languages,
|
||||
specializations: interpreter.specialties,
|
||||
hourly_rate: 100,
|
||||
currency: 'CNY' as const,
|
||||
rating: interpreter.rating,
|
||||
total_calls: 50,
|
||||
status: interpreter.status === 'busy' ? 'busy' as const : 'online' as const,
|
||||
is_certified: true,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
setActiveCalls(formattedCalls);
|
||||
setOnlineInterpreters(formattedInterpreters);
|
||||
} else {
|
||||
// 使用真实数据
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
// 获取今日通话统计
|
||||
const { data: todayCalls } = await supabase
|
||||
.from(TABLES.CALLS)
|
||||
.select('*')
|
||||
.gte('created_at', today.toISOString());
|
||||
|
||||
// 获取活跃通话
|
||||
const { data: activeCallsData } = await supabase
|
||||
.from(TABLES.CALLS)
|
||||
.select(`
|
||||
*,
|
||||
user:users(full_name, email),
|
||||
interpreter:interpreters(name, rating)
|
||||
`)
|
||||
.eq('status', 'active');
|
||||
|
||||
// 获取在线翻译员
|
||||
const { data: interpretersData } = await supabase
|
||||
.from(TABLES.INTERPRETERS)
|
||||
.select('*')
|
||||
.neq('status', 'offline');
|
||||
|
||||
// 计算统计数据
|
||||
const totalRevenue = todayCalls && todayCalls.length > 0
|
||||
? todayCalls
|
||||
.filter(call => call.status === 'ended')
|
||||
.reduce((sum, call) => sum + call.cost, 0)
|
||||
: 0;
|
||||
|
||||
const avgResponseTime = todayCalls && todayCalls.length > 0
|
||||
? todayCalls.reduce((sum, call) => {
|
||||
const startTime = new Date(call.start_time);
|
||||
const createdTime = new Date(call.created_at);
|
||||
return sum + (startTime.getTime() - createdTime.getTime()) / 1000;
|
||||
}, 0) / todayCalls.length
|
||||
: 0;
|
||||
|
||||
setStats({
|
||||
total_calls_today: todayCalls?.length || 0,
|
||||
active_calls: activeCallsData?.length || 0,
|
||||
average_response_time: Math.round(avgResponseTime),
|
||||
online_interpreters: interpretersData?.length || 0,
|
||||
total_revenue_today: totalRevenue,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
setActiveCalls(activeCallsData || []);
|
||||
setOnlineInterpreters(interpretersData || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取仪表盘数据失败:', error);
|
||||
toast.error('获取数据失败,请稍后重试');
|
||||
|
||||
// 如果获取真实数据失败,切换到演示模式
|
||||
setIsDemoMode(true);
|
||||
const [statsData, callsData, interpretersData] = await Promise.all([
|
||||
getDemoData.stats(),
|
||||
getDemoData.calls(),
|
||||
getDemoData.interpreters(),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
total_calls_today: statsData.todayCalls,
|
||||
active_calls: statsData.activeCalls,
|
||||
average_response_time: statsData.avgResponseTime,
|
||||
online_interpreters: statsData.onlineInterpreters,
|
||||
total_revenue_today: statsData.todayRevenue,
|
||||
currency: 'CNY',
|
||||
});
|
||||
|
||||
// 设置空数组避免类型错误
|
||||
setActiveCalls([]);
|
||||
setOnlineInterpreters([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 强制结束通话
|
||||
const handleEndCall = async (callId: string) => {
|
||||
try {
|
||||
await db.update(TABLES.CALLS, callId, {
|
||||
status: 'ended',
|
||||
end_time: new Date().toISOString()
|
||||
});
|
||||
toast.success('通话已结束');
|
||||
fetchDashboardData();
|
||||
} catch (error) {
|
||||
console.error('Error ending call:', error);
|
||||
toast.error('结束通话失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 分配翻译员
|
||||
const handleAssignInterpreter = async (callId: string, interpreterId: string) => {
|
||||
try {
|
||||
await db.update(TABLES.CALLS, callId, {
|
||||
callee_id: interpreterId,
|
||||
call_mode: 'human_interpreter'
|
||||
});
|
||||
toast.success('翻译员已分配');
|
||||
fetchDashboardData();
|
||||
} catch (error) {
|
||||
console.error('Error assigning interpreter:', error);
|
||||
toast.error('分配翻译员失败');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// 在演示模式下不检查用户认证
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemoMode = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
|
||||
if (!isDemoMode && !user) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
fetchDashboardData();
|
||||
|
||||
// 设置实时数据更新
|
||||
const callsChannel = realtime.subscribe(
|
||||
TABLES.CALLS,
|
||||
() => {
|
||||
fetchDashboardData();
|
||||
}
|
||||
);
|
||||
|
||||
const interpretersChannel = realtime.subscribe(
|
||||
TABLES.INTERPRETERS,
|
||||
() => {
|
||||
fetchDashboardData();
|
||||
}
|
||||
);
|
||||
|
||||
// 每30秒刷新一次数据
|
||||
const interval = setInterval(fetchDashboardData, 30000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
realtime.unsubscribe(callsChannel);
|
||||
realtime.unsubscribe(interpretersChannel);
|
||||
};
|
||||
}, [user, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout user={user}>
|
||||
<Head>
|
||||
<title>仪表盘 - 口译服务管理后台</title>
|
||||
</Head>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<PhoneIcon className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
今日通话总量
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.total_calls_today}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<VideoCameraIcon className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
当前活跃通话
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.active_calls}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<UserGroupIcon className="h-6 w-6 text-blue-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
在线翻译员
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{stats.online_interpreters}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||||
<div className="p-5">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<CurrencyDollarIcon className="h-6 w-6 text-yellow-400" />
|
||||
</div>
|
||||
<div className="ml-5 w-0 flex-1">
|
||||
<dl>
|
||||
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||
今日收入
|
||||
</dt>
|
||||
<dd className="text-lg font-medium text-gray-900">
|
||||
{formatCurrency(stats.total_revenue_today, 'CNY')}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 活跃通话列表 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
实时通话列表
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{activeCalls.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">
|
||||
当前没有活跃通话
|
||||
</p>
|
||||
) : (
|
||||
activeCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="flex items-center justify-between p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`call-status ${call.status}`}>
|
||||
<div className="w-3 h-3 rounded-full bg-green-500"></div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{getCallModeText(call.call_mode)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatTime(call.start_time)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(call.status)}`}>
|
||||
{getCallStatusText(call.status)}
|
||||
</span>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => handleEndCall(call.id)}
|
||||
className="p-1 text-red-600 hover:text-red-500"
|
||||
title="强制结束通话"
|
||||
>
|
||||
<StopIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {/* 跳转到通话详情 */}}
|
||||
className="p-1 text-blue-600 hover:text-blue-500"
|
||||
title="查看详情"
|
||||
>
|
||||
<PlayIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 在线翻译员 */}
|
||||
<div>
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||
在线翻译员
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{onlineInterpreters.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-4">
|
||||
暂无翻译员在线
|
||||
</p>
|
||||
) : (
|
||||
onlineInterpreters.slice(0, 5).map((interpreter) => (
|
||||
<div
|
||||
key={interpreter.id}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<img
|
||||
className="h-8 w-8 rounded-full"
|
||||
src={interpreter.avatar_url || `https://ui-avatars.com/api/?name=${interpreter.name}`}
|
||||
alt={interpreter.name}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{interpreter.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
评分: {interpreter.rating}/5
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full"></div>
|
||||
<span className="text-xs text-green-600">在线</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
414
pages/dashboard/interpreters.tsx
Normal file
414
pages/dashboard/interpreters.tsx
Normal file
@ -0,0 +1,414 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
UserIcon,
|
||||
StarIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { Interpreter } from '@/types';
|
||||
import { formatTime } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface InterpreterFilters {
|
||||
search: string;
|
||||
status: 'all' | 'online' | 'busy' | 'offline';
|
||||
sortBy: 'created_at' | 'name' | 'rating';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function InterpretersPage() {
|
||||
const [interpreters, setInterpreters] = useState<Interpreter[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [filters, setFilters] = useState<InterpreterFilters>({
|
||||
search: '',
|
||||
status: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取翻译员列表
|
||||
const fetchInterpreters = async (page = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
setIsDemoMode(isDemo);
|
||||
|
||||
if (isDemo) {
|
||||
// 使用演示数据
|
||||
const result = await getDemoData.interpreters();
|
||||
// 转换数据格式以匹配 Interpreter 类型
|
||||
const formattedResult = result.map(item => ({
|
||||
...item,
|
||||
user_id: item.id,
|
||||
specializations: item.specialties || [],
|
||||
hourly_rate: 150,
|
||||
currency: 'CNY' as const,
|
||||
total_calls: Math.floor(Math.random() * 100),
|
||||
is_certified: Math.random() > 0.5,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}));
|
||||
setInterpreters(formattedResult);
|
||||
setTotalCount(formattedResult.length);
|
||||
setTotalPages(Math.ceil(formattedResult.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据
|
||||
let query = supabase
|
||||
.from(TABLES.INTERPRETERS)
|
||||
.select('*', { count: 'exact' });
|
||||
|
||||
// 搜索过滤
|
||||
if (filters.search) {
|
||||
query = query.or(`name.ilike.%${filters.search}%`);
|
||||
}
|
||||
|
||||
// 状态过滤
|
||||
if (filters.status !== 'all') {
|
||||
query = query.eq('status', filters.status);
|
||||
}
|
||||
|
||||
// 排序
|
||||
query = query.order(filters.sortBy, { ascending: filters.sortOrder === 'asc' });
|
||||
|
||||
// 分页
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
query = query.range(from, to);
|
||||
|
||||
const { data, error, count } = await query;
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setInterpreters(data || []);
|
||||
setTotalCount(count || 0);
|
||||
setTotalPages(Math.ceil((count || 0) / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching interpreters:', error);
|
||||
toast.error('获取翻译员列表失败');
|
||||
|
||||
// 如果真实数据获取失败,切换到演示模式
|
||||
if (!isDemoMode) {
|
||||
setIsDemoMode(true);
|
||||
const result = await getDemoData.interpreters();
|
||||
// 转换数据格式以匹配 Interpreter 类型
|
||||
const formattedResult = result.map(item => ({
|
||||
...item,
|
||||
user_id: item.id,
|
||||
specializations: item.specialties || [],
|
||||
hourly_rate: 150,
|
||||
currency: 'CNY' as const,
|
||||
total_calls: Math.floor(Math.random() * 100),
|
||||
is_certified: Math.random() > 0.5,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}));
|
||||
setInterpreters(formattedResult);
|
||||
setTotalCount(formattedResult.length);
|
||||
setTotalPages(Math.ceil(formattedResult.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof InterpreterFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchInterpreters(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
status: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchInterpreters(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'busy':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'offline':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return '在线';
|
||||
case 'busy':
|
||||
return '忙碌';
|
||||
case 'offline':
|
||||
return '离线';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchInterpreters();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>翻译员管理 - 口译服务管理后台</title>
|
||||
</Head>
|
||||
|
||||
<div className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<div className="px-4 py-6 sm:px-0">
|
||||
{/* 页面标题 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">翻译员管理</h1>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="bg-white shadow rounded-lg mb-6">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="lg:col-span-2">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索翻译员姓名..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="block w-full pl-10 pr-3 py-2 border border-gray-300 rounded-md leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态筛选 */}
|
||||
<div>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="all">全部状态</option>
|
||||
<option value="online">在线</option>
|
||||
<option value="busy">忙碌</option>
|
||||
<option value="offline">离线</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 排序 */}
|
||||
<div>
|
||||
<select
|
||||
value={`${filters.sortBy}-${filters.sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [sortBy, sortOrder] = e.target.value.split('-');
|
||||
handleFilterChange('sortBy', sortBy);
|
||||
handleFilterChange('sortOrder', sortOrder);
|
||||
}}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md bg-white focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="created_at-desc">加入时间 (新到旧)</option>
|
||||
<option value="created_at-asc">加入时间 (旧到新)</option>
|
||||
<option value="name-asc">姓名 (A-Z)</option>
|
||||
<option value="name-desc">姓名 (Z-A)</option>
|
||||
<option value="rating-desc">评分 (高到低)</option>
|
||||
<option value="rating-asc">评分 (低到高)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-3">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
应用筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 翻译员列表 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="loading-spinner"></div>
|
||||
</div>
|
||||
) : interpreters.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<UserIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">暂无翻译员</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
调整筛选条件或检查数据源
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
翻译员列表 ({totalCount} 个翻译员)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{interpreters.map((interpreter) => (
|
||||
<div
|
||||
key={interpreter.id}
|
||||
className="p-4 border border-gray-200 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<img
|
||||
className="h-10 w-10 rounded-full"
|
||||
src={interpreter.avatar_url || `https://ui-avatars.com/api/?name=${interpreter.name}`}
|
||||
alt={interpreter.name}
|
||||
/>
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{interpreter.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500">
|
||||
{interpreter.languages.join(', ')}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400">
|
||||
专业领域: {interpreter.specializations?.join(', ') || '无'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<StarIcon className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{interpreter.rating || 0}/5
|
||||
</span>
|
||||
</div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(interpreter.status)}`}>
|
||||
{getStatusText(interpreter.status)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => fetchInterpreters(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => fetchInterpreters(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
显示第 <span className="font-medium">{(currentPage - 1) * pageSize + 1}</span> 到{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(currentPage * pageSize, totalCount)}
|
||||
</span>{' '}
|
||||
条,共 <span className="font-medium">{totalCount}</span> 条记录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => fetchInterpreters(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronLeftIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{[...Array(Math.min(totalPages, 5))].map((_, i) => {
|
||||
const page = i + 1;
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => fetchInterpreters(page)}
|
||||
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
|
||||
page === currentPage
|
||||
? 'z-10 bg-blue-50 border-blue-500 text-blue-600'
|
||||
: 'bg-white border-gray-300 text-gray-500 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={() => fetchInterpreters(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
<ChevronRightIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
542
pages/dashboard/invoices.tsx
Normal file
542
pages/dashboard/invoices.tsx
Normal 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
483
pages/dashboard/orders.tsx
Normal file
@ -0,0 +1,483 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/router';
|
||||
import Head from 'next/head';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
EyeIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PhoneIcon,
|
||||
VideoCameraIcon,
|
||||
DocumentTextIcon,
|
||||
HandRaisedIcon,
|
||||
SpeakerWaveIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { supabase, TABLES } from '@/lib/supabase';
|
||||
import { getDemoData } from '@/lib/demo-data';
|
||||
import { formatTime, formatCurrency } from '@/utils';
|
||||
import Layout from '@/components/Layout';
|
||||
|
||||
interface Order {
|
||||
id: string;
|
||||
order_number: string;
|
||||
user_id: string;
|
||||
user_name: string;
|
||||
user_email: string;
|
||||
service_type: 'ai_voice_translation' | 'ai_video_translation' | 'sign_language_translation' | 'human_interpretation' | 'document_translation';
|
||||
service_name: string;
|
||||
source_language: string;
|
||||
target_language: string;
|
||||
duration?: number; // 分钟
|
||||
pages?: number; // 页数
|
||||
status: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed';
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
cost: number;
|
||||
currency: string;
|
||||
// 时间信息
|
||||
scheduled_at?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// 额外信息
|
||||
notes?: string;
|
||||
interpreter_id?: string;
|
||||
interpreter_name?: string;
|
||||
}
|
||||
|
||||
interface OrderFilters {
|
||||
search: string;
|
||||
status: 'all' | 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed';
|
||||
service_type: 'all' | 'ai_voice_translation' | 'ai_video_translation' | 'sign_language_translation' | 'human_interpretation' | 'document_translation';
|
||||
priority: 'all' | 'low' | 'normal' | 'high' | 'urgent';
|
||||
date_range: 'all' | 'today' | 'week' | 'month';
|
||||
sortBy: 'created_at' | 'cost' | 'scheduled_at';
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||
const [filters, setFilters] = useState<OrderFilters>({
|
||||
search: '',
|
||||
status: 'all',
|
||||
service_type: 'all',
|
||||
priority: 'all',
|
||||
date_range: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
// 获取订单数据
|
||||
const fetchOrders = async (page = 1) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 检查是否为演示模式
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const isDemo = !supabaseUrl || supabaseUrl === 'https://demo.supabase.co' || supabaseUrl === '';
|
||||
setIsDemoMode(isDemo);
|
||||
|
||||
if (isDemo) {
|
||||
// 使用演示数据
|
||||
const result = await getDemoData.orders();
|
||||
setOrders(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
} else {
|
||||
// 使用真实数据 - 这里需要根据实际数据库结构调整
|
||||
// 暂时使用演示数据
|
||||
const result = await getDemoData.orders();
|
||||
setOrders(result);
|
||||
setTotalCount(result.length);
|
||||
setTotalPages(Math.ceil(result.length / pageSize));
|
||||
setCurrentPage(page);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching orders:', error);
|
||||
toast.error('获取订单数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders(1);
|
||||
}, []);
|
||||
|
||||
// 处理筛选变更
|
||||
const handleFilterChange = (key: keyof OrderFilters, value: any) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
// 应用筛选
|
||||
const applyFilters = () => {
|
||||
setCurrentPage(1);
|
||||
fetchOrders(1);
|
||||
};
|
||||
|
||||
// 重置筛选
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
status: 'all',
|
||||
service_type: 'all',
|
||||
priority: 'all',
|
||||
date_range: 'all',
|
||||
sortBy: 'created_at',
|
||||
sortOrder: 'desc'
|
||||
});
|
||||
setCurrentPage(1);
|
||||
fetchOrders(1);
|
||||
};
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'processing':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'cancelled':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '待处理';
|
||||
case 'processing':
|
||||
return '处理中';
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取优先级颜色
|
||||
const getPriorityColor = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'normal':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'low':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取优先级文本
|
||||
const getPriorityText = (priority: string) => {
|
||||
switch (priority) {
|
||||
case 'urgent':
|
||||
return '紧急';
|
||||
case 'high':
|
||||
return '高';
|
||||
case 'normal':
|
||||
return '普通';
|
||||
case 'low':
|
||||
return '低';
|
||||
default:
|
||||
return priority;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取服务类型图标
|
||||
const getServiceIcon = (serviceType: string) => {
|
||||
switch (serviceType) {
|
||||
case 'ai_voice_translation':
|
||||
return <SpeakerWaveIcon className="h-4 w-4" />;
|
||||
case 'ai_video_translation':
|
||||
return <VideoCameraIcon className="h-4 w-4" />;
|
||||
case 'sign_language_translation':
|
||||
return <HandRaisedIcon className="h-4 w-4" />;
|
||||
case 'human_interpretation':
|
||||
return <PhoneIcon className="h-4 w-4" />;
|
||||
case 'document_translation':
|
||||
return <DocumentTextIcon className="h-4 w-4" />;
|
||||
default:
|
||||
return <DocumentTextIcon className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Head>
|
||||
<title>订单管理 - 口译服务管理系统</title>
|
||||
</Head>
|
||||
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="sm:flex sm:items-center">
|
||||
<div className="sm:flex-auto">
|
||||
<h1 className="text-2xl font-semibold text-gray-900">订单管理</h1>
|
||||
<p className="mt-2 text-sm text-gray-700">
|
||||
管理所有口译服务订单
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="mt-8 bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-6">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索订单号或用户..."
|
||||
className="pl-10 w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.status}
|
||||
onChange={(e) => handleFilterChange('status', e.target.value)}
|
||||
>
|
||||
<option value="all">所有状态</option>
|
||||
<option value="pending">待处理</option>
|
||||
<option value="processing">处理中</option>
|
||||
<option value="completed">已完成</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<option value="failed">失败</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.service_type}
|
||||
onChange={(e) => handleFilterChange('service_type', e.target.value)}
|
||||
>
|
||||
<option value="all">所有服务</option>
|
||||
<option value="ai_voice_translation">AI语音翻译</option>
|
||||
<option value="ai_video_translation">AI视频翻译</option>
|
||||
<option value="sign_language_translation">手语翻译</option>
|
||||
<option value="human_interpretation">人工口译</option>
|
||||
<option value="document_translation">文档翻译</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.priority}
|
||||
onChange={(e) => handleFilterChange('priority', e.target.value)}
|
||||
>
|
||||
<option value="all">所有优先级</option>
|
||||
<option value="urgent">紧急</option>
|
||||
<option value="high">高</option>
|
||||
<option value="normal">普通</option>
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
|
||||
value={filters.date_range}
|
||||
onChange={(e) => handleFilterChange('date_range', e.target.value)}
|
||||
>
|
||||
<option value="all">所有时间</option>
|
||||
<option value="today">今天</option>
|
||||
<option value="week">本周</option>
|
||||
<option value="month">本月</option>
|
||||
</select>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={applyFilters}
|
||||
className="flex-1 bg-indigo-600 text-white px-4 py-2 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
>
|
||||
筛选
|
||||
</button>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="flex-1 bg-gray-300 text-gray-700 px-4 py-2 rounded-md hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 订单列表 */}
|
||||
<div className="mt-8 bg-white shadow rounded-lg overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="p-8 text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto"></div>
|
||||
<p className="mt-2 text-sm text-gray-500">加载中...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
订单信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
客户信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
服务详情
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
优先级
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
费用
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{orders.map((order) => (
|
||||
<tr key={order.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{order.order_number}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 flex items-center">
|
||||
{getServiceIcon(order.service_type)}
|
||||
<span className="ml-1">{order.service_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{order.user_name}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">{order.user_email}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<div className="text-sm text-gray-900">
|
||||
{order.source_language} → {order.target_language}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{order.duration && `时长: ${order.duration}分钟`}
|
||||
{order.pages && `页数: ${order.pages}页`}
|
||||
</div>
|
||||
{order.interpreter_name && (
|
||||
<div className="text-xs text-gray-400">
|
||||
译员: {order.interpreter_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(order.status)}`}>
|
||||
{getStatusText(order.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getPriorityColor(order.priority)}`}>
|
||||
{getPriorityText(order.priority)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{formatCurrency(order.cost)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
<div>
|
||||
<div>创建: {formatTime(order.created_at)}</div>
|
||||
{order.scheduled_at && (
|
||||
<div>预约: {formatTime(order.scheduled_at)}</div>
|
||||
)}
|
||||
{order.completed_at && (
|
||||
<div>完成: {formatTime(order.completed_at)}</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
title="查看详情"
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-700">
|
||||
显示第 {(currentPage - 1) * pageSize + 1} - {Math.min(currentPage * pageSize, totalCount)} 条,共 {totalCount} 条
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => fetchOrders(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm font-medium text-gray-700">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => fetchOrders(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
671
pages/dashboard/settings.tsx
Normal file
671
pages/dashboard/settings.tsx
Normal 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
370
pages/dashboard/users.tsx
Normal 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
75
pages/index.tsx
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
402
styles/components.css
Normal file
402
styles/components.css
Normal 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
435
styles/globals.css
Normal 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
57
tailwind.config.js
Normal 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
34
tsconfig.json
Normal 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
246
types/index.ts
Normal 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
260
utils/index.ts
Normal 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;
|
Loading…
x
Reference in New Issue
Block a user