修复退出登录重定向问题和相关功能优化

- 修复DashboardLayout中的退出登录函数,确保清除所有认证信息
- 恢复_app.tsx中的认证逻辑,确保仪表盘页面需要登录访问
- 完善退出登录流程:清除本地存储 -> 调用登出API -> 重定向到登录页面
- 添加错误边界组件提升用户体验
- 优化React水合错误处理
- 添加JWT令牌验证API
- 完善各个仪表盘页面的功能和样式
This commit is contained in:
2025-07-03 20:56:17 +08:00
parent 211e0306b5
commit 1ba859196a
17 changed files with 1656 additions and 462 deletions
+140 -53
View File
@@ -1,17 +1,85 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } 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 useClientMount from '../utils/useClientMount';
import ErrorBoundary from '../components/ErrorBoundary';
import '../styles/globals.css';
// 自定义用户类型,用于 JWT 认证
interface CustomUser {
id: string;
email: string;
name: string;
userType: string;
phone?: string;
avatarUrl?: string;
}
export default function App({ Component, pageProps }: AppProps) {
const [user, setUser] = useState<User | null>(null);
const [user, setUser] = useState<CustomUser | null>(null);
const [loading, setLoading] = useState(true);
const [navigationInProgress, setNavigationInProgress] = useState(false);
const isClient = useClientMount();
const router = useRouter();
const navigationTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// 防抖路由跳转函数
const navigateWithDebounce = (path: string) => {
if (navigationInProgress) return;
setNavigationInProgress(true);
// 清除之前的定时器
if (navigationTimeoutRef.current) {
clearTimeout(navigationTimeoutRef.current);
}
// 设置新的定时器
navigationTimeoutRef.current = setTimeout(() => {
router.push(path).finally(() => {
setNavigationInProgress(false);
});
}, 100); // 100ms 防抖延迟
};
// 检查 JWT 令牌的有效性
const checkJWTAuth = async () => {
try {
const token = localStorage.getItem('adminToken');
if (!token) {
return null;
}
// 验证令牌
const response = await fetch('/api/auth/verify-token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
return data.user;
} else {
// 令牌无效,清除本地存储
localStorage.removeItem('adminToken');
return null;
}
} catch (error) {
console.error('Token verification error:', error);
localStorage.removeItem('adminToken');
return null;
}
};
useEffect(() => {
// 只在客户端执行
if (!isClient) return;
// 检查是否为演示模式
const isDemoMode = !process.env.NEXT_PUBLIC_SUPABASE_URL ||
process.env.NEXT_PUBLIC_SUPABASE_URL === 'https://demo.supabase.co' ||
@@ -19,15 +87,27 @@ export default function App({ Component, pageProps }: AppProps) {
const checkUser = async () => {
try {
if (isDemoMode) {
// 演示模式下不检查用户认证
// 使用自定义 JWT 认证检查
const jwtUser = await checkJWTAuth();
if (jwtUser) {
setUser(jwtUser);
// 如果当前在登录页面且已经认证,重定向到仪表板
if (router.pathname === '/auth/login') {
navigateWithDebounce('/dashboard');
}
} else {
setUser(null);
setLoading(false);
return;
// 如果在需要认证的页面但未登录,重定向到登录页
const protectedRoutes = ['/dashboard', '/admin', '/settings'];
const isProtectedRoute = protectedRoutes.some(route =>
router.pathname.startsWith(route)
);
if (isProtectedRoute) {
navigateWithDebounce('/auth/login');
}
}
const { data: { user } } = await supabase.auth.getUser();
setUser(user);
} catch (error) {
console.error('Auth check error:', error);
setUser(null);
@@ -38,25 +118,30 @@ export default function App({ Component, pageProps }: AppProps) {
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');
}
}
);
// 监听路由变化,重新检查认证状态
const handleRouteChange = () => {
checkUser();
};
return () => {
subscription.unsubscribe();
};
}
}, [router]);
router.events.on('routeChangeComplete', handleRouteChange);
return () => {
router.events.off('routeChangeComplete', handleRouteChange);
// 清理定时器
if (navigationTimeoutRef.current) {
clearTimeout(navigationTimeoutRef.current);
}
};
}, [router, isClient]);
// 在客户端挂载之前,显示最小化的 loading 状态以避免水合错误
if (!isClient) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50" suppressHydrationWarning>
<div className="loading-spinner"></div>
</div>
);
}
// 显示加载状态
if (loading) {
@@ -68,32 +153,34 @@ export default function App({ Component, pageProps }: AppProps) {
}
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',
<ErrorBoundary>
<div suppressHydrationWarning>
<Component {...pageProps} user={user} />
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: '#363636',
color: '#fff',
},
},
error: {
duration: 5000,
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
success: {
duration: 3000,
iconTheme: {
primary: '#10b981',
secondary: '#fff',
},
},
},
}}
/>
</>
error: {
duration: 5000,
iconTheme: {
primary: '#ef4444',
secondary: '#fff',
},
},
}}
/>
</div>
</ErrorBoundary>
);
}
+71
View File
@@ -0,0 +1,71 @@
import { NextApiRequest, NextApiResponse } from 'next';
import jwt from 'jsonwebtoken';
interface JWTPayload {
userId: string;
email: string;
userType: string;
name: string;
iat?: number;
exp?: number;
}
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') {
return res.status(405).json({ success: false, error: '方法不允许' });
}
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: '缺少授权令牌'
});
}
const token = authHeader.substring(7); // 移除 "Bearer " 前缀
const jwtSecret = process.env.JWT_SECRET || 'your-secret-key';
try {
// 验证并解码 JWT 令牌
const decoded = jwt.verify(token, jwtSecret) as JWTPayload;
// 构造用户对象
const user = {
id: decoded.userId,
email: decoded.email,
name: decoded.name,
userType: decoded.userType,
phone: '13800138000', // 从硬编码数据中获取
avatarUrl: null
};
res.status(200).json({
success: true,
user,
valid: true
});
} catch (jwtError) {
// JWT 令牌无效或过期
console.log('JWT验证失败:', jwtError);
return res.status(401).json({
success: false,
error: '令牌无效或已过期',
valid: false
});
}
} catch (error) {
console.error('令牌验证错误:', error);
res.status(500).json({
success: false,
error: '服务器内部错误'
});
}
}
+1 -1
View File
@@ -42,7 +42,7 @@ const LoginPage = () => {
// 存储用户信息和令牌
localStorage.setItem('user', JSON.stringify(data.user));
localStorage.setItem('access_token', data.token);
localStorage.setItem('adminToken', data.token);
// 使用 window.location 进行重定向,避免 Next.js 路由问题
window.location.href = '/dashboard';
+208 -351
View File
@@ -1,53 +1,16 @@
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Head from 'next/head';
import DashboardLayout from '../../components/Layout/DashboardLayout';
import {
UserGroupIcon,
PhoneIcon,
DocumentTextIcon,
CurrencyDollarIcon,
ChartBarIcon,
ClockIcon,
CheckCircleIcon,
ExclamationTriangleIcon,
ArrowUpIcon,
ArrowDownIcon,
EyeIcon,
PencilIcon,
TrashIcon,
PlayIcon,
PauseIcon,
StopIcon,
MicrophoneIcon,
VideoCameraIcon,
GlobeAltIcon,
BellIcon,
CogIcon,
UserIcon,
BuildingOfficeIcon,
CalendarDaysIcon,
ChatBubbleLeftRightIcon,
BanknotesIcon,
UsersIcon,
LanguageIcon,
DocumentDuplicateIcon,
InboxIcon,
PhoneArrowUpRightIcon,
PhoneArrowDownLeftIcon,
TrophyIcon,
StarIcon,
HeartIcon,
FireIcon,
LightBulbIcon,
ShieldCheckIcon,
SparklesIcon,
RocketLaunchIcon,
MegaphoneIcon,
GiftIcon,
AcademicCapIcon,
MapIcon,
SunIcon,
MoonIcon,
ComputerDesktopIcon,
VideoCameraIcon,
LanguageIcon,
CurrencyDollarIcon,
} from '@heroicons/react/24/outline';
import {
CheckCircleIcon as CheckCircleIconSolid,
@@ -58,23 +21,7 @@ import {
PhoneIcon as PhoneIconSolid,
DocumentTextIcon as DocumentTextIconSolid,
CurrencyDollarIcon as CurrencyDollarIconSolid,
ChartBarIcon as ChartBarIconSolid,
BellIcon as BellIconSolid,
StarIcon as StarIconSolid,
HeartIcon as HeartIconSolid,
FireIcon as FireIconSolid,
TrophyIcon as TrophyIconSolid,
SparklesIcon as SparklesIconSolid,
RocketLaunchIcon as RocketLaunchIconSolid,
GiftIcon as GiftIconSolid,
AcademicCapIcon as AcademicCapIconSolid,
ShieldCheckIcon as ShieldCheckIconSolid,
LightBulbIcon as LightBulbIconSolid,
MegaphoneIcon as MegaphoneIconSolid,
MapIcon as MapIconSolid,
SunIcon as SunIconSolid,
MoonIcon as MoonIconSolid,
ComputerDesktopIcon as ComputerDesktopIconSolid,
UsersIcon as UsersIconSolid,
} from '@heroicons/react/24/solid';
import { toast } from 'react-hot-toast';
import { statsAPI } from '../../lib/api-service';
@@ -133,8 +80,8 @@ export default function Dashboard() {
activities.push({
id: order.id,
type: 'order',
title: `订单 ${order.order_number}`,
description: `${order.user_name} - ${order.service_name}`,
title: `订单 ${order.order_number || order.id}`,
description: `${order.user_name || '用户'} - ${order.service_name || '服务'}`,
time: formatTime(order.created_at),
status: getOrderStatus(order.status),
icon: getOrderIcon(order.service_type)
@@ -194,6 +141,7 @@ export default function Dashboard() {
};
const formatTime = (dateString: string) => {
if (!dateString) return '未知时间';
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
@@ -201,25 +149,23 @@ export default function Dashboard() {
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 0) return `${days}天前`;
if (hours > 0) return `${hours}小时`;
if (minutes > 0) return `${minutes}分钟前`;
return '刚刚';
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(amount);
if (days > 0) {
return `${days}`;
} else if (hours > 0) {
return `${hours}小时前`;
} else if (minutes > 0) {
return `${minutes}分钟前`;
} else {
return '刚刚';
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'success': return 'text-green-600 bg-green-50';
case 'warning': return 'text-yellow-600 bg-yellow-50';
case 'error': return 'text-red-600 bg-red-50';
default: return 'text-blue-600 bg-blue-50';
case 'success': return 'bg-green-100 text-green-800';
case 'warning': return 'bg-yellow-100 text-yellow-800';
case 'error': return 'bg-red-100 text-red-800';
default: return 'bg-blue-100 text-blue-800';
}
};
@@ -234,294 +180,205 @@ export default function Dashboard() {
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto"></div>
<p className="mt-4 text-gray-600">...</p>
</div>
</div>
<>
<Head>
<title> - </title>
</Head>
<DashboardLayout title="仪表盘">
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<p className="ml-4 text-gray-600">...</p>
</div>
</DashboardLayout>
</>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900"></h1>
<p className="mt-1 text-sm text-gray-600">
</p>
</div>
<div className="flex items-center space-x-4">
<button
onClick={() => router.push('/dashboard/notifications')}
className="relative p-2 text-gray-400 hover:text-gray-500"
>
<BellIcon className="h-6 w-6" />
<span className="absolute top-0 right-0 block h-2 w-2 rounded-full bg-red-400 ring-2 ring-white" />
</button>
<button
onClick={() => router.push('/dashboard/settings')}
className="p-2 text-gray-400 hover:text-gray-500"
>
<CogIcon className="h-6 w-6" />
</button>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 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">
<UserGroupIconSolid className="h-8 w-8 text-blue-600" />
</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.totalUsers.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-green-600 font-medium">
{stats.activeUsers}
</span>
</div>
</div>
<>
<Head>
<title> - </title>
</Head>
<DashboardLayout title="仪表盘">
<div className="space-y-6">
{/* 页面标题和描述 */}
<div>
<h1 className="text-2xl font-bold text-gray-900"></h1>
<p className="mt-2 text-sm text-gray-700">
</p>
</div>
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<UsersIcon className="h-8 w-8 text-green-600" />
</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.totalInterpreters.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-green-600 font-medium">
线
</span>
</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">
<DocumentTextIconSolid className="h-8 w-8 text-purple-600" />
</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.totalOrders.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-green-600 font-medium">
</span>
</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">
<PhoneIconSolid className="h-8 w-8 text-orange-600" />
</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.totalCalls.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-orange-600 font-medium">
{stats.activeCalls}
</span>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Recent Activity */}
<div className="lg:col-span-2">
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<div className="divide-y divide-gray-200">
{recentActivity.length === 0 ? (
<div className="px-6 py-8 text-center">
<InboxIcon className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-500"></p>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white overflow-hidden shadow rounded-lg">
<div className="p-5">
<div className="flex items-center">
<div className="flex-shrink-0">
<UserGroupIconSolid className="h-8 w-8 text-blue-600" />
</div>
) : (
recentActivity.map((activity) => {
const StatusIcon = getStatusIcon(activity.status);
const ActivityIcon = activity.icon;
return (
<div key={activity.id} className="px-6 py-4">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`p-2 rounded-full ${getStatusColor(activity.status)}`}>
<ActivityIcon className="h-5 w-5" />
</div>
</div>
<div className="ml-4 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900">
{activity.title}
</p>
<div className="flex items-center">
<StatusIcon className={`h-4 w-4 mr-1 ${
activity.status === 'success' ? 'text-green-500' :
activity.status === 'warning' ? 'text-yellow-500' :
activity.status === 'error' ? 'text-red-500' :
'text-blue-500'
}`} />
<span className="text-xs text-gray-500">
{activity.time}
</span>
</div>
</div>
<p className="text-sm text-gray-500">
{activity.description}
</p>
<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.totalUsers.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-green-600 font-medium">
{stats.activeUsers}
</span>
</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">
<UsersIconSolid className="h-8 w-8 text-green-600" />
</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.totalInterpreters.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-green-600 font-medium">
线
</span>
</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">
<DocumentTextIconSolid className="h-8 w-8 text-purple-600" />
</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.totalOrders.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-green-600 font-medium">
</span>
</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">
<PhoneIconSolid className="h-8 w-8 text-orange-600" />
</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.totalCalls.toLocaleString()}
</dd>
</dl>
</div>
</div>
</div>
<div className="bg-gray-50 px-5 py-3">
<div className="text-sm">
<span className="text-orange-600 font-medium">
{stats.activeCalls}
</span>
</div>
</div>
</div>
</div>
{/* Recent Activity */}
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<ChartBarIcon className="h-5 w-5 text-indigo-600 mr-2" />
</h3>
</div>
<div className="divide-y divide-gray-200">
{recentActivity.length === 0 ? (
<div className="px-6 py-8 text-center">
<InboxIcon className="mx-auto h-12 w-12 text-gray-400" />
<p className="mt-2 text-sm text-gray-500"></p>
</div>
) : (
recentActivity.map((activity) => {
const StatusIcon = getStatusIcon(activity.status);
const ActivityIcon = activity.icon;
return (
<div key={activity.id} className="px-6 py-4 hover:bg-gray-50 transition-colors duration-200">
<div className="flex items-center">
<div className="flex-shrink-0">
<div className={`p-2 rounded-full ${getStatusColor(activity.status)}`}>
<ActivityIcon className="h-5 w-5" />
</div>
</div>
<div className="ml-4 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-gray-900">
{activity.title}
</p>
<div className="flex items-center">
<StatusIcon className={`h-4 w-4 mr-1 ${
activity.status === 'success' ? 'text-green-500' :
activity.status === 'warning' ? 'text-yellow-500' :
activity.status === 'error' ? 'text-red-500' :
'text-blue-500'
}`} />
<span className="text-xs text-gray-500">
{activity.time}
</span>
</div>
</div>
<p className="text-sm text-gray-500 mt-1">
{activity.description}
</p>
</div>
</div>
);
})
)}
</div>
</div>
</div>
{/* Quick Actions */}
<div>
<div className="bg-white shadow rounded-lg">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900"></h3>
</div>
<div className="p-6">
<div className="space-y-3">
<button
onClick={() => router.push('/dashboard/users')}
className="w-full flex items-center justify-between p-3 text-left border border-gray-200 rounded-md hover:bg-gray-50"
>
<div className="flex items-center">
<UserGroupIcon className="h-5 w-5 text-blue-600 mr-3" />
<span className="text-sm font-medium text-gray-900"></span>
</div>
<ArrowUpIcon className="h-4 w-4 text-gray-400 transform rotate-45" />
</button>
<button
onClick={() => router.push('/dashboard/interpreters')}
className="w-full flex items-center justify-between p-3 text-left border border-gray-200 rounded-md hover:bg-gray-50"
>
<div className="flex items-center">
<UsersIcon className="h-5 w-5 text-green-600 mr-3" />
<span className="text-sm font-medium text-gray-900"></span>
</div>
<ArrowUpIcon className="h-4 w-4 text-gray-400 transform rotate-45" />
</button>
<button
onClick={() => router.push('/dashboard/orders')}
className="w-full flex items-center justify-between p-3 text-left border border-gray-200 rounded-md hover:bg-gray-50"
>
<div className="flex items-center">
<DocumentTextIcon className="h-5 w-5 text-purple-600 mr-3" />
<span className="text-sm font-medium text-gray-900"></span>
</div>
<ArrowUpIcon className="h-4 w-4 text-gray-400 transform rotate-45" />
</button>
<button
onClick={() => router.push('/dashboard/calls')}
className="w-full flex items-center justify-between p-3 text-left border border-gray-200 rounded-md hover:bg-gray-50"
>
<div className="flex items-center">
<PhoneIcon className="h-5 w-5 text-orange-600 mr-3" />
<span className="text-sm font-medium text-gray-900"></span>
</div>
<ArrowUpIcon className="h-4 w-4 text-gray-400 transform rotate-45" />
</button>
<button
onClick={() => router.push('/dashboard/invoices')}
className="w-full flex items-center justify-between p-3 text-left border border-gray-200 rounded-md hover:bg-gray-50"
>
<div className="flex items-center">
<CurrencyDollarIcon className="h-5 w-5 text-emerald-600 mr-3" />
<span className="text-sm font-medium text-gray-900"></span>
</div>
<ArrowUpIcon className="h-4 w-4 text-gray-400 transform rotate-45" />
</button>
<button
onClick={() => router.push('/dashboard/documents')}
className="w-full flex items-center justify-between p-3 text-left border border-gray-200 rounded-md hover:bg-gray-50"
>
<div className="flex items-center">
<DocumentDuplicateIcon className="h-5 w-5 text-indigo-600 mr-3" />
<span className="text-sm font-medium text-gray-900"></span>
</div>
<ArrowUpIcon className="h-4 w-4 text-gray-400 transform rotate-45" />
</button>
</div>
</div>
);
})
)}
</div>
</div>
</div>
</div>
</div>
</DashboardLayout>
</>
);
}
+275 -2
View File
@@ -23,7 +23,8 @@ import {
MapPinIcon,
CalendarIcon,
CurrencyDollarIcon,
AcademicCapIcon
AcademicCapIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
import { getDemoData } from '../../lib/demo-data';
import { formatTime } from '../../lib/utils';
@@ -74,6 +75,23 @@ export default function Interpreters() {
availability: ''
});
// 添加模态框状态
const [showAddInterpreterModal, setShowAddInterpreterModal] = useState(false);
const [newInterpreter, setNewInterpreter] = useState({
name: '',
email: '',
phone: '',
languages: '',
specialties: '',
experience_years: 0,
hourly_rate: 0,
location: '',
bio: '',
status: 'active' as 'active' | 'inactive' | 'busy' | 'offline',
availability: 'available' as 'available' | 'busy' | 'offline'
});
const [isSubmitting, setIsSubmitting] = useState(false);
const pageSize = 10;
useEffect(() => {
@@ -395,6 +413,258 @@ export default function Interpreters() {
);
};
// 添加翻译员提交函数
const handleAddInterpreter = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
// 创建新翻译员对象
const newInterpreterData: Interpreter = {
id: Date.now().toString(),
...newInterpreter,
languages: newInterpreter.languages.split(',').map(lang => lang.trim()),
specialties: newInterpreter.specialties.split(',').map(spec => spec.trim()),
rating: 5.0,
total_calls: 0,
total_hours: 0,
certifications: [],
joined_at: new Date().toISOString(),
last_active: new Date().toISOString()
};
// 添加到翻译员列表
setInterpreters(prev => [newInterpreterData, ...prev]);
// 重置表单
setNewInterpreter({
name: '',
email: '',
phone: '',
languages: '',
specialties: '',
experience_years: 0,
hourly_rate: 0,
location: '',
bio: '',
status: 'active',
availability: 'available'
});
// 关闭模态框
setShowAddInterpreterModal(false);
// 可以添加成功提示
alert('翻译员添加成功!');
} catch (error) {
console.error('添加翻译员失败:', error);
alert('添加翻译员失败,请重试');
} finally {
setIsSubmitting(false);
}
};
// 添加翻译员模态框组件
const AddInterpreterModal = () => (
<div className={`fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 ${showAddInterpreterModal ? 'block' : 'hidden'}`}>
<div className="relative top-10 mx-auto p-5 border w-[600px] shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900"></h3>
<button
onClick={() => setShowAddInterpreterModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleAddInterpreter} className="space-y-4 max-h-[70vh] overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.name}
onChange={(e) => setNewInterpreter({...newInterpreter, name: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.email}
onChange={(e) => setNewInterpreter({...newInterpreter, email: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="tel"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.phone}
onChange={(e) => setNewInterpreter({...newInterpreter, phone: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.location}
onChange={(e) => setNewInterpreter({...newInterpreter, location: e.target.value})}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
<span className="text-gray-500 text-xs ml-1">()</span>
</label>
<input
type="text"
required
placeholder="例如:英语,中文,法语"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.languages}
onChange={(e) => setNewInterpreter({...newInterpreter, languages: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-gray-500 text-xs ml-1">()</span>
</label>
<input
type="text"
placeholder="例如:医疗,法律,商务"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.specialties}
onChange={(e) => setNewInterpreter({...newInterpreter, specialties: e.target.value})}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="number"
required
min="0"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.experience_years}
onChange={(e) => setNewInterpreter({...newInterpreter, experience_years: parseInt(e.target.value) || 0})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
(¥) <span className="text-red-500">*</span>
</label>
<input
type="number"
required
min="0"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.hourly_rate}
onChange={(e) => setNewInterpreter({...newInterpreter, hourly_rate: parseFloat(e.target.value) || 0})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.status}
onChange={(e) => setNewInterpreter({...newInterpreter, status: e.target.value as 'active' | 'inactive' | 'busy' | 'offline'})}
>
<option value="active"></option>
<option value="inactive"></option>
<option value="busy"></option>
<option value="offline">线</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.availability}
onChange={(e) => setNewInterpreter({...newInterpreter, availability: e.target.value as 'available' | 'busy' | 'offline'})}
>
<option value="available"></option>
<option value="busy"></option>
<option value="offline">线</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newInterpreter.bio}
onChange={(e) => setNewInterpreter({...newInterpreter, bio: e.target.value})}
placeholder="请简要介绍翻译员的背景和专业经验..."
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={() => setShowAddInterpreterModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? '添加中...' : '添加翻译员'}
</button>
</div>
</form>
</div>
</div>
);
return (
<>
<Head>
@@ -420,7 +690,7 @@ export default function Interpreters() {
</button>
<button
onClick={() => router.push('/dashboard/interpreters/new')}
onClick={() => setShowAddInterpreterModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<UserPlusIcon className="h-4 w-4 mr-2" />
@@ -784,6 +1054,9 @@ export default function Interpreters() {
)}
</div>
</div>
{/* 添加翻译员模态框 */}
<AddInterpreterModal />
</DashboardLayout>
</>
);
+240 -2
View File
@@ -23,7 +23,8 @@ import {
DocumentTextIcon,
PlayIcon,
PauseIcon,
StopIcon
StopIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
import { getDemoData } from '../../lib/demo-data';
import { formatTime } from '../../lib/utils';
@@ -71,6 +72,21 @@ export default function Orders() {
date_range: ''
});
// 添加模态框状态
const [showCreateOrderModal, setShowCreateOrderModal] = useState(false);
const [newOrder, setNewOrder] = useState({
user_name: '',
user_email: '',
interpreter_name: '',
language_pair: '',
service_type: 'audio' as 'audio' | 'video' | 'onsite',
start_time: '',
duration: 60,
amount: 0,
notes: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const pageSize = 10;
useEffect(() => {
@@ -396,6 +412,225 @@ export default function Orders() {
return `${mins}分钟`;
};
// 创建订单提交函数
const handleCreateOrder = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
// 创建新订单对象
const newOrderData: Order = {
id: Date.now().toString(),
order_number: `ORD-${Date.now()}`,
...newOrder,
status: 'pending',
payment_status: 'pending',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
// 添加到订单列表
setOrders(prev => [newOrderData, ...prev]);
// 重置表单
setNewOrder({
user_name: '',
user_email: '',
interpreter_name: '',
language_pair: '',
service_type: 'audio',
start_time: '',
duration: 60,
amount: 0,
notes: ''
});
// 关闭模态框
setShowCreateOrderModal(false);
// 可以添加成功提示
alert('订单创建成功!');
} catch (error) {
console.error('创建订单失败:', error);
alert('创建订单失败,请重试');
} finally {
setIsSubmitting(false);
}
};
// 创建订单模态框组件
const CreateOrderModal = () => (
<div className={`fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 ${showCreateOrderModal ? 'block' : 'hidden'}`}>
<div className="relative top-10 mx-auto p-5 border w-[600px] shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900"></h3>
<button
onClick={() => setShowCreateOrderModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleCreateOrder} className="space-y-4 max-h-[70vh] overflow-y-auto">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.user_name}
onChange={(e) => setNewOrder({...newOrder, user_name: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.user_email}
onChange={(e) => setNewOrder({...newOrder, user_email: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.interpreter_name}
onChange={(e) => setNewOrder({...newOrder, interpreter_name: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
required
placeholder="例如:中文-英文"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.language_pair}
onChange={(e) => setNewOrder({...newOrder, language_pair: e.target.value})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<select
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.service_type}
onChange={(e) => setNewOrder({...newOrder, service_type: e.target.value as 'audio' | 'video' | 'onsite'})}
>
<option value="audio"></option>
<option value="video"></option>
<option value="onsite"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
() <span className="text-red-500">*</span>
</label>
<input
type="number"
required
min="15"
step="15"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.duration}
onChange={(e) => setNewOrder({...newOrder, duration: parseInt(e.target.value) || 60})}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="datetime-local"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.start_time}
onChange={(e) => setNewOrder({...newOrder, start_time: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
(¥) <span className="text-red-500">*</span>
</label>
<input
type="number"
required
min="0"
step="0.01"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.amount}
onChange={(e) => setNewOrder({...newOrder, amount: parseFloat(e.target.value) || 0})}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<textarea
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newOrder.notes}
onChange={(e) => setNewOrder({...newOrder, notes: e.target.value})}
placeholder="请输入订单相关的备注信息..."
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={() => setShowCreateOrderModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? '创建中...' : '创建订单'}
</button>
</div>
</form>
</div>
</div>
);
return (
<>
<Head>
@@ -421,7 +656,7 @@ export default function Orders() {
</button>
<button
onClick={() => router.push('/dashboard/orders/new')}
onClick={() => setShowCreateOrderModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<PlusIcon className="h-4 w-4 mr-2" />
@@ -787,6 +1022,9 @@ export default function Orders() {
)}
</div>
</div>
{/* 创建订单模态框 */}
<CreateOrderModal />
</DashboardLayout>
</>
);
+182 -2
View File
@@ -18,7 +18,8 @@ import {
XCircleIcon,
ExclamationTriangleIcon,
ArrowDownTrayIcon,
FunnelIcon
FunnelIcon,
XMarkIcon
} from '@heroicons/react/24/outline';
import { getDemoData } from '../../lib/demo-data';
import { formatTime } from '../../lib/utils';
@@ -59,6 +60,18 @@ export default function Users() {
company: ''
});
// 添加模态框状态
const [showAddUserModal, setShowAddUserModal] = useState(false);
const [newUser, setNewUser] = useState({
name: '',
email: '',
phone: '',
company: '',
role: 'user' as 'admin' | 'user' | 'interpreter',
status: 'active' as 'active' | 'inactive' | 'pending'
});
const [isSubmitting, setIsSubmitting] = useState(false);
const pageSize = 10;
useEffect(() => {
@@ -318,6 +331,170 @@ export default function Users() {
}
};
// 添加用户提交函数
const handleAddUser = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
try {
// 模拟API调用
await new Promise(resolve => setTimeout(resolve, 1000));
// 创建新用户对象
const newUserData: User = {
id: Date.now().toString(),
...newUser,
created_at: new Date().toISOString(),
last_login: '从未登录',
total_calls: 0,
total_spent: 0
};
// 添加到用户列表
setUsers(prev => [newUserData, ...prev]);
// 重置表单
setNewUser({
name: '',
email: '',
phone: '',
company: '',
role: 'user',
status: 'active'
});
// 关闭模态框
setShowAddUserModal(false);
// 可以添加成功提示
alert('用户添加成功!');
} catch (error) {
console.error('添加用户失败:', error);
alert('添加用户失败,请重试');
} finally {
setIsSubmitting(false);
}
};
// 添加用户模态框组件
const AddUserModal = () => (
<div className={`fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50 ${showAddUserModal ? 'block' : 'hidden'}`}>
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900"></h3>
<button
onClick={() => setShowAddUserModal(false)}
className="text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleAddUser} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="text"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newUser.name}
onChange={(e) => setNewUser({...newUser, name: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newUser.email}
onChange={(e) => setNewUser({...newUser, email: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<input
type="tel"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newUser.phone}
onChange={(e) => setNewUser({...newUser, phone: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<input
type="text"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newUser.company}
onChange={(e) => setNewUser({...newUser, company: e.target.value})}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
<span className="text-red-500">*</span>
</label>
<select
required
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newUser.role}
onChange={(e) => setNewUser({...newUser, role: e.target.value as 'admin' | 'user' | 'interpreter'})}
>
<option value="user"></option>
<option value="admin"></option>
<option value="interpreter"></option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500"
value={newUser.status}
onChange={(e) => setNewUser({...newUser, status: e.target.value as 'active' | 'inactive' | 'pending'})}
>
<option value="active"></option>
<option value="inactive"></option>
<option value="pending"></option>
</select>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={() => setShowAddUserModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md text-sm font-medium text-gray-700 hover:bg-gray-50"
>
</button>
<button
type="submit"
disabled={isSubmitting}
className="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? '添加中...' : '添加用户'}
</button>
</div>
</form>
</div>
</div>
);
return (
<>
<Head>
@@ -343,7 +520,7 @@ export default function Users() {
</button>
<button
onClick={() => router.push('/dashboard/users/new')}
onClick={() => setShowAddUserModal(true)}
className="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700"
>
<PlusIcon className="h-4 w-4 mr-2" />
@@ -656,6 +833,9 @@ export default function Users() {
</div>
</div>
</DashboardLayout>
{/* 添加用户模态框 */}
<AddUserModal />
</>
);
}