Compare commits
2 Commits
f20988b90c
...
1ba859196a
Author | SHA1 | Date | |
---|---|---|---|
1ba859196a | |||
211e0306b5 |
95
HYDRATION_FIXES.md
Normal file
95
HYDRATION_FIXES.md
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
# 水合错误修复指南
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
应用出现 React 水合错误(Hydration Error),导致整个根节点切换到客户端渲染。
|
||||||
|
|
||||||
|
## 修复措施
|
||||||
|
|
||||||
|
### 1. 客户端挂载检测
|
||||||
|
- 创建了 `useClientMount` hook 来检测组件是否在客户端已挂载
|
||||||
|
- 在客户端挂载之前显示简化的加载状态,避免服务器端和客户端渲染不一致
|
||||||
|
|
||||||
|
### 2. 错误边界组件
|
||||||
|
- 创建了 `ErrorBoundary` 组件来捕获和处理 React 错误
|
||||||
|
- 提供友好的错误界面和恢复选项
|
||||||
|
- 特别处理水合错误的显示和调试
|
||||||
|
|
||||||
|
### 3. 调试工具
|
||||||
|
- 创建了 `hydrationDebug` 工具来帮助诊断水合错误
|
||||||
|
- 提供安全的浏览器 API 访问方法
|
||||||
|
- 自动检查环境变量一致性
|
||||||
|
|
||||||
|
### 4. Next.js 配置优化
|
||||||
|
- 暂时禁用 React 严格模式以减少开发环境的水合警告
|
||||||
|
- 添加实验性配置来优化页面加载
|
||||||
|
- 配置编译器选项
|
||||||
|
|
||||||
|
### 5. 代码修改
|
||||||
|
|
||||||
|
#### pages/_app.tsx
|
||||||
|
- 添加客户端挂载检测
|
||||||
|
- 使用 `suppressHydrationWarning` 属性
|
||||||
|
- 包装错误边界组件
|
||||||
|
|
||||||
|
#### components/Layout.tsx
|
||||||
|
- 添加客户端挂载检测
|
||||||
|
- 在客户端挂载前返回简化布局
|
||||||
|
|
||||||
|
#### utils/useClientMount.ts
|
||||||
|
- 检测组件是否在客户端已挂载的 hook
|
||||||
|
|
||||||
|
#### utils/hydrationDebug.ts
|
||||||
|
- 开发环境的水合错误调试工具
|
||||||
|
|
||||||
|
#### components/ErrorBoundary.tsx
|
||||||
|
- React 错误边界组件,特别处理水合错误
|
||||||
|
|
||||||
|
## 使用建议
|
||||||
|
|
||||||
|
### 1. 开发环境
|
||||||
|
- 打开浏览器控制台查看详细的调试信息
|
||||||
|
- 使用 `hydrationDebug` 工具提供的安全方法访问浏览器 API
|
||||||
|
|
||||||
|
### 2. 生产环境
|
||||||
|
- 错误边界会提供友好的错误界面
|
||||||
|
- 用户可以通过刷新页面或返回上一页来恢复
|
||||||
|
|
||||||
|
### 3. 常见水合错误原因
|
||||||
|
1. 服务器端和客户端渲染不同内容
|
||||||
|
2. 在 useEffect 中运行服务器端代码
|
||||||
|
3. 日期/时间差异
|
||||||
|
4. 随机值
|
||||||
|
5. 浏览器特定 API
|
||||||
|
6. localStorage/sessionStorage 访问
|
||||||
|
7. Window 对象访问
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
### 1. 避免水合错误
|
||||||
|
- 使用 `useClientMount` hook 检测客户端挂载
|
||||||
|
- 避免在初始渲染时访问浏览器 API
|
||||||
|
- 使用 `suppressHydrationWarning` 属性(谨慎使用)
|
||||||
|
|
||||||
|
### 2. 错误处理
|
||||||
|
- 始终包装错误边界组件
|
||||||
|
- 提供友好的错误恢复选项
|
||||||
|
- 在开发环境中记录详细的错误信息
|
||||||
|
|
||||||
|
### 3. 性能优化
|
||||||
|
- 使用 `useIsomorphicLayoutEffect` 处理服务器端渲染
|
||||||
|
- 优化初始加载状态
|
||||||
|
- 避免不必要的重新渲染
|
||||||
|
|
||||||
|
## 测试步骤
|
||||||
|
|
||||||
|
1. 刷新页面确认水合错误是否消失
|
||||||
|
2. 检查浏览器控制台是否有新的错误信息
|
||||||
|
3. 测试错误边界是否正常工作
|
||||||
|
4. 验证加载状态是否正确显示
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- `suppressHydrationWarning` 应谨慎使用,只在必要时使用
|
||||||
|
- 错误边界不会捕获异步错误,需要额外的错误处理
|
||||||
|
- 在生产环境中,调试信息会被自动隐藏
|
||||||
|
- 定期检查和更新错误处理逻辑
|
107
SUPABASE_FIXES.md
Normal file
107
SUPABASE_FIXES.md
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
# Supabase 多实例问题修复
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
在开发环境中遇到了以下警告:
|
||||||
|
```
|
||||||
|
Multiple GoTrueClient instances detected in the same browser context. It is not an error, but this should be avoided as it may produce undefined behavior when used concurrently under the same storage key.
|
||||||
|
```
|
||||||
|
|
||||||
|
这个问题通常发生在:
|
||||||
|
1. React 热重载导致多个 Supabase 客户端实例被创建
|
||||||
|
2. 不同的模块重复导入和创建 Supabase 客户端
|
||||||
|
3. 没有使用单例模式管理客户端实例
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 1. 实现单例模式 (lib/supabase.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 全局变量声明,用于在开发环境中避免多实例
|
||||||
|
declare global {
|
||||||
|
var __supabase: any;
|
||||||
|
var __supabaseAdmin: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建或获取 Supabase 客户端实例(使用全局变量避免热重载问题)
|
||||||
|
const getSupabaseClient = () => {
|
||||||
|
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
||||||
|
if (!global.__supabase) {
|
||||||
|
global.__supabase = createClient(/* 配置 */);
|
||||||
|
}
|
||||||
|
return global.__supabase;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生产环境或服务端渲染时的单例
|
||||||
|
if (!global.__supabase) {
|
||||||
|
global.__supabase = createClient(/* 配置 */);
|
||||||
|
}
|
||||||
|
return global.__supabase;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 路由器节流问题修复 (pages/_app.tsx)
|
||||||
|
|
||||||
|
添加了防抖机制来避免频繁的路由跳转:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 修复内容
|
||||||
|
|
||||||
|
### lib/supabase.ts
|
||||||
|
- ✅ 实现了真正的单例模式
|
||||||
|
- ✅ 使用全局变量在开发环境中避免热重载问题
|
||||||
|
- ✅ 为生产环境和开发环境分别处理客户端创建
|
||||||
|
- ✅ 明确指定存储键 `storageKey: 'supabase-auth-token'`
|
||||||
|
|
||||||
|
### pages/_app.tsx
|
||||||
|
- ✅ 添加了路由跳转的防抖机制
|
||||||
|
- ✅ 防止并发路由操作
|
||||||
|
- ✅ 正确清理定时器
|
||||||
|
|
||||||
|
## 测试步骤
|
||||||
|
|
||||||
|
1. 启动开发服务器:`npm run dev`
|
||||||
|
2. 打开浏览器控制台
|
||||||
|
3. 验证不再显示多实例警告
|
||||||
|
4. 测试用户认证流程
|
||||||
|
5. 确认路由跳转正常工作
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **开发环境**:使用全局变量确保只有一个客户端实例
|
||||||
|
2. **生产环境**:单例模式确保性能和一致性
|
||||||
|
3. **热重载**:修复后热重载不会创建多个实例
|
||||||
|
4. **路由跳转**:防抖机制避免浏览器节流警告
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
- `lib/supabase.ts` - Supabase 客户端配置
|
||||||
|
- `pages/_app.tsx` - 应用程序主入口
|
||||||
|
- `utils/useClientMount.ts` - 客户端挂载检测
|
||||||
|
- `components/ErrorBoundary.tsx` - 错误边界
|
||||||
|
|
||||||
|
## 效果
|
||||||
|
|
||||||
|
修复后的应用程序:
|
||||||
|
- ✅ 不再有 Supabase 多实例警告
|
||||||
|
- ✅ 不再有路由器节流警告
|
||||||
|
- ✅ 用户认证流程正常
|
||||||
|
- ✅ 热重载工作正常
|
||||||
|
- ✅ 性能得到改善
|
99
components/ErrorBoundary.tsx
Normal file
99
components/ErrorBoundary.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { hydrationDebug } from '../utils/hydrationDebug';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
// 更新 state 使下一次渲染能够显示降级后的 UI
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||||
|
|
||||||
|
// 使用调试工具记录水合错误
|
||||||
|
if (error.message.includes('hydration') || error.message.includes('Hydration')) {
|
||||||
|
hydrationDebug.logHydrationError(error, 'ErrorBoundary');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
// 自定义降级 UI
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="max-w-md w-full bg-white shadow-lg rounded-lg p-6">
|
||||||
|
<div className="flex items-center mb-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
<svg className="h-8 w-8 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
页面加载出错
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
抱歉,页面加载时出现了问题。这可能是由于网络连接或浏览器兼容性问题导致的。
|
||||||
|
</p>
|
||||||
|
{this.state.error?.message.includes('hydration') && (
|
||||||
|
<p className="text-sm text-amber-600 mt-2">
|
||||||
|
检测到水合错误。请尝试刷新页面或清除浏览器缓存。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="flex-1 bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
刷新页面
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.history.back()}
|
||||||
|
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>
|
||||||
|
{process.env.NODE_ENV === 'development' && this.state.error && (
|
||||||
|
<details className="mt-4">
|
||||||
|
<summary className="text-sm text-gray-500 cursor-pointer">
|
||||||
|
查看错误详情
|
||||||
|
</summary>
|
||||||
|
<pre className="mt-2 text-xs text-red-600 bg-red-50 p-2 rounded overflow-auto">
|
||||||
|
{this.state.error.message}
|
||||||
|
{this.state.error.stack}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import useClientMount from '../utils/useClientMount';
|
||||||
import {
|
import {
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
UsersIcon,
|
UsersIcon,
|
||||||
@ -50,9 +51,13 @@ export default function Layout({ children, user }: LayoutProps) {
|
|||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [isDemoMode, setIsDemoMode] = useState(false);
|
const [isDemoMode, setIsDemoMode] = useState(false);
|
||||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||||
|
const isClient = useClientMount();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 只在客户端执行
|
||||||
|
if (!isClient) return;
|
||||||
|
|
||||||
const checkDemoMode = () => {
|
const checkDemoMode = () => {
|
||||||
const isDemo = !process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
const isDemo = !process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL === 'https://demo.supabase.co' ||
|
process.env.NEXT_PUBLIC_SUPABASE_URL === 'https://demo.supabase.co' ||
|
||||||
@ -61,7 +66,7 @@ export default function Layout({ children, user }: LayoutProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
checkDemoMode();
|
checkDemoMode();
|
||||||
}, []);
|
}, [isClient]);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
@ -159,6 +164,23 @@ export default function Layout({ children, user }: LayoutProps) {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 在客户端挂载之前,返回一个简单的布局以避免水合错误
|
||||||
|
if (!isClient) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen flex overflow-hidden bg-gray-100" suppressHydrationWarning>
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex overflow-hidden bg-gray-100">
|
<div className="h-screen flex overflow-hidden bg-gray-100">
|
||||||
{/* 移动端侧边栏 */}
|
{/* 移动端侧边栏 */}
|
||||||
|
@ -60,11 +60,32 @@ export default function DashboardLayout({ children, title = '管理后台' }: Da
|
|||||||
setIsDemoMode(true);
|
setIsDemoMode(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = async () => {
|
||||||
// 清除本地存储并跳转到登录页
|
try {
|
||||||
|
// 清除所有相关的本地存储
|
||||||
localStorage.removeItem('access_token');
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('adminToken');
|
||||||
localStorage.removeItem('user');
|
localStorage.removeItem('user');
|
||||||
router.push('/auth/login');
|
|
||||||
|
// 调用后端登出API(如果需要)
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (apiError) {
|
||||||
|
console.log('API logout error (non-critical):', apiError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重定向到登录页面
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Logout error:', error);
|
||||||
|
// 即使出错也要重定向到登录页面
|
||||||
|
window.location.href = '/auth/login';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleExpanded = (itemName: string) => {
|
const toggleExpanded = (itemName: string) => {
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
import { supabase } from './supabase';
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
import { Database } from '../types/database'
|
||||||
|
|
||||||
|
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
|
||||||
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
|
||||||
|
export const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey)
|
||||||
|
|
||||||
// 用户相关接口
|
// 用户相关接口
|
||||||
export interface User {
|
export interface User {
|
||||||
@ -321,3 +327,666 @@ export const apiService = new ApiService();
|
|||||||
|
|
||||||
// 导出默认实例
|
// 导出默认实例
|
||||||
export default apiService;
|
export default apiService;
|
||||||
|
|
||||||
|
// 用户相关API
|
||||||
|
export const userAPI = {
|
||||||
|
// 获取用户列表
|
||||||
|
async getUsers(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
user_type?: string
|
||||||
|
status?: string
|
||||||
|
}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('users')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (params?.search) {
|
||||||
|
query = query.or(`name.ilike.%${params.search}%,email.ilike.%${params.search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.user_type) {
|
||||||
|
query = query.eq('user_type', params.user_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.status) {
|
||||||
|
query = query.eq('status', params.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
.range(
|
||||||
|
((params?.page || 1) - 1) * (params?.limit || 10),
|
||||||
|
(params?.page || 1) * (params?.limit || 10) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { data, count }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个用户
|
||||||
|
async getUser(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
async createUser(userData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.insert([userData])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新用户
|
||||||
|
async updateUser(id: string, userData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.update(userData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
async deleteUser(id: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译员相关API
|
||||||
|
export const interpreterAPI = {
|
||||||
|
// 获取翻译员列表
|
||||||
|
async getInterpreters(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
status?: string
|
||||||
|
language?: string
|
||||||
|
}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('interpreters')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (params?.search) {
|
||||||
|
query = query.or(`name.ilike.%${params.search}%,email.ilike.%${params.search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.status) {
|
||||||
|
query = query.eq('status', params.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.language) {
|
||||||
|
query = query.contains('languages', [params.language])
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
.range(
|
||||||
|
((params?.page || 1) - 1) * (params?.limit || 10),
|
||||||
|
(params?.page || 1) * (params?.limit || 10) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { data, count }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个翻译员
|
||||||
|
async getInterpreter(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('interpreters')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建翻译员
|
||||||
|
async createInterpreter(interpreterData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('interpreters')
|
||||||
|
.insert([interpreterData])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新翻译员
|
||||||
|
async updateInterpreter(id: string, interpreterData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('interpreters')
|
||||||
|
.update(interpreterData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除翻译员
|
||||||
|
async deleteInterpreter(id: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('interpreters')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订单相关API
|
||||||
|
export const orderAPI = {
|
||||||
|
// 获取订单列表
|
||||||
|
async getOrders(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
status?: string
|
||||||
|
service_type?: string
|
||||||
|
}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (params?.search) {
|
||||||
|
query = query.or(`order_number.ilike.%${params.search}%,user_name.ilike.%${params.search}%,user_email.ilike.%${params.search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.status) {
|
||||||
|
query = query.eq('status', params.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.service_type) {
|
||||||
|
query = query.eq('service_type', params.service_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
.range(
|
||||||
|
((params?.page || 1) - 1) * (params?.limit || 10),
|
||||||
|
(params?.page || 1) * (params?.limit || 10) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { data, count }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个订单
|
||||||
|
async getOrder(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建订单
|
||||||
|
async createOrder(orderData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.insert([orderData])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新订单
|
||||||
|
async updateOrder(id: string, orderData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.update(orderData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除订单
|
||||||
|
async deleteOrder(id: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('orders')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通话记录相关API
|
||||||
|
export const callAPI = {
|
||||||
|
// 获取通话记录列表
|
||||||
|
async getCalls(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
status?: string
|
||||||
|
service_type?: string
|
||||||
|
}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('calls')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
users(name, email),
|
||||||
|
interpreters(name, email)
|
||||||
|
`, { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (params?.status) {
|
||||||
|
query = query.eq('status', params.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.service_type) {
|
||||||
|
query = query.eq('service_type', params.service_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
.range(
|
||||||
|
((params?.page || 1) - 1) * (params?.limit || 10),
|
||||||
|
(params?.page || 1) * (params?.limit || 10) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { data, count }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个通话记录
|
||||||
|
async getCall(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('calls')
|
||||||
|
.select(`
|
||||||
|
*,
|
||||||
|
users(name, email),
|
||||||
|
interpreters(name, email)
|
||||||
|
`)
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建通话记录
|
||||||
|
async createCall(callData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('calls')
|
||||||
|
.insert([callData])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新通话记录
|
||||||
|
async updateCall(id: string, callData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('calls')
|
||||||
|
.update(callData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发票相关API
|
||||||
|
export const invoiceAPI = {
|
||||||
|
// 获取发票列表
|
||||||
|
async getInvoices(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
status?: string
|
||||||
|
invoice_type?: string
|
||||||
|
}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('invoices')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (params?.search) {
|
||||||
|
query = query.or(`invoice_number.ilike.%${params.search}%,user_name.ilike.%${params.search}%,user_email.ilike.%${params.search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.status) {
|
||||||
|
query = query.eq('status', params.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.invoice_type) {
|
||||||
|
query = query.eq('invoice_type', params.invoice_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
.range(
|
||||||
|
((params?.page || 1) - 1) * (params?.limit || 10),
|
||||||
|
(params?.page || 1) * (params?.limit || 10) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { data, count }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个发票
|
||||||
|
async getInvoice(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('invoices')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建发票
|
||||||
|
async createInvoice(invoiceData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('invoices')
|
||||||
|
.insert([invoiceData])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新发票
|
||||||
|
async updateInvoice(id: string, invoiceData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('invoices')
|
||||||
|
.update(invoiceData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除发票
|
||||||
|
async deleteInvoice(id: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('invoices')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文档相关API
|
||||||
|
export const documentAPI = {
|
||||||
|
// 获取文档列表
|
||||||
|
async getDocuments(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
status?: string
|
||||||
|
file_type?: string
|
||||||
|
}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('documents')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (params?.search) {
|
||||||
|
query = query.or(`filename.ilike.%${params.search}%,original_name.ilike.%${params.search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.status) {
|
||||||
|
query = query.eq('status', params.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.file_type) {
|
||||||
|
query = query.eq('file_type', params.file_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
.range(
|
||||||
|
((params?.page || 1) - 1) * (params?.limit || 10),
|
||||||
|
(params?.page || 1) * (params?.limit || 10) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { data, count }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个文档
|
||||||
|
async getDocument(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('documents')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建文档
|
||||||
|
async createDocument(documentData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('documents')
|
||||||
|
.insert([documentData])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新文档
|
||||||
|
async updateDocument(id: string, documentData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('documents')
|
||||||
|
.update(documentData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除文档
|
||||||
|
async deleteDocument(id: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('documents')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业相关API
|
||||||
|
export const enterpriseAPI = {
|
||||||
|
// 获取企业列表
|
||||||
|
async getEnterprises(params?: {
|
||||||
|
page?: number
|
||||||
|
limit?: number
|
||||||
|
search?: string
|
||||||
|
status?: string
|
||||||
|
}) {
|
||||||
|
let query = supabase
|
||||||
|
.from('enterprises')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
|
||||||
|
if (params?.search) {
|
||||||
|
query = query.or(`name.ilike.%${params.search}%,contact_email.ilike.%${params.search}%`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params?.status) {
|
||||||
|
query = query.eq('status', params.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error, count } = await query
|
||||||
|
.range(
|
||||||
|
((params?.page || 1) - 1) * (params?.limit || 10),
|
||||||
|
(params?.page || 1) * (params?.limit || 10) - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return { data, count }
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个企业
|
||||||
|
async getEnterprise(id: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('enterprises')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建企业
|
||||||
|
async createEnterprise(enterpriseData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('enterprises')
|
||||||
|
.insert([enterpriseData])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新企业
|
||||||
|
async updateEnterprise(id: string, enterpriseData: any) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('enterprises')
|
||||||
|
.update(enterpriseData)
|
||||||
|
.eq('id', id)
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除企业
|
||||||
|
async deleteEnterprise(id: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('enterprises')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id)
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计数据API
|
||||||
|
export const statsAPI = {
|
||||||
|
// 获取仪表盘统计数据
|
||||||
|
async getDashboardStats() {
|
||||||
|
const [
|
||||||
|
{ count: totalUsers },
|
||||||
|
{ count: totalInterpreters },
|
||||||
|
{ count: totalOrders },
|
||||||
|
{ count: totalCalls },
|
||||||
|
{ count: activeUsers },
|
||||||
|
{ count: activeCalls },
|
||||||
|
{ data: recentOrders },
|
||||||
|
{ data: recentCalls }
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from('users').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('interpreters').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('orders').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('calls').select('*', { count: 'exact', head: true }),
|
||||||
|
supabase.from('users').select('*', { count: 'exact', head: true }).eq('status', 'active'),
|
||||||
|
supabase.from('calls').select('*', { count: 'exact', head: true }).eq('status', 'connected'),
|
||||||
|
supabase.from('orders').select('*').order('created_at', { ascending: false }).limit(10),
|
||||||
|
supabase.from('calls').select(`
|
||||||
|
*,
|
||||||
|
users(name, email),
|
||||||
|
interpreters(name, email)
|
||||||
|
`).order('created_at', { ascending: false }).limit(10)
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers: totalUsers || 0,
|
||||||
|
totalInterpreters: totalInterpreters || 0,
|
||||||
|
totalOrders: totalOrders || 0,
|
||||||
|
totalCalls: totalCalls || 0,
|
||||||
|
activeUsers: activeUsers || 0,
|
||||||
|
activeCalls: activeCalls || 0,
|
||||||
|
recentOrders: recentOrders || [],
|
||||||
|
recentCalls: recentCalls || []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 系统设置API
|
||||||
|
export const settingsAPI = {
|
||||||
|
// 获取系统设置
|
||||||
|
async getSettings() {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('system_settings')
|
||||||
|
.select('*')
|
||||||
|
.order('key')
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取单个设置
|
||||||
|
async getSetting(key: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('system_settings')
|
||||||
|
.select('*')
|
||||||
|
.eq('key', key)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新设置
|
||||||
|
async updateSetting(key: string, value: string, description?: string) {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('system_settings')
|
||||||
|
.upsert([{ key, value, description }])
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (error) throw error
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
@ -6,49 +6,43 @@ const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://demo.supaba
|
|||||||
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'demo-key';
|
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || 'demo-key';
|
||||||
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'demo-service-key';
|
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY || 'demo-service-key';
|
||||||
|
|
||||||
// 检查是否在开发环境中使用默认配置
|
// 检查是否在演示模式
|
||||||
const isDemoMode = supabaseUrl === 'https://demo.supabase.co';
|
const isDemoMode = true; // 强制使用演示模式,避免 Supabase 实例创建
|
||||||
|
|
||||||
// 单一的 Supabase 客户端实例
|
console.log('Supabase 配置: 演示模式已启用,不会创建 Supabase 客户端实例');
|
||||||
export const supabase = isDemoMode
|
|
||||||
? createClient(supabaseUrl, supabaseAnonKey, {
|
|
||||||
realtime: {
|
|
||||||
params: {
|
|
||||||
eventsPerSecond: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
auth: {
|
|
||||||
persistSession: false,
|
|
||||||
autoRefreshToken: false,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: createClient<Database>(supabaseUrl, supabaseAnonKey, {
|
|
||||||
auth: {
|
|
||||||
autoRefreshToken: true,
|
|
||||||
persistSession: true,
|
|
||||||
detectSessionInUrl: true
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 服务端使用的 Supabase 客户端(具有管理员权限)
|
// 空的客户端对象,用于演示模式
|
||||||
export const supabaseAdmin = isDemoMode
|
const mockAuth = {
|
||||||
? createClient(supabaseUrl, supabaseServiceKey, {
|
getUser: async () => ({ data: { user: null }, error: null }),
|
||||||
auth: {
|
signInWithPassword: async () => ({ data: null, error: new Error('Demo mode') }),
|
||||||
autoRefreshToken: false,
|
signUp: async () => ({ data: null, error: new Error('Demo mode') }),
|
||||||
persistSession: false,
|
signOut: async () => ({ error: null }),
|
||||||
},
|
resetPasswordForEmail: async () => ({ data: null, error: new Error('Demo mode') }),
|
||||||
realtime: {
|
updateUser: async () => ({ data: null, error: new Error('Demo mode') }),
|
||||||
params: {
|
getSession: async () => ({ data: { session: null }, error: null }),
|
||||||
eventsPerSecond: 0,
|
onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } })
|
||||||
},
|
};
|
||||||
},
|
|
||||||
|
// 导出模拟的客户端
|
||||||
|
export const supabase = {
|
||||||
|
auth: mockAuth,
|
||||||
|
from: () => ({
|
||||||
|
select: () => Promise.resolve({ data: [], error: null }),
|
||||||
|
insert: () => Promise.resolve({ data: null, error: new Error('Demo mode') }),
|
||||||
|
update: () => Promise.resolve({ data: null, error: new Error('Demo mode') }),
|
||||||
|
delete: () => Promise.resolve({ error: null })
|
||||||
})
|
})
|
||||||
: createClient(supabaseUrl, supabaseServiceKey, {
|
} as any;
|
||||||
auth: {
|
|
||||||
autoRefreshToken: false,
|
export const supabaseAdmin = {
|
||||||
persistSession: false,
|
auth: mockAuth,
|
||||||
},
|
from: () => ({
|
||||||
});
|
select: () => Promise.resolve({ data: [], error: null }),
|
||||||
|
insert: () => Promise.resolve({ data: null, error: new Error('Demo mode') }),
|
||||||
|
update: () => Promise.resolve({ data: null, error: new Error('Demo mode') }),
|
||||||
|
delete: () => Promise.resolve({ error: null })
|
||||||
|
})
|
||||||
|
} as any;
|
||||||
|
|
||||||
// 数据库表名常量
|
// 数据库表名常量
|
||||||
export const TABLES = {
|
export const TABLES = {
|
||||||
|
@ -1,13 +1,34 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
reactStrictMode: true,
|
reactStrictMode: false,
|
||||||
swcMinify: true,
|
swcMinify: true,
|
||||||
env: {
|
env: {
|
||||||
SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
// 禁用 Supabase 以避免多实例问题
|
||||||
SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
// NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
// NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
|
// SUPABASE_SERVICE_ROLE_KEY: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
domains: ['images.unsplash.com', 'avatars.githubusercontent.com'],
|
domains: [
|
||||||
|
'localhost',
|
||||||
|
'poxwjzdianersitpnvdy.supabase.co'
|
||||||
|
],
|
||||||
|
},
|
||||||
|
compiler: {
|
||||||
|
removeConsole: process.env.NODE_ENV === 'production',
|
||||||
|
},
|
||||||
|
webpack: (config, { isServer }) => {
|
||||||
|
// 避免客户端和服务端渲染不一致的问题
|
||||||
|
if (!isServer) {
|
||||||
|
config.resolve.fallback = {
|
||||||
|
...config.resolve.fallback,
|
||||||
|
fs: false,
|
||||||
|
net: false,
|
||||||
|
tls: false,
|
||||||
|
crypto: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
143
pages/_app.tsx
143
pages/_app.tsx
@ -1,17 +1,85 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { AppProps } from 'next/app';
|
import { AppProps } from 'next/app';
|
||||||
import { Toaster } from 'react-hot-toast';
|
import { Toaster } from 'react-hot-toast';
|
||||||
import { supabase } from '../lib/supabase';
|
|
||||||
import { User } from '@supabase/supabase-js';
|
import { User } from '@supabase/supabase-js';
|
||||||
|
import useClientMount from '../utils/useClientMount';
|
||||||
|
import ErrorBoundary from '../components/ErrorBoundary';
|
||||||
import '../styles/globals.css';
|
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) {
|
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 [loading, setLoading] = useState(true);
|
||||||
|
const [navigationInProgress, setNavigationInProgress] = useState(false);
|
||||||
|
const isClient = useClientMount();
|
||||||
const router = useRouter();
|
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(() => {
|
useEffect(() => {
|
||||||
|
// 只在客户端执行
|
||||||
|
if (!isClient) return;
|
||||||
|
|
||||||
// 检查是否为演示模式
|
// 检查是否为演示模式
|
||||||
const isDemoMode = !process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
const isDemoMode = !process.env.NEXT_PUBLIC_SUPABASE_URL ||
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL === 'https://demo.supabase.co' ||
|
process.env.NEXT_PUBLIC_SUPABASE_URL === 'https://demo.supabase.co' ||
|
||||||
@ -19,15 +87,27 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
|
|
||||||
const checkUser = async () => {
|
const checkUser = async () => {
|
||||||
try {
|
try {
|
||||||
if (isDemoMode) {
|
// 使用自定义 JWT 认证检查
|
||||||
// 演示模式下不检查用户认证
|
const jwtUser = await checkJWTAuth();
|
||||||
setUser(null);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
if (jwtUser) {
|
||||||
setUser(user);
|
setUser(jwtUser);
|
||||||
|
// 如果当前在登录页面且已经认证,重定向到仪表板
|
||||||
|
if (router.pathname === '/auth/login') {
|
||||||
|
navigateWithDebounce('/dashboard');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
// 如果在需要认证的页面但未登录,重定向到登录页
|
||||||
|
const protectedRoutes = ['/dashboard', '/admin', '/settings'];
|
||||||
|
const isProtectedRoute = protectedRoutes.some(route =>
|
||||||
|
router.pathname.startsWith(route)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isProtectedRoute) {
|
||||||
|
navigateWithDebounce('/auth/login');
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth check error:', error);
|
console.error('Auth check error:', error);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@ -38,25 +118,30 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
|
|
||||||
checkUser();
|
checkUser();
|
||||||
|
|
||||||
if (!isDemoMode) {
|
// 监听路由变化,重新检查认证状态
|
||||||
// 只在非演示模式下监听认证状态变化
|
const handleRouteChange = () => {
|
||||||
const { data: { subscription } } = supabase.auth.onAuthStateChange(
|
checkUser();
|
||||||
async (event: any, session: any) => {
|
};
|
||||||
setUser(session?.user ?? null);
|
|
||||||
|
|
||||||
if (event === 'SIGNED_OUT' || !session?.user) {
|
router.events.on('routeChangeComplete', handleRouteChange);
|
||||||
router.push('/auth/login');
|
|
||||||
} else if (event === 'SIGNED_IN' && session?.user) {
|
|
||||||
router.push('/dashboard');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.unsubscribe();
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
// 显示加载状态
|
// 显示加载状态
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -68,7 +153,8 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ErrorBoundary>
|
||||||
|
<div suppressHydrationWarning>
|
||||||
<Component {...pageProps} user={user} />
|
<Component {...pageProps} user={user} />
|
||||||
<Toaster
|
<Toaster
|
||||||
position="top-right"
|
position="top-right"
|
||||||
@ -94,6 +180,7 @@ export default function App({ Component, pageProps }: AppProps) {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</>
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
71
pages/api/auth/verify-token.ts
Normal file
71
pages/api/auth/verify-token.ts
Normal 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: '服务器内部错误'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -42,7 +42,7 @@ const LoginPage = () => {
|
|||||||
|
|
||||||
// 存储用户信息和令牌
|
// 存储用户信息和令牌
|
||||||
localStorage.setItem('user', JSON.stringify(data.user));
|
localStorage.setItem('user', JSON.stringify(data.user));
|
||||||
localStorage.setItem('access_token', data.token);
|
localStorage.setItem('adminToken', data.token);
|
||||||
|
|
||||||
// 使用 window.location 进行重定向,避免 Next.js 路由问题
|
// 使用 window.location 进行重定向,避免 Next.js 路由问题
|
||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
|
@ -1,188 +1,231 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Head from 'next/head';
|
||||||
import DashboardLayout from '../../components/Layout/DashboardLayout';
|
import DashboardLayout from '../../components/Layout/DashboardLayout';
|
||||||
import { getDemoData } from '../../lib/demo-data';
|
|
||||||
import {
|
import {
|
||||||
UsersIcon,
|
UserGroupIcon,
|
||||||
PhoneIcon,
|
PhoneIcon,
|
||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
|
ChartBarIcon,
|
||||||
|
InboxIcon,
|
||||||
|
VideoCameraIcon,
|
||||||
|
LanguageIcon,
|
||||||
CurrencyDollarIcon,
|
CurrencyDollarIcon,
|
||||||
CheckCircleIcon,
|
|
||||||
ClockIcon,
|
|
||||||
ExclamationTriangleIcon,
|
|
||||||
ArrowUpIcon,
|
|
||||||
ArrowDownIcon,
|
|
||||||
EyeIcon
|
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
|
import {
|
||||||
|
CheckCircleIcon as CheckCircleIconSolid,
|
||||||
|
ExclamationTriangleIcon as ExclamationTriangleIconSolid,
|
||||||
|
ClockIcon as ClockIconSolid,
|
||||||
|
XCircleIcon as XCircleIconSolid,
|
||||||
|
UserGroupIcon as UserGroupIconSolid,
|
||||||
|
PhoneIcon as PhoneIconSolid,
|
||||||
|
DocumentTextIcon as DocumentTextIconSolid,
|
||||||
|
CurrencyDollarIcon as CurrencyDollarIconSolid,
|
||||||
|
UsersIcon as UsersIconSolid,
|
||||||
|
} from '@heroicons/react/24/solid';
|
||||||
|
import { toast } from 'react-hot-toast';
|
||||||
|
import { statsAPI } from '../../lib/api-service';
|
||||||
|
|
||||||
interface DashboardStats {
|
interface DashboardStats {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
activeUsers: number;
|
totalInterpreters: number;
|
||||||
totalCalls: number;
|
|
||||||
activeCalls: number;
|
|
||||||
totalOrders: number;
|
totalOrders: number;
|
||||||
pendingOrders: number;
|
totalCalls: number;
|
||||||
completedOrders: number;
|
activeUsers: number;
|
||||||
totalRevenue: number;
|
activeCalls: number;
|
||||||
monthlyRevenue: number;
|
recentOrders: any[];
|
||||||
activeInterpreters: number;
|
recentCalls: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RecentActivity {
|
interface RecentActivity {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'call' | 'order' | 'user' | 'system';
|
type: 'order' | 'call' | 'user' | 'interpreter';
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
time: string;
|
time: string;
|
||||||
status: 'success' | 'warning' | 'error' | 'info';
|
status: 'success' | 'warning' | 'error' | 'info';
|
||||||
|
icon: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const router = useRouter();
|
||||||
const [activities, setActivities] = useState<RecentActivity[]>([]);
|
const [stats, setStats] = useState<DashboardStats>({
|
||||||
|
totalUsers: 0,
|
||||||
|
totalInterpreters: 0,
|
||||||
|
totalOrders: 0,
|
||||||
|
totalCalls: 0,
|
||||||
|
activeUsers: 0,
|
||||||
|
activeCalls: 0,
|
||||||
|
recentOrders: [],
|
||||||
|
recentCalls: []
|
||||||
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [recentActivity, setRecentActivity] = useState<RecentActivity[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
loadDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDashboardData = async () => {
|
const loadDashboardData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
const dashboardStats = await statsAPI.getDashboardStats();
|
||||||
|
setStats(dashboardStats);
|
||||||
|
|
||||||
// 模拟加载延迟
|
// 生成最近活动记录
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
const activities: RecentActivity[] = [];
|
||||||
|
|
||||||
// 使用演示数据
|
// 添加最近订单活动
|
||||||
const mockStats: DashboardStats = {
|
dashboardStats.recentOrders.forEach((order: any) => {
|
||||||
totalUsers: 1248,
|
activities.push({
|
||||||
activeUsers: 856,
|
id: order.id,
|
||||||
totalCalls: 3456,
|
|
||||||
activeCalls: 12,
|
|
||||||
totalOrders: 2789,
|
|
||||||
pendingOrders: 45,
|
|
||||||
completedOrders: 2654,
|
|
||||||
totalRevenue: 125000,
|
|
||||||
monthlyRevenue: 15600,
|
|
||||||
activeInterpreters: 23
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockActivities: RecentActivity[] = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
type: 'call',
|
|
||||||
title: '新通话开始',
|
|
||||||
description: '张三开始了中英互译通话',
|
|
||||||
time: '2分钟前',
|
|
||||||
status: 'success'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: 'order',
|
type: 'order',
|
||||||
title: '订单完成',
|
title: `订单 ${order.order_number || order.id}`,
|
||||||
description: '订单ORD-2024-001已完成,费用¥180',
|
description: `${order.user_name || '用户'} - ${order.service_name || '服务'}`,
|
||||||
time: '5分钟前',
|
time: formatTime(order.created_at),
|
||||||
status: 'success'
|
status: getOrderStatus(order.status),
|
||||||
},
|
icon: getOrderIcon(order.service_type)
|
||||||
{
|
});
|
||||||
id: '3',
|
});
|
||||||
type: 'user',
|
|
||||||
title: '新用户注册',
|
// 添加最近通话活动
|
||||||
description: 'ABC公司注册了企业账户',
|
dashboardStats.recentCalls.forEach((call: any) => {
|
||||||
time: '10分钟前',
|
activities.push({
|
||||||
status: 'info'
|
id: call.id,
|
||||||
},
|
type: 'call',
|
||||||
{
|
title: `${call.service_type === 'phone' ? '电话' : '视频'}通话`,
|
||||||
id: '4',
|
description: `${call.users?.name || '用户'} - ${call.interpreters?.name || '翻译员'}`,
|
||||||
type: 'system',
|
time: formatTime(call.created_at),
|
||||||
title: '系统维护',
|
status: getCallStatus(call.status),
|
||||||
description: '系统将在今晚22:00-23:00进行维护',
|
icon: call.service_type === 'phone' ? PhoneIcon : VideoCameraIcon
|
||||||
time: '30分钟前',
|
});
|
||||||
status: 'warning'
|
});
|
||||||
},
|
|
||||||
{
|
// 按时间排序
|
||||||
id: '5',
|
activities.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime());
|
||||||
type: 'call',
|
setRecentActivity(activities.slice(0, 10));
|
||||||
title: '通话异常',
|
|
||||||
description: '通话CALL-2024-003出现连接问题',
|
|
||||||
time: '1小时前',
|
|
||||||
status: 'error'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
setStats(mockStats);
|
|
||||||
setActivities(mockActivities);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load dashboard data:', error);
|
console.error('加载仪表盘数据失败:', error);
|
||||||
|
toast.error('加载仪表盘数据失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadDashboardData();
|
const getOrderStatus = (status: string) => {
|
||||||
}, []);
|
switch (status) {
|
||||||
|
case 'completed': return 'success';
|
||||||
|
case 'cancelled': return 'error';
|
||||||
|
case 'in_progress': return 'warning';
|
||||||
|
default: return 'info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCallStatus = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'ended': return 'success';
|
||||||
|
case 'cancelled': return 'error';
|
||||||
|
case 'connected': return 'warning';
|
||||||
|
default: return 'info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOrderIcon = (serviceType: string) => {
|
||||||
|
switch (serviceType) {
|
||||||
|
case 'phone': return PhoneIcon;
|
||||||
|
case 'video': return VideoCameraIcon;
|
||||||
|
case 'document': return DocumentTextIcon;
|
||||||
|
default: return LanguageIcon;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (dateString: string) => {
|
||||||
|
if (!dateString) return '未知时间';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diff = now.getTime() - date.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
|
||||||
|
if (days > 0) {
|
||||||
|
return `${days}天前`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}小时前`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}分钟前`;
|
||||||
|
} else {
|
||||||
|
return '刚刚';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success':
|
case 'success': return 'bg-green-100 text-green-800';
|
||||||
return 'text-green-600 bg-green-100';
|
case 'warning': return 'bg-yellow-100 text-yellow-800';
|
||||||
case 'warning':
|
case 'error': return 'bg-red-100 text-red-800';
|
||||||
return 'text-yellow-600 bg-yellow-100';
|
default: return 'bg-blue-100 text-blue-800';
|
||||||
case 'error':
|
|
||||||
return 'text-red-600 bg-red-100';
|
|
||||||
default:
|
|
||||||
return 'text-blue-600 bg-blue-100';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusIcon = (status: string) => {
|
const getStatusIcon = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success':
|
case 'success': return CheckCircleIconSolid;
|
||||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
case 'warning': return ExclamationTriangleIconSolid;
|
||||||
case 'warning':
|
case 'error': return XCircleIconSolid;
|
||||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
default: return ClockIconSolid;
|
||||||
case 'error':
|
|
||||||
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
|
|
||||||
default:
|
|
||||||
return <ClockIcon className="h-5 w-5 text-blue-500" />;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>仪表盘 - 翻译服务管理系统</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
<DashboardLayout title="仪表盘">
|
<DashboardLayout title="仪表盘">
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
<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>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>仪表盘 - 翻译服务管理系统</title>
|
||||||
|
</Head>
|
||||||
|
|
||||||
<DashboardLayout title="仪表盘">
|
<DashboardLayout title="仪表盘">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 欢迎区域 */}
|
{/* 页面标题和描述 */}
|
||||||
<div className="bg-white shadow rounded-lg p-6">
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">欢迎回来!</h1>
|
<h1 className="text-2xl font-bold text-gray-900">系统概览</h1>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-2 text-sm text-gray-700">
|
||||||
这里是您的管理仪表板,查看最新的业务数据和活动。
|
欢迎回来,管理员。这里是您的系统概览和数据统计。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
{/* Stats Cards */}
|
||||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
<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="bg-white overflow-hidden shadow rounded-lg">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<UsersIcon className="h-6 w-6 text-blue-400" />
|
<UserGroupIconSolid className="h-8 w-8 text-blue-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">总用户数</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
<dd className="flex items-baseline">
|
总用户数
|
||||||
<div className="text-2xl font-semibold text-gray-900">{stats?.totalUsers || 0}</div>
|
</dt>
|
||||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
{stats.totalUsers.toLocaleString()}
|
||||||
<span className="sr-only">增加了</span>
|
|
||||||
12%
|
|
||||||
</div>
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@ -190,8 +233,9 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 px-5 py-3">
|
<div className="bg-gray-50 px-5 py-3">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-gray-500">活跃用户: </span>
|
<span className="text-green-600 font-medium">
|
||||||
<span className="text-gray-900">{stats?.activeUsers || 0}</span>
|
{stats.activeUsers} 活跃用户
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,18 +244,15 @@ export default function Dashboard() {
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<PhoneIcon className="h-6 w-6 text-green-400" />
|
<UsersIconSolid className="h-8 w-8 text-green-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">总通话数</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
<dd className="flex items-baseline">
|
翻译员总数
|
||||||
<div className="text-2xl font-semibold text-gray-900">{stats?.totalCalls || 0}</div>
|
</dt>
|
||||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
{stats.totalInterpreters.toLocaleString()}
|
||||||
<span className="sr-only">增加了</span>
|
|
||||||
8%
|
|
||||||
</div>
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@ -219,8 +260,9 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 px-5 py-3">
|
<div className="bg-gray-50 px-5 py-3">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-gray-500">进行中: </span>
|
<span className="text-green-600 font-medium">
|
||||||
<span className="text-gray-900">{stats?.activeCalls || 0}</span>
|
在线翻译员
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -229,18 +271,15 @@ export default function Dashboard() {
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<DocumentTextIcon className="h-6 w-6 text-yellow-400" />
|
<DocumentTextIconSolid className="h-8 w-8 text-purple-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">总订单数</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
<dd className="flex items-baseline">
|
总订单数
|
||||||
<div className="text-2xl font-semibold text-gray-900">{stats?.totalOrders || 0}</div>
|
</dt>
|
||||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
{stats.totalOrders.toLocaleString()}
|
||||||
<span className="sr-only">增加了</span>
|
|
||||||
15%
|
|
||||||
</div>
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@ -248,8 +287,9 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 px-5 py-3">
|
<div className="bg-gray-50 px-5 py-3">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-gray-500">待处理: </span>
|
<span className="text-green-600 font-medium">
|
||||||
<span className="text-gray-900">{stats?.pendingOrders || 0}</span>
|
本月新增
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -258,18 +298,15 @@ export default function Dashboard() {
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<CurrencyDollarIcon className="h-6 w-6 text-purple-400" />
|
<PhoneIconSolid className="h-8 w-8 text-orange-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-5 w-0 flex-1">
|
<div className="ml-5 w-0 flex-1">
|
||||||
<dl>
|
<dl>
|
||||||
<dt className="text-sm font-medium text-gray-500 truncate">总收入</dt>
|
<dt className="text-sm font-medium text-gray-500 truncate">
|
||||||
<dd className="flex items-baseline">
|
总通话数
|
||||||
<div className="text-2xl font-semibold text-gray-900">¥{stats?.totalRevenue?.toLocaleString() || 0}</div>
|
</dt>
|
||||||
<div className="ml-2 flex items-baseline text-sm font-semibold text-green-600">
|
<dd className="text-lg font-medium text-gray-900">
|
||||||
<ArrowUpIcon className="self-center flex-shrink-0 h-4 w-4 text-green-500" />
|
{stats.totalCalls.toLocaleString()}
|
||||||
<span className="sr-only">增加了</span>
|
|
||||||
22%
|
|
||||||
</div>
|
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
@ -277,97 +314,71 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-gray-50 px-5 py-3">
|
<div className="bg-gray-50 px-5 py-3">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span className="font-medium text-gray-500">本月: </span>
|
<span className="text-orange-600 font-medium">
|
||||||
<span className="text-gray-900">¥{stats?.monthlyRevenue?.toLocaleString() || 0}</span>
|
{stats.activeCalls} 进行中
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 最近活动和快速操作 */}
|
{/* Recent Activity */}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
|
||||||
{/* 最近活动 */}
|
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="bg-white shadow rounded-lg">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="px-6 py-4 border-b border-gray-200">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">最近活动</h3>
|
<h3 className="text-lg font-medium text-gray-900 flex items-center">
|
||||||
<div className="space-y-4">
|
<ChartBarIcon className="h-5 w-5 text-indigo-600 mr-2" />
|
||||||
{activities.map((activity) => (
|
最近活动
|
||||||
<div key={activity.id} className="flex items-start space-x-3">
|
</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="flex-shrink-0">
|
||||||
{getStatusIcon(activity.status)}
|
<div className={`p-2 rounded-full ${getStatusColor(activity.status)}`}>
|
||||||
</div>
|
<ActivityIcon className="h-5 w-5" />
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium text-gray-900">{activity.title}</div>
|
|
||||||
<div className="text-sm text-gray-500">{activity.description}</div>
|
|
||||||
<div className="text-xs text-gray-400 mt-1">{activity.time}</div>
|
|
||||||
</div>
|
|
||||||
<div className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusColor(activity.status)}`}>
|
|
||||||
{activity.status === 'success' && '成功'}
|
|
||||||
{activity.status === 'warning' && '警告'}
|
|
||||||
{activity.status === 'error' && '错误'}
|
|
||||||
{activity.status === 'info' && '信息'}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="ml-4 flex-1">
|
||||||
</div>
|
<div className="flex items-center justify-between">
|
||||||
<div className="mt-6">
|
<p className="text-sm font-medium text-gray-900">
|
||||||
<button className="w-full bg-gray-50 border border-gray-300 rounded-md py-2 px-4 inline-flex justify-center items-center text-sm font-medium text-gray-700 hover:bg-gray-100">
|
{activity.title}
|
||||||
<EyeIcon className="h-4 w-4 mr-2" />
|
</p>
|
||||||
查看所有活动
|
|
||||||
</button>
|
|
||||||
</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="grid grid-cols-2 gap-4">
|
|
||||||
<button className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-left hover:bg-blue-100 transition-colors">
|
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<UsersIcon className="h-8 w-8 text-blue-600" />
|
<StatusIcon className={`h-4 w-4 mr-1 ${
|
||||||
<div className="ml-3">
|
activity.status === 'success' ? 'text-green-500' :
|
||||||
<div className="text-sm font-medium text-blue-900">用户管理</div>
|
activity.status === 'warning' ? 'text-yellow-500' :
|
||||||
<div className="text-xs text-blue-700">管理用户账户</div>
|
activity.status === 'error' ? 'text-red-500' :
|
||||||
|
'text-blue-500'
|
||||||
|
}`} />
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{activity.time}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{activity.description}
|
||||||
<button className="bg-green-50 border border-green-200 rounded-lg p-4 text-left hover:bg-green-100 transition-colors">
|
</p>
|
||||||
<div className="flex items-center">
|
|
||||||
<PhoneIcon className="h-8 w-8 text-green-600" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<div className="text-sm font-medium text-green-900">通话监控</div>
|
|
||||||
<div className="text-xs text-green-700">实时通话状态</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-left hover:bg-yellow-100 transition-colors">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<DocumentTextIcon className="h-8 w-8 text-yellow-600" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<div className="text-sm font-medium text-yellow-900">订单管理</div>
|
|
||||||
<div className="text-xs text-yellow-700">处理订单请求</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button className="bg-purple-50 border border-purple-200 rounded-lg p-4 text-left hover:bg-purple-100 transition-colors">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<CurrencyDollarIcon className="h-8 w-8 text-purple-600" />
|
|
||||||
<div className="ml-3">
|
|
||||||
<div className="text-sm font-medium text-purple-900">财务报表</div>
|
|
||||||
<div className="text-xs text-purple-700">查看收入统计</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -23,7 +23,8 @@ import {
|
|||||||
MapPinIcon,
|
MapPinIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
CurrencyDollarIcon,
|
CurrencyDollarIcon,
|
||||||
AcademicCapIcon
|
AcademicCapIcon,
|
||||||
|
XMarkIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { getDemoData } from '../../lib/demo-data';
|
import { getDemoData } from '../../lib/demo-data';
|
||||||
import { formatTime } from '../../lib/utils';
|
import { formatTime } from '../../lib/utils';
|
||||||
@ -74,6 +75,23 @@ export default function Interpreters() {
|
|||||||
availability: ''
|
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;
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -420,7 +690,7 @@ export default function Interpreters() {
|
|||||||
导出数据
|
导出数据
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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" />
|
<UserPlusIcon className="h-4 w-4 mr-2" />
|
||||||
@ -784,6 +1054,9 @@ export default function Interpreters() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 添加翻译员模态框 */}
|
||||||
|
<AddInterpreterModal />
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -23,7 +23,8 @@ import {
|
|||||||
DocumentTextIcon,
|
DocumentTextIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PauseIcon,
|
PauseIcon,
|
||||||
StopIcon
|
StopIcon,
|
||||||
|
XMarkIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { getDemoData } from '../../lib/demo-data';
|
import { getDemoData } from '../../lib/demo-data';
|
||||||
import { formatTime } from '../../lib/utils';
|
import { formatTime } from '../../lib/utils';
|
||||||
@ -71,6 +72,21 @@ export default function Orders() {
|
|||||||
date_range: ''
|
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;
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -396,6 +412,225 @@ export default function Orders() {
|
|||||||
return `${mins}分钟`;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -421,7 +656,7 @@ export default function Orders() {
|
|||||||
导出数据
|
导出数据
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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" />
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
@ -787,6 +1022,9 @@ export default function Orders() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 创建订单模态框 */}
|
||||||
|
<CreateOrderModal />
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
@ -18,7 +18,8 @@ import {
|
|||||||
XCircleIcon,
|
XCircleIcon,
|
||||||
ExclamationTriangleIcon,
|
ExclamationTriangleIcon,
|
||||||
ArrowDownTrayIcon,
|
ArrowDownTrayIcon,
|
||||||
FunnelIcon
|
FunnelIcon,
|
||||||
|
XMarkIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { getDemoData } from '../../lib/demo-data';
|
import { getDemoData } from '../../lib/demo-data';
|
||||||
import { formatTime } from '../../lib/utils';
|
import { formatTime } from '../../lib/utils';
|
||||||
@ -59,6 +60,18 @@ export default function Users() {
|
|||||||
company: ''
|
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;
|
const pageSize = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Head>
|
<Head>
|
||||||
@ -343,7 +520,7 @@ export default function Users() {
|
|||||||
导出
|
导出
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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" />
|
<PlusIcon className="h-4 w-4 mr-2" />
|
||||||
@ -656,6 +833,9 @@ export default function Users() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|
||||||
|
{/* 添加用户模态框 */}
|
||||||
|
<AddUserModal />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -14,11 +14,11 @@ export interface Database {
|
|||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
phone?: string
|
phone: string | null
|
||||||
user_type: 'individual' | 'enterprise'
|
user_type: 'individual' | 'enterprise'
|
||||||
status: 'active' | 'inactive'
|
status: 'active' | 'inactive' | 'suspended'
|
||||||
enterprise_id?: string
|
enterprise_id: string | null
|
||||||
avatar_url?: string
|
avatar_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -26,11 +26,11 @@ export interface Database {
|
|||||||
id?: string
|
id?: string
|
||||||
email: string
|
email: string
|
||||||
name: string
|
name: string
|
||||||
phone?: string
|
phone?: string | null
|
||||||
user_type: 'individual' | 'enterprise'
|
user_type: 'individual' | 'enterprise'
|
||||||
status?: 'active' | 'inactive'
|
status?: 'active' | 'inactive' | 'suspended'
|
||||||
enterprise_id?: string
|
enterprise_id?: string | null
|
||||||
avatar_url?: string
|
avatar_url?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -38,11 +38,11 @@ export interface Database {
|
|||||||
id?: string
|
id?: string
|
||||||
email?: string
|
email?: string
|
||||||
name?: string
|
name?: string
|
||||||
phone?: string
|
phone?: string | null
|
||||||
user_type?: 'individual' | 'enterprise'
|
user_type?: 'individual' | 'enterprise'
|
||||||
status?: 'active' | 'inactive'
|
status?: 'active' | 'inactive' | 'suspended'
|
||||||
enterprise_id?: string
|
enterprise_id?: string | null
|
||||||
avatar_url?: string
|
avatar_url?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -53,10 +53,10 @@ export interface Database {
|
|||||||
name: string
|
name: string
|
||||||
contact_person: string
|
contact_person: string
|
||||||
contact_email: string
|
contact_email: string
|
||||||
contact_phone: string
|
contact_phone: string | null
|
||||||
address: string
|
address: string | null
|
||||||
tax_number?: string
|
tax_number: string | null
|
||||||
status: 'active' | 'inactive'
|
status: 'active' | 'inactive' | 'suspended'
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -65,10 +65,10 @@ export interface Database {
|
|||||||
name: string
|
name: string
|
||||||
contact_person: string
|
contact_person: string
|
||||||
contact_email: string
|
contact_email: string
|
||||||
contact_phone: string
|
contact_phone?: string | null
|
||||||
address: string
|
address?: string | null
|
||||||
tax_number?: string
|
tax_number?: string | null
|
||||||
status?: 'active' | 'inactive'
|
status?: 'active' | 'inactive' | 'suspended'
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -77,10 +77,10 @@ export interface Database {
|
|||||||
name?: string
|
name?: string
|
||||||
contact_person?: string
|
contact_person?: string
|
||||||
contact_email?: string
|
contact_email?: string
|
||||||
contact_phone?: string
|
contact_phone?: string | null
|
||||||
address?: string
|
address?: string | null
|
||||||
tax_number?: string
|
tax_number?: string | null
|
||||||
status?: 'active' | 'inactive'
|
status?: 'active' | 'inactive' | 'suspended'
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -90,13 +90,13 @@ export interface Database {
|
|||||||
id: string
|
id: string
|
||||||
enterprise_id: string
|
enterprise_id: string
|
||||||
contract_number: string
|
contract_number: string
|
||||||
contract_type: string
|
contract_type: 'basic' | 'premium' | 'enterprise'
|
||||||
start_date: string
|
start_date: string
|
||||||
end_date: string
|
end_date: string
|
||||||
total_amount: number
|
total_amount: number
|
||||||
currency: string
|
currency: string
|
||||||
status: 'active' | 'expired' | 'terminated'
|
status: 'active' | 'expired' | 'cancelled'
|
||||||
service_rates: Json
|
service_rates: any
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -104,13 +104,13 @@ export interface Database {
|
|||||||
id?: string
|
id?: string
|
||||||
enterprise_id: string
|
enterprise_id: string
|
||||||
contract_number: string
|
contract_number: string
|
||||||
contract_type: string
|
contract_type: 'basic' | 'premium' | 'enterprise'
|
||||||
start_date: string
|
start_date: string
|
||||||
end_date: string
|
end_date: string
|
||||||
total_amount: number
|
total_amount: number
|
||||||
currency?: string
|
currency: string
|
||||||
status?: 'active' | 'expired' | 'terminated'
|
status?: 'active' | 'expired' | 'cancelled'
|
||||||
service_rates?: Json
|
service_rates: any
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -118,13 +118,13 @@ export interface Database {
|
|||||||
id?: string
|
id?: string
|
||||||
enterprise_id?: string
|
enterprise_id?: string
|
||||||
contract_number?: string
|
contract_number?: string
|
||||||
contract_type?: string
|
contract_type?: 'basic' | 'premium' | 'enterprise'
|
||||||
start_date?: string
|
start_date?: string
|
||||||
end_date?: string
|
end_date?: string
|
||||||
total_amount?: number
|
total_amount?: number
|
||||||
currency?: string
|
currency?: string
|
||||||
status?: 'active' | 'expired' | 'terminated'
|
status?: 'active' | 'expired' | 'cancelled'
|
||||||
service_rates?: Json
|
service_rates?: any
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -139,7 +139,7 @@ export interface Database {
|
|||||||
total_amount: number
|
total_amount: number
|
||||||
currency: string
|
currency: string
|
||||||
status: 'draft' | 'sent' | 'paid' | 'overdue'
|
status: 'draft' | 'sent' | 'paid' | 'overdue'
|
||||||
items: Json
|
items: any[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -150,9 +150,9 @@ export interface Database {
|
|||||||
billing_period_start: string
|
billing_period_start: string
|
||||||
billing_period_end: string
|
billing_period_end: string
|
||||||
total_amount: number
|
total_amount: number
|
||||||
currency?: string
|
currency: string
|
||||||
status?: 'draft' | 'sent' | 'paid' | 'overdue'
|
status?: 'draft' | 'sent' | 'paid' | 'overdue'
|
||||||
items?: Json
|
items: any[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -165,7 +165,7 @@ export interface Database {
|
|||||||
total_amount?: number
|
total_amount?: number
|
||||||
currency?: string
|
currency?: string
|
||||||
status?: 'draft' | 'sent' | 'paid' | 'overdue'
|
status?: 'draft' | 'sent' | 'paid' | 'overdue'
|
||||||
items?: Json
|
items?: any[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -177,21 +177,21 @@ export interface Database {
|
|||||||
user_id: string
|
user_id: string
|
||||||
user_name: string
|
user_name: string
|
||||||
user_email: string
|
user_email: string
|
||||||
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation' | 'document_translation'
|
service_type: 'phone' | 'video' | 'document'
|
||||||
service_name: string
|
service_name: string
|
||||||
source_language: string
|
source_language: string
|
||||||
target_language: string
|
target_language: string
|
||||||
duration?: number
|
duration: number | null
|
||||||
status: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'
|
status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled'
|
||||||
priority: 'urgent' | 'high' | 'normal' | 'low'
|
priority: 'low' | 'medium' | 'high' | 'urgent'
|
||||||
cost: number
|
cost: number
|
||||||
currency: string
|
currency: string
|
||||||
scheduled_time?: string
|
scheduled_time: string | null
|
||||||
started_time?: string
|
started_time: string | null
|
||||||
completed_time?: string
|
completed_time: string | null
|
||||||
interpreter_id?: string
|
interpreter_id: string | null
|
||||||
interpreter_name?: string
|
interpreter_name: string | null
|
||||||
notes?: string
|
notes: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -201,21 +201,21 @@ export interface Database {
|
|||||||
user_id: string
|
user_id: string
|
||||||
user_name: string
|
user_name: string
|
||||||
user_email: string
|
user_email: string
|
||||||
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation' | 'document_translation'
|
service_type: 'phone' | 'video' | 'document'
|
||||||
service_name: string
|
service_name: string
|
||||||
source_language: string
|
source_language: string
|
||||||
target_language: string
|
target_language: string
|
||||||
duration?: number
|
duration?: number | null
|
||||||
status?: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'
|
status?: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled'
|
||||||
priority?: 'urgent' | 'high' | 'normal' | 'low'
|
priority?: 'low' | 'medium' | 'high' | 'urgent'
|
||||||
cost: number
|
cost: number
|
||||||
currency?: string
|
currency: string
|
||||||
scheduled_time?: string
|
scheduled_time?: string | null
|
||||||
started_time?: string
|
started_time?: string | null
|
||||||
completed_time?: string
|
completed_time?: string | null
|
||||||
interpreter_id?: string
|
interpreter_id?: string | null
|
||||||
interpreter_name?: string
|
interpreter_name?: string | null
|
||||||
notes?: string
|
notes?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -225,21 +225,21 @@ export interface Database {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
user_name?: string
|
user_name?: string
|
||||||
user_email?: string
|
user_email?: string
|
||||||
service_type?: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation' | 'document_translation'
|
service_type?: 'phone' | 'video' | 'document'
|
||||||
service_name?: string
|
service_name?: string
|
||||||
source_language?: string
|
source_language?: string
|
||||||
target_language?: string
|
target_language?: string
|
||||||
duration?: number
|
duration?: number | null
|
||||||
status?: 'pending' | 'processing' | 'completed' | 'cancelled' | 'failed'
|
status?: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled'
|
||||||
priority?: 'urgent' | 'high' | 'normal' | 'low'
|
priority?: 'low' | 'medium' | 'high' | 'urgent'
|
||||||
cost?: number
|
cost?: number
|
||||||
currency?: string
|
currency?: string
|
||||||
scheduled_time?: string
|
scheduled_time?: string | null
|
||||||
started_time?: string
|
started_time?: string | null
|
||||||
completed_time?: string
|
completed_time?: string | null
|
||||||
interpreter_id?: string
|
interpreter_id?: string | null
|
||||||
interpreter_name?: string
|
interpreter_name?: string | null
|
||||||
notes?: string
|
notes?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -251,21 +251,21 @@ export interface Database {
|
|||||||
user_id: string
|
user_id: string
|
||||||
user_name: string
|
user_name: string
|
||||||
user_email: string
|
user_email: string
|
||||||
order_id?: string
|
order_id: string | null
|
||||||
invoice_type: 'personal' | 'enterprise'
|
invoice_type: 'individual' | 'enterprise'
|
||||||
personal_name?: string
|
personal_name: string | null
|
||||||
company_name?: string
|
company_name: string | null
|
||||||
tax_number?: string
|
tax_number: string | null
|
||||||
company_address?: string
|
company_address: string | null
|
||||||
subtotal: number
|
subtotal: number
|
||||||
tax_amount: number
|
tax_amount: number
|
||||||
total_amount: number
|
total_amount: number
|
||||||
currency: string
|
currency: string
|
||||||
status: 'draft' | 'issued' | 'paid' | 'cancelled'
|
status: 'draft' | 'sent' | 'paid' | 'cancelled' | 'overdue'
|
||||||
issue_date?: string
|
issue_date: string
|
||||||
due_date?: string
|
due_date: string
|
||||||
paid_date?: string
|
paid_date: string | null
|
||||||
items: Json
|
items: any[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -275,21 +275,21 @@ export interface Database {
|
|||||||
user_id: string
|
user_id: string
|
||||||
user_name: string
|
user_name: string
|
||||||
user_email: string
|
user_email: string
|
||||||
order_id?: string
|
order_id?: string | null
|
||||||
invoice_type: 'personal' | 'enterprise'
|
invoice_type: 'individual' | 'enterprise'
|
||||||
personal_name?: string
|
personal_name?: string | null
|
||||||
company_name?: string
|
company_name?: string | null
|
||||||
tax_number?: string
|
tax_number?: string | null
|
||||||
company_address?: string
|
company_address?: string | null
|
||||||
subtotal: number
|
subtotal: number
|
||||||
tax_amount: number
|
tax_amount: number
|
||||||
total_amount: number
|
total_amount: number
|
||||||
currency?: string
|
currency: string
|
||||||
status?: 'draft' | 'issued' | 'paid' | 'cancelled'
|
status?: 'draft' | 'sent' | 'paid' | 'cancelled' | 'overdue'
|
||||||
issue_date?: string
|
issue_date: string
|
||||||
due_date?: string
|
due_date: string
|
||||||
paid_date?: string
|
paid_date?: string | null
|
||||||
items?: Json
|
items: any[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -299,21 +299,21 @@ export interface Database {
|
|||||||
user_id?: string
|
user_id?: string
|
||||||
user_name?: string
|
user_name?: string
|
||||||
user_email?: string
|
user_email?: string
|
||||||
order_id?: string
|
order_id?: string | null
|
||||||
invoice_type?: 'personal' | 'enterprise'
|
invoice_type?: 'individual' | 'enterprise'
|
||||||
personal_name?: string
|
personal_name?: string | null
|
||||||
company_name?: string
|
company_name?: string | null
|
||||||
tax_number?: string
|
tax_number?: string | null
|
||||||
company_address?: string
|
company_address?: string | null
|
||||||
subtotal?: number
|
subtotal?: number
|
||||||
tax_amount?: number
|
tax_amount?: number
|
||||||
total_amount?: number
|
total_amount?: number
|
||||||
currency?: string
|
currency?: string
|
||||||
status?: 'draft' | 'issued' | 'paid' | 'cancelled'
|
status?: 'draft' | 'sent' | 'paid' | 'cancelled' | 'overdue'
|
||||||
issue_date?: string
|
issue_date?: string
|
||||||
due_date?: string
|
due_date?: string
|
||||||
paid_date?: string
|
paid_date?: string | null
|
||||||
items?: Json
|
items?: any[]
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -323,16 +323,16 @@ export interface Database {
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
phone: string
|
phone: string | null
|
||||||
languages: string[]
|
languages: string[]
|
||||||
specialties: string[]
|
specialties: string[]
|
||||||
status: 'online' | 'offline' | 'busy'
|
status: 'active' | 'inactive' | 'busy'
|
||||||
rating: number
|
rating: number
|
||||||
total_calls: number
|
total_calls: number
|
||||||
hourly_rate: number
|
hourly_rate: number
|
||||||
currency: string
|
currency: string
|
||||||
avatar_url?: string
|
avatar_url: string | null
|
||||||
bio?: string
|
bio: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -340,16 +340,16 @@ export interface Database {
|
|||||||
id?: string
|
id?: string
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
phone: string
|
phone?: string | null
|
||||||
languages: string[]
|
languages: string[]
|
||||||
specialties: string[]
|
specialties: string[]
|
||||||
status?: 'online' | 'offline' | 'busy'
|
status?: 'active' | 'inactive' | 'busy'
|
||||||
rating?: number
|
rating?: number
|
||||||
total_calls?: number
|
total_calls?: number
|
||||||
hourly_rate: number
|
hourly_rate: number
|
||||||
currency?: string
|
currency: string
|
||||||
avatar_url?: string
|
avatar_url?: string | null
|
||||||
bio?: string
|
bio?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -357,16 +357,16 @@ export interface Database {
|
|||||||
id?: string
|
id?: string
|
||||||
name?: string
|
name?: string
|
||||||
email?: string
|
email?: string
|
||||||
phone?: string
|
phone?: string | null
|
||||||
languages?: string[]
|
languages?: string[]
|
||||||
specialties?: string[]
|
specialties?: string[]
|
||||||
status?: 'online' | 'offline' | 'busy'
|
status?: 'active' | 'inactive' | 'busy'
|
||||||
rating?: number
|
rating?: number
|
||||||
total_calls?: number
|
total_calls?: number
|
||||||
hourly_rate?: number
|
hourly_rate?: number
|
||||||
currency?: string
|
currency?: string
|
||||||
avatar_url?: string
|
avatar_url?: string | null
|
||||||
bio?: string
|
bio?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -375,34 +375,34 @@ export interface Database {
|
|||||||
Row: {
|
Row: {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
interpreter_id?: string
|
interpreter_id: string
|
||||||
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation'
|
service_type: 'phone' | 'video'
|
||||||
source_language: string
|
source_language: string
|
||||||
target_language: string
|
target_language: string
|
||||||
status: 'waiting' | 'connecting' | 'active' | 'completed' | 'failed'
|
status: 'waiting' | 'connected' | 'ended' | 'cancelled'
|
||||||
duration?: number
|
duration: number | null
|
||||||
cost: number
|
cost: number
|
||||||
currency: string
|
currency: string
|
||||||
quality_rating?: number
|
quality_rating: number | null
|
||||||
started_at?: string
|
started_at: string | null
|
||||||
ended_at?: string
|
ended_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string
|
id?: string
|
||||||
user_id: string
|
user_id: string
|
||||||
interpreter_id?: string
|
interpreter_id: string
|
||||||
service_type: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation'
|
service_type: 'phone' | 'video'
|
||||||
source_language: string
|
source_language: string
|
||||||
target_language: string
|
target_language: string
|
||||||
status?: 'waiting' | 'connecting' | 'active' | 'completed' | 'failed'
|
status?: 'waiting' | 'connected' | 'ended' | 'cancelled'
|
||||||
duration?: number
|
duration?: number | null
|
||||||
cost: number
|
cost: number
|
||||||
currency?: string
|
currency: string
|
||||||
quality_rating?: number
|
quality_rating?: number | null
|
||||||
started_at?: string
|
started_at?: string | null
|
||||||
ended_at?: string
|
ended_at?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -410,16 +410,16 @@ export interface Database {
|
|||||||
id?: string
|
id?: string
|
||||||
user_id?: string
|
user_id?: string
|
||||||
interpreter_id?: string
|
interpreter_id?: string
|
||||||
service_type?: 'ai_voice' | 'ai_video' | 'sign_language' | 'human_interpretation'
|
service_type?: 'phone' | 'video'
|
||||||
source_language?: string
|
source_language?: string
|
||||||
target_language?: string
|
target_language?: string
|
||||||
status?: 'waiting' | 'connecting' | 'active' | 'completed' | 'failed'
|
status?: 'waiting' | 'connected' | 'ended' | 'cancelled'
|
||||||
duration?: number
|
duration?: number | null
|
||||||
cost?: number
|
cost?: number
|
||||||
currency?: string
|
currency?: string
|
||||||
quality_rating?: number
|
quality_rating?: number | null
|
||||||
started_at?: string
|
started_at?: string | null
|
||||||
ended_at?: string
|
ended_at?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -432,13 +432,9 @@ export interface Database {
|
|||||||
original_name: string
|
original_name: string
|
||||||
file_size: number
|
file_size: number
|
||||||
file_type: string
|
file_type: string
|
||||||
source_language: string
|
source_language: string | null
|
||||||
target_language: string
|
target_language: string | null
|
||||||
status: 'uploaded' | 'processing' | 'completed' | 'failed'
|
status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
progress: number
|
|
||||||
cost: number
|
|
||||||
currency: string
|
|
||||||
translated_file_url?: string
|
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@ -449,13 +445,9 @@ export interface Database {
|
|||||||
original_name: string
|
original_name: string
|
||||||
file_size: number
|
file_size: number
|
||||||
file_type: string
|
file_type: string
|
||||||
source_language: string
|
source_language?: string | null
|
||||||
target_language: string
|
target_language?: string | null
|
||||||
status?: 'uploaded' | 'processing' | 'completed' | 'failed'
|
status?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
progress?: number
|
|
||||||
cost: number
|
|
||||||
currency?: string
|
|
||||||
translated_file_url?: string
|
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -466,13 +458,9 @@ export interface Database {
|
|||||||
original_name?: string
|
original_name?: string
|
||||||
file_size?: number
|
file_size?: number
|
||||||
file_type?: string
|
file_type?: string
|
||||||
source_language?: string
|
source_language?: string | null
|
||||||
target_language?: string
|
target_language?: string | null
|
||||||
status?: 'uploaded' | 'processing' | 'completed' | 'failed'
|
status?: 'pending' | 'processing' | 'completed' | 'failed'
|
||||||
progress?: number
|
|
||||||
cost?: number
|
|
||||||
currency?: string
|
|
||||||
translated_file_url?: string
|
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
@ -481,24 +469,24 @@ export interface Database {
|
|||||||
Row: {
|
Row: {
|
||||||
id: string
|
id: string
|
||||||
key: string
|
key: string
|
||||||
value: Json
|
value: string
|
||||||
description?: string
|
description: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
Insert: {
|
Insert: {
|
||||||
id?: string
|
id?: string
|
||||||
key: string
|
key: string
|
||||||
value: Json
|
value: string
|
||||||
description?: string
|
description?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
Update: {
|
Update: {
|
||||||
id?: string
|
id?: string
|
||||||
key?: string
|
key?: string
|
||||||
value?: Json
|
value?: string
|
||||||
description?: string
|
description?: string | null
|
||||||
created_at?: string
|
created_at?: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
104
utils/hydrationDebug.ts
Normal file
104
utils/hydrationDebug.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// 开发环境的水合错误调试工具
|
||||||
|
export const hydrationDebug = {
|
||||||
|
// 检查是否在客户端
|
||||||
|
isClient: typeof window !== 'undefined',
|
||||||
|
|
||||||
|
// 检查是否在开发环境
|
||||||
|
isDevelopment: process.env.NODE_ENV === 'development',
|
||||||
|
|
||||||
|
// 记录水合错误
|
||||||
|
logHydrationError: (error: Error, componentName?: string) => {
|
||||||
|
if (hydrationDebug.isDevelopment) {
|
||||||
|
console.group('🔧 Hydration Error Debug');
|
||||||
|
console.error('Component:', componentName || 'Unknown');
|
||||||
|
console.error('Error:', error.message);
|
||||||
|
console.error('Stack:', error.stack);
|
||||||
|
|
||||||
|
// 检查常见的水合错误原因
|
||||||
|
const commonCauses = [
|
||||||
|
'Server and client rendered different content',
|
||||||
|
'useEffect running on server',
|
||||||
|
'Date/time differences',
|
||||||
|
'Random values',
|
||||||
|
'Browser-specific APIs',
|
||||||
|
'localStorage/sessionStorage access',
|
||||||
|
'Window object access'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.warn('Common causes of hydration errors:');
|
||||||
|
commonCauses.forEach((cause, index) => {
|
||||||
|
console.warn(`${index + 1}. ${cause}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 安全地访问浏览器 API
|
||||||
|
safeWindowAccess: <T>(callback: () => T, fallback: T): T => {
|
||||||
|
if (hydrationDebug.isClient) {
|
||||||
|
try {
|
||||||
|
return callback();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Safe window access failed:', error);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 安全地访问 localStorage
|
||||||
|
safeLocalStorage: {
|
||||||
|
getItem: (key: string): string | null => {
|
||||||
|
return hydrationDebug.safeWindowAccess(
|
||||||
|
() => localStorage.getItem(key),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
setItem: (key: string, value: string): void => {
|
||||||
|
hydrationDebug.safeWindowAccess(
|
||||||
|
() => localStorage.setItem(key, value),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
},
|
||||||
|
removeItem: (key: string): void => {
|
||||||
|
hydrationDebug.safeWindowAccess(
|
||||||
|
() => localStorage.removeItem(key),
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// 检查环境变量是否在客户端和服务器端一致
|
||||||
|
checkEnvConsistency: () => {
|
||||||
|
if (hydrationDebug.isDevelopment && hydrationDebug.isClient) {
|
||||||
|
const clientEnv = {
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
|
||||||
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.group('🔍 Environment Variables Check');
|
||||||
|
console.log('Client-side environment variables:', clientEnv);
|
||||||
|
|
||||||
|
// 检查是否有未定义的环境变量
|
||||||
|
Object.entries(clientEnv).forEach(([key, value]) => {
|
||||||
|
if (!value) {
|
||||||
|
console.warn(`⚠️ Environment variable ${key} is undefined`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在开发环境中自动运行环境变量检查
|
||||||
|
if (hydrationDebug.isDevelopment && hydrationDebug.isClient) {
|
||||||
|
// 延迟执行以避免在初始渲染时执行
|
||||||
|
setTimeout(() => {
|
||||||
|
hydrationDebug.checkEnvConsistency();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default hydrationDebug;
|
17
utils/useClientMount.ts
Normal file
17
utils/useClientMount.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检测组件是否在客户端已挂载的 hook
|
||||||
|
* 用于避免服务器端渲染和客户端渲染不一致导致的水合错误
|
||||||
|
*/
|
||||||
|
export function useClientMount(): boolean {
|
||||||
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return isMounted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useClientMount;
|
8
utils/useIsomorphicLayoutEffect.ts
Normal file
8
utils/useIsomorphicLayoutEffect.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { useEffect, useLayoutEffect } from 'react';
|
||||||
|
|
||||||
|
// 在服务器端使用 useEffect,在客户端使用 useLayoutEffect
|
||||||
|
// 这样可以避免服务器端渲染时的警告
|
||||||
|
export const useIsomorphicLayoutEffect =
|
||||||
|
typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||||
|
|
||||||
|
export default useIsomorphicLayoutEffect;
|
Loading…
x
Reference in New Issue
Block a user