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