feat: 前端页面重构 - Shadcn UI组件库集成、API层完善、测试覆盖
- 集成 Shadcn UI 组件库(dialog, form, table, tabs, select 等20+组件) - 重构所有管理页面(用户/角色/权限/订单/支付/文件/通知/内容/个人中心) - 完善 API 层(auth-api, rbac-api, content-api, user-api) - 新增 React Query hooks(use-auth, use-rbac, use-content, use-files 等) - 添加 AuthProvider 和 RouteGuard 组件 - 新增 131 个前端测试用例 - 修复 Provider 嵌套顺序问题 - 修复 hooks 参数传递问题 - 清理子项目 pnpm-lock.yaml 和 workspace 配置
This commit is contained in:
parent
bdb509a611
commit
72063651c3
|
|
@ -2,6 +2,18 @@ import type { NextConfig } from "next";
|
|||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
compress: true,
|
||||
poweredByHeader: false,
|
||||
generateEtags: true,
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
formats: ["image/avif", "image/webp"],
|
||||
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
|
||||
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
|
||||
},
|
||||
experimental: {
|
||||
optimizeCss: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -15,25 +15,44 @@
|
|||
"dependencies": {
|
||||
"@fischerx/types": "workspace:*",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tanstack/react-query": "^5.100.13",
|
||||
"@sentry/nextjs": "^8.0.0",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.21.0",
|
||||
"@opentelemetry/context-zone": "^1.21.0",
|
||||
"@opentelemetry/instrumentation-fetch": "^0.48.0",
|
||||
"@opentelemetry/instrumentation-xml-http-request": "^0.48.0",
|
||||
"@opentelemetry/sdk-trace-web": "^1.21.0",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/nextjs": "^8.0.0",
|
||||
"@tanstack/react-query": "^5.100.13",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.16.1",
|
||||
"web-vitals": "^4.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.3.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next": "16.2.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "18.0.0",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "18.0.0",
|
||||
"react-hook-form": "^7.76.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"web-vitals": "^4.0.0",
|
||||
"zod": "^4.4.3",
|
||||
"zustand": "^5.0.13"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +0,0 @@
|
|||
allowBuilds:
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
|
@ -1,11 +1,17 @@
|
|||
'use client'
|
||||
|
||||
import { RouteGuard } from "@/components/auth/route-guard"
|
||||
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
{children}
|
||||
</div>
|
||||
<RouteGuard mode="auth">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
{children}
|
||||
</div>
|
||||
</RouteGuard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,353 +1,240 @@
|
|||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
|
||||
type LoginTab = 'password' | 'sms';
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useLogin, useSmsLogin, useSendSmsCode } from '@/hooks/use-auth'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
|
||||
const passwordLoginSchema = z.object({
|
||||
email: z.string().email('Invalid email format').optional().or(z.literal('')),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format').optional().or(z.literal('')),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
}).refine((data) => {
|
||||
if (!data.email && !data.phone) {
|
||||
return {
|
||||
path: ['email'],
|
||||
message: 'Email or phone is required',
|
||||
};
|
||||
}
|
||||
return { path: ['email'], message: '' };
|
||||
});
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
|
||||
password: z.string().min(1, '请输入密码'),
|
||||
})
|
||||
|
||||
const smsLoginSchema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format'),
|
||||
code: z.string().regex(/^\d{6}$/, 'Verification code must be 6 digits'),
|
||||
});
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
|
||||
code: z.string().regex(/^\d{6}$/, '验证码必须为6位数字'),
|
||||
})
|
||||
|
||||
type PasswordLoginFormData = z.infer<typeof passwordLoginSchema>;
|
||||
type SmsLoginFormData = z.infer<typeof smsLoginSchema>;
|
||||
type PasswordLoginFormData = z.infer<typeof passwordLoginSchema>
|
||||
type SmsLoginFormData = z.infer<typeof smsLoginSchema>
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<LoginTab>('password');
|
||||
const [smsSent, setSmsSent] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const router = useRouter()
|
||||
const loginMutation = useLogin()
|
||||
const smsLoginMutation = useSmsLogin()
|
||||
const sendSmsMutation = useSendSmsCode()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [smsSent, setSmsSent] = useState(false)
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
const passwordForm = useForm<PasswordLoginFormData>({
|
||||
resolver: zodResolver(passwordLoginSchema),
|
||||
});
|
||||
})
|
||||
|
||||
const smsForm = useForm<SmsLoginFormData>({
|
||||
resolver: zodResolver(smsLoginSchema),
|
||||
});
|
||||
})
|
||||
|
||||
const handleSendSms = async () => {
|
||||
const phone = smsForm.getValues('phone');
|
||||
const phone = smsForm.getValues('phone')
|
||||
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
smsForm.setError('phone', { message: 'Please enter a valid phone number' });
|
||||
return;
|
||||
smsForm.setError('phone', { message: '请输入有效的手机号' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
await api.post('/auth/sms/send', { phone });
|
||||
setSmsSent(true);
|
||||
setCountdown(60);
|
||||
setError(null)
|
||||
await sendSmsMutation.mutateAsync({ phone })
|
||||
setSmsSent(true)
|
||||
setCountdown(60)
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer);
|
||||
setSmsSent(false);
|
||||
return 0;
|
||||
clearInterval(timer)
|
||||
setSmsSent(false)
|
||||
return 0
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'Failed to send verification code');
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
setError(e.response?.data?.message || '发送验证码失败')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onPasswordSubmit = async (data: PasswordLoginFormData) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const response = await api.post('/auth/login', {
|
||||
email: data.email || undefined,
|
||||
phone: data.phone || undefined,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
router.push('/dashboard');
|
||||
setError(null)
|
||||
await loginMutation.mutateAsync(data)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'Login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
setError(e.response?.data?.message || '登录失败')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const onSmsSubmit = async (data: SmsLoginFormData) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const response = await api.post('/auth/sms/login', {
|
||||
phone: data.phone,
|
||||
code: data.code,
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
router.push('/dashboard');
|
||||
setError(null)
|
||||
await smsLoginMutation.mutateAsync(data)
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'SMS login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
setError(e.response?.data?.message || '短信登录失败')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleWechatLogin = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const response = await api.post('/auth/wechat/login', {
|
||||
code: 'mock-wechat-code',
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'WeChat login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlipayLogin = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const response = await api.post('/auth/alipay/login', {
|
||||
code: 'mock-alipay-code',
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'Alipay login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const isLoading = loginMutation.isPending || smsLoginMutation.isPending
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Sign In</h1>
|
||||
<p className="text-muted-foreground">Choose your preferred login method</p>
|
||||
</div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>登录</CardTitle>
|
||||
<CardDescription>选择您偏好的登录方式</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="password">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="password">密码登录</TabsTrigger>
|
||||
<TabsTrigger value="sms">短信登录</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex border-b">
|
||||
<button
|
||||
className={`flex-1 py-2 text-center font-medium ${
|
||||
activeTab === 'password'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
onClick={() => setActiveTab('password')}
|
||||
>
|
||||
Password Login
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2 text-center font-medium ${
|
||||
activeTab === 'sms'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
onClick={() => setActiveTab('sms')}
|
||||
>
|
||||
SMS Login
|
||||
</button>
|
||||
</div>
|
||||
<TabsContent value="password">
|
||||
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="13800138000"
|
||||
{...passwordForm.register('phone')}
|
||||
/>
|
||||
{passwordForm.formState.errors.phone && (
|
||||
<p className="text-sm text-destructive">{passwordForm.formState.errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'password' && (
|
||||
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...passwordForm.register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
{passwordForm.formState.errors.email && (
|
||||
<p className="text-sm text-red-500">{passwordForm.formState.errors.email.message}</p>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••"
|
||||
{...passwordForm.register('password')}
|
||||
/>
|
||||
{passwordForm.formState.errors.password && (
|
||||
<p className="text-sm text-destructive">{passwordForm.formState.errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? '登录中...' : '登录'}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="sms">
|
||||
<form onSubmit={smsForm.handleSubmit(onSmsSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sms-phone">手机号</Label>
|
||||
<Input
|
||||
id="sms-phone"
|
||||
type="tel"
|
||||
placeholder="13800138000"
|
||||
{...smsForm.register('phone')}
|
||||
/>
|
||||
{smsForm.formState.errors.phone && (
|
||||
<p className="text-sm text-destructive">{smsForm.formState.errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sms-code">验证码</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="sms-code"
|
||||
type="text"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
{...smsForm.register('code')}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleSendSms}
|
||||
disabled={smsSent || sendSmsMutation.isPending}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{smsSent ? `${countdown}s` : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
{smsForm.formState.errors.code && (
|
||||
<p className="text-sm text-destructive">{smsForm.formState.errors.code.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? '登录中...' : '短信登录'}
|
||||
</Button>
|
||||
</form>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="relative my-6">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
{...passwordForm.register('phone')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="13800138000"
|
||||
/>
|
||||
{passwordForm.formState.errors.phone && (
|
||||
<p className="text-sm text-red-500">{passwordForm.formState.errors.phone.message}</p>
|
||||
)}
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-card px-2 text-muted-foreground">其他登录方式</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
{...passwordForm.register('password')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="••••••"
|
||||
/>
|
||||
{passwordForm.formState.errors.password && (
|
||||
<p className="text-sm text-red-500">{passwordForm.formState.errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{activeTab === 'sms' && (
|
||||
<form onSubmit={smsForm.handleSubmit(onSmsSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
{...smsForm.register('phone')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="13800138000"
|
||||
/>
|
||||
{smsForm.formState.errors.phone && (
|
||||
<p className="text-sm text-red-500">{smsForm.formState.errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Verification Code</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
{...smsForm.register('code')}
|
||||
className="flex-1 px-3 py-2 border rounded-md"
|
||||
placeholder="123456"
|
||||
maxLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendSms}
|
||||
disabled={smsSent || loading}
|
||||
className="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{smsSent ? `${countdown}s` : 'Send Code'}
|
||||
</button>
|
||||
</div>
|
||||
{smsForm.formState.errors.code && (
|
||||
<p className="text-sm text-red-500">{smsForm.formState.errors.code.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Signing in...' : 'Sign In with SMS'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button variant="outline" disabled={isLoading} className="w-full">
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="#07C160">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.504c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.934-6.446 1.707-1.415 3.738-2.19 5.77-2.19 3.568 0 6.69 2.19 6.69 5.504 0 3.314-3.122 5.504-6.69 5.504-.492 0-.973-.055-1.449-.136a.69.69 0 0 0-.578.136l-1.56 1.114a.326.326 0 0 1-.167.054.295.295 0 0 1-.29-.295c0-.072.029-.143.048-.213l.39-1.48a.59.59 0 0 0-.213-.665C19.829 13.707 21 11.716 21 9.504c0-4.028-3.891-7.316-8.691-7.316H8.691z"/>
|
||||
</svg>
|
||||
微信
|
||||
</Button>
|
||||
<Button variant="outline" disabled={isLoading} className="w-full">
|
||||
<svg className="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="#1677FF">
|
||||
<path d="M21.422 14.752c-1.386-.612-3.016-1.29-4.742-1.968.566-1.176.99-2.535 1.178-4.034h-3.57V7.5h4.5V6h-4.5V4.5h-3v1.5H8.25V7.5h4.5v1.25h-3.57c.188 1.499.612 2.858 1.178 4.034-1.726.678-3.356 1.356-4.742 1.968L4.5 18h15l-1.578-3.248zM12 16.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5z"/>
|
||||
</svg>
|
||||
支付宝
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={handleWechatLogin}
|
||||
disabled={loading}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="#07C160">
|
||||
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.504c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.934-6.446 1.707-1.415 3.738-2.19 5.77-2.19 3.568 0 6.69 2.19 6.69 5.504 0 3.314-3.122 5.504-6.69 5.504-.492 0-.973-.055-1.449-.136a.69.69 0 0 0-.578.136l-1.56 1.114a.326.326 0 0 1-.167.054.295.295 0 0 1-.29-.295c0-.072.029-.143.048-.213l.39-1.48a.59.59 0 0 0-.213-.665C19.829 13.707 21 11.716 21 9.504c0-4.028-3.891-7.316-8.691-7.316H8.691z"/>
|
||||
</svg>
|
||||
WeChat
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAlipayLogin}
|
||||
disabled={loading}
|
||||
className="flex items-center justify-center gap-2 py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="#1677FF">
|
||||
<path d="M21.422 14.752c-1.386-.612-3.016-1.29-4.742-1.968.566-1.176.99-2.535 1.178-4.034h-3.57V7.5h4.5V6h-4.5V4.5h-3v1.5H8.25V7.5h4.5v1.25h-3.57c.188 1.499.612 2.858 1.178 4.034-1.726.678-3.356 1.356-4.742 1.968L4.5 18h15l-1.578-3.248zM12 16.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5z"/>
|
||||
</svg>
|
||||
Alipay
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<Link href="/auth/register" className="text-primary hover:underline">
|
||||
Create account
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<Link href="/register" className="text-sm text-primary hover:underline">
|
||||
注册账号
|
||||
</Link>
|
||||
<Link href="/auth/forgot-password" className="text-primary hover:underline">
|
||||
Forgot password?
|
||||
<Link href="/forgot-password" className="text-sm text-primary hover:underline">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,197 +1,173 @@
|
|||
'use client';
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import api from '@/lib/api';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { useRegister } from '@/hooks/use-auth'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
|
||||
const registerSchema = z.object({
|
||||
email: z.string().email('Invalid email format').optional().or(z.literal('')),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format').optional().or(z.literal('')),
|
||||
username: z.string().min(3, 'Username must be at least 3 characters'),
|
||||
password: z.string().min(6, 'Password must be at least 6 characters'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号'),
|
||||
username: z.string().min(3, '用户名至少3个字符'),
|
||||
password: z.string().min(6, '密码至少6个字符'),
|
||||
confirmPassword: z.string(),
|
||||
agreeTerms: z.boolean().refine((val) => val === true, {
|
||||
message: 'You must agree to the terms and conditions',
|
||||
message: '请同意服务条款和隐私政策',
|
||||
}),
|
||||
}).refine((data) => {
|
||||
if (!data.email && !data.phone) {
|
||||
return {
|
||||
path: ['email'],
|
||||
message: 'Email or phone is required',
|
||||
};
|
||||
}
|
||||
if (data.password !== data.confirmPassword) {
|
||||
return {
|
||||
path: ['confirmPassword'],
|
||||
message: 'Passwords do not match',
|
||||
};
|
||||
message: '两次输入的密码不一致',
|
||||
}
|
||||
}
|
||||
return { path: ['email'], message: '' };
|
||||
});
|
||||
return { path: ['confirmPassword'], message: '' }
|
||||
})
|
||||
|
||||
type RegisterFormData = z.infer<typeof registerSchema>;
|
||||
type RegisterFormData = z.infer<typeof registerSchema>
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter()
|
||||
const registerMutation = useRegister()
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setValue,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<RegisterFormData>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: {
|
||||
agreeTerms: false,
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const agreeTerms = watch('agreeTerms')
|
||||
|
||||
const onSubmit = async (data: RegisterFormData) => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const response = await api.post('/auth/register', {
|
||||
email: data.email || undefined,
|
||||
phone: data.phone || undefined,
|
||||
setError(null)
|
||||
await registerMutation.mutateAsync({
|
||||
phone: data.phone,
|
||||
username: data.username,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
router.push('/dashboard');
|
||||
})
|
||||
router.push('/dashboard')
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'Registration failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const e = err as { response?: { data?: { message?: string } } }
|
||||
setError(e.response?.data?.message || '注册失败')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Create Account</h1>
|
||||
<p className="text-muted-foreground">Enter your details to get started</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
{...register('phone')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="13800138000"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-red-500">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('username')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="johndoe"
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-red-500">{errors.username.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('password')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="••••••"
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-500">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Confirm Password</label>
|
||||
<input
|
||||
type="password"
|
||||
{...register('confirmPassword')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="••••••"
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-red-500">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register('agreeTerms')}
|
||||
className="mt-1"
|
||||
/>
|
||||
<label className="text-sm">
|
||||
I agree to the{' '}
|
||||
<Link href="/terms" className="text-primary hover:underline">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/privacy" className="text-primary hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</label>
|
||||
</div>
|
||||
{errors.agreeTerms && (
|
||||
<p className="text-sm text-red-500">{errors.agreeTerms.message}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{error}
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<CardTitle>注册账号</CardTitle>
|
||||
<CardDescription>填写以下信息创建您的账号</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="phone">手机号</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="13800138000"
|
||||
{...register('phone')}
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-destructive">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">用户名</Label>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
{...register('username')}
|
||||
/>
|
||||
{errors.username && (
|
||||
<p className="text-sm text-destructive">{errors.username.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link href="/auth/login" className="text-primary hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="••••••"
|
||||
{...register('password')}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-destructive">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">确认密码</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="••••••"
|
||||
{...register('confirmPassword')}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">{errors.confirmPassword.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox
|
||||
id="agreeTerms"
|
||||
checked={agreeTerms}
|
||||
onCheckedChange={(checked) => setValue('agreeTerms', checked === true, { shouldValidate: true })}
|
||||
/>
|
||||
<Label htmlFor="agreeTerms" className="text-sm leading-none">
|
||||
我已阅读并同意{' '}
|
||||
<Link href="/terms" className="text-primary hover:underline">
|
||||
服务条款
|
||||
</Link>{' '}
|
||||
和{' '}
|
||||
<Link href="/privacy" className="text-primary hover:underline">
|
||||
隐私政策
|
||||
</Link>
|
||||
</Label>
|
||||
</div>
|
||||
{errors.agreeTerms && (
|
||||
<p className="text-sm text-destructive">{errors.agreeTerms.message}</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md text-destructive text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={registerMutation.isPending}>
|
||||
{registerMutation.isPending ? '注册中...' : '注册'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
已有账号?{' '}
|
||||
<Link href="/login" className="text-primary hover:underline">
|
||||
立即登录
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,311 +1,367 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
resource: string;
|
||||
action: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { MoreHorizontal, Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||
import {
|
||||
usePermissions,
|
||||
useCreatePermission,
|
||||
useUpdatePermission,
|
||||
useDeletePermission,
|
||||
} from '@/hooks/use-rbac';
|
||||
import type { Permission } from '@/lib/rbac-api';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export default function PermissionsManagementPage() {
|
||||
const [permissions, setPermissions] = useState<Permission[]>([]);
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [resourceFilter, setResourceFilter] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingPermission, setEditingPermission] = useState<Permission | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
resource: '',
|
||||
action: '',
|
||||
});
|
||||
const { data: permissionsData, isLoading } = usePermissions();
|
||||
const createPermission = useCreatePermission();
|
||||
const updatePermission = useUpdatePermission();
|
||||
const deletePermission = useDeletePermission();
|
||||
|
||||
useEffect(() => {
|
||||
fetchPermissions();
|
||||
}, [pagination.page, resourceFilter]);
|
||||
const permissions = permissionsData?.data?.data ?? [];
|
||||
|
||||
const fetchPermissions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/permissions', {
|
||||
params: {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
resource: resourceFilter || undefined,
|
||||
},
|
||||
});
|
||||
setPermissions(response.data.data.permissions);
|
||||
setPagination(response.data.data.pagination);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch permissions:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selectedPermission, setSelectedPermission] = useState<Permission | null>(null);
|
||||
const [permForm, setPermForm] = useState({ name: '', resource: '', action: '', description: '' });
|
||||
const [resourceFilter, setResourceFilter] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
|
||||
const filteredPermissions = permissions
|
||||
.filter((p) => resourceFilter === 'all' || p.resource === resourceFilter)
|
||||
.filter(
|
||||
(p) =>
|
||||
!searchQuery.trim() ||
|
||||
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.resource.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
p.action.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(p.description ?? '').toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!permForm.name.trim() || !permForm.resource.trim() || !permForm.action.trim()) {
|
||||
toast.error('请填写完整的权限信息');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await api.post('/permissions', formData);
|
||||
setShowCreateModal(false);
|
||||
setFormData({ name: '', description: '', resource: '', action: '' });
|
||||
fetchPermissions();
|
||||
} catch (err) {
|
||||
console.error('Failed to create permission:', err);
|
||||
alert('Failed to create permission');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingPermission) return;
|
||||
|
||||
try {
|
||||
await api.put(`/permissions/${editingPermission.id}`, formData);
|
||||
setEditingPermission(null);
|
||||
setFormData({ name: '', description: '', resource: '', action: '' });
|
||||
fetchPermissions();
|
||||
} catch (err) {
|
||||
console.error('Failed to update permission:', err);
|
||||
alert('Failed to update permission');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (permissionId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this permission?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/permissions/${permissionId}`);
|
||||
fetchPermissions();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete permission:', err);
|
||||
alert('Failed to delete permission');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (permission: Permission) => {
|
||||
setEditingPermission(permission);
|
||||
setFormData({
|
||||
name: permission.name,
|
||||
description: permission.description || '',
|
||||
resource: permission.resource,
|
||||
action: permission.action,
|
||||
createPermission.mutate(permForm, {
|
||||
onSuccess: () => {
|
||||
toast.success('权限创建成功');
|
||||
setCreateOpen(false);
|
||||
setPermForm({ name: '', resource: '', action: '', description: '' });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('权限创建失败');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
|
||||
const handleEdit = () => {
|
||||
if (!selectedPermission) return;
|
||||
if (!permForm.name.trim() || !permForm.resource.trim() || !permForm.action.trim()) {
|
||||
toast.error('请填写完整的权限信息');
|
||||
return;
|
||||
}
|
||||
updatePermission.mutate(
|
||||
{ id: selectedPermission.id, data: permForm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('权限更新成功');
|
||||
setEditOpen(false);
|
||||
setSelectedPermission(null);
|
||||
setPermForm({ name: '', resource: '', action: '', description: '' });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('权限更新失败');
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedPermission) return;
|
||||
deletePermission.mutate(selectedPermission.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('权限删除成功');
|
||||
setDeleteOpen(false);
|
||||
setSelectedPermission(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('权限删除失败');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = (permission: Permission) => {
|
||||
setSelectedPermission(permission);
|
||||
setPermForm({
|
||||
name: permission.name,
|
||||
resource: permission.resource,
|
||||
action: permission.action,
|
||||
description: permission.description ?? '',
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openDelete = (permission: Permission) => {
|
||||
setSelectedPermission(permission);
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Permission Management</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Create Permission
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold">权限管理</h1>
|
||||
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索权限名称、资源、操作..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={resourceFilter} onValueChange={setResourceFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="筛选资源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部资源</SelectItem>
|
||||
{resources.map((r) => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建权限
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<select
|
||||
value={resourceFilter}
|
||||
onChange={(e) => setResourceFilter(e.target.value)}
|
||||
className="px-3 py-2 border rounded-md"
|
||||
>
|
||||
<option value="">All Resources</option>
|
||||
{resources.map((resource) => (
|
||||
<option key={resource} value={resource}>
|
||||
{resource}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Resource
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : permissions.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
|
||||
No permissions found
|
||||
</td>
|
||||
</tr>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>权限名</TableHead>
|
||||
<TableHead>资源</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-8 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : filteredPermissions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
暂无权限数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
permissions.map((permission) => (
|
||||
<tr key={permission.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{permission.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded">
|
||||
filteredPermissions.map((permission) => (
|
||||
<TableRow key={permission.id}>
|
||||
<TableCell className="font-medium">{permission.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-blue-100 text-blue-700 hover:bg-blue-100">
|
||||
{permission.resource}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded">
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-100 text-green-700 hover:bg-green-100">
|
||||
{permission.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{permission.description || '-'}</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(permission)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(permission.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{permission.description || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEdit(permission)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openDelete(permission)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
|
||||
permissions
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建权限</DialogTitle>
|
||||
<DialogDescription>创建一个新的权限定义。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>权限名</Label>
|
||||
<Input
|
||||
value={permForm.name}
|
||||
onChange={(e) => setPermForm({ ...permForm, name: e.target.value })}
|
||||
placeholder="请输入权限名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>资源</Label>
|
||||
<Input
|
||||
value={permForm.resource}
|
||||
onChange={(e) => setPermForm({ ...permForm, resource: e.target.value })}
|
||||
placeholder="例如:user, role, file"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>操作</Label>
|
||||
<Input
|
||||
value={permForm.action}
|
||||
onChange={(e) => setPermForm({ ...permForm, action: e.target.value })}
|
||||
placeholder="例如:create, read, update, delete"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={permForm.description}
|
||||
onChange={(e) => setPermForm({ ...permForm, description: e.target.value })}
|
||||
placeholder="请输入权限描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={createPermission.isPending}>
|
||||
{createPermission.isPending ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{(showCreateModal || editingPermission) && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{editingPermission ? 'Edit Permission' : 'Create Permission'}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="e.g., user:create"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Resource</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.resource}
|
||||
onChange={(e) => setFormData({ ...formData, resource: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="e.g., user, file, role"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Action</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.action}
|
||||
onChange={(e) => setFormData({ ...formData, action: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="e.g., create, read, update, delete"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑权限</DialogTitle>
|
||||
<DialogDescription>修改权限的定义信息。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>权限名</Label>
|
||||
<Input
|
||||
value={permForm.name}
|
||||
onChange={(e) => setPermForm({ ...permForm, name: e.target.value })}
|
||||
placeholder="请输入权限名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setEditingPermission(null);
|
||||
setFormData({ name: '', description: '', resource: '', action: '' });
|
||||
}}
|
||||
className="px-4 py-2 border rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={editingPermission ? handleUpdate : handleCreate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{editingPermission ? 'Update' : 'Create'}
|
||||
</button>
|
||||
<div className="space-y-2">
|
||||
<Label>资源</Label>
|
||||
<Input
|
||||
value={permForm.resource}
|
||||
onChange={(e) => setPermForm({ ...permForm, resource: e.target.value })}
|
||||
placeholder="例如:user, role, file"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>操作</Label>
|
||||
<Input
|
||||
value={permForm.action}
|
||||
onChange={(e) => setPermForm({ ...permForm, action: e.target.value })}
|
||||
placeholder="例如:create, read, update, delete"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={permForm.description}
|
||||
onChange={(e) => setPermForm({ ...permForm, description: e.target.value })}
|
||||
placeholder="请输入权限描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>取消</Button>
|
||||
<Button onClick={handleEdit} disabled={updatePermission.isPending}>
|
||||
{updatePermission.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除权限「{selectedPermission?.name}」吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deletePermission.isPending}>
|
||||
{deletePermission.isPending ? '删除中...' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,175 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
|
||||
const mockRoles = [
|
||||
{
|
||||
id: 'r1',
|
||||
name: '管理员',
|
||||
description: '系统管理员',
|
||||
permissions: [
|
||||
{ id: 'p1', name: '用户创建', resource: 'user', action: 'create', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'p2', name: '用户删除', resource: 'user', action: 'delete', createdAt: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
createdAt: '2026-01-15T08:00:00Z',
|
||||
updatedAt: '2026-01-15T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
name: '编辑者',
|
||||
description: '内容编辑',
|
||||
permissions: [
|
||||
{ id: 'p3', name: '内容编辑', resource: 'content', action: 'update', createdAt: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
createdAt: '2026-02-20T10:30:00Z',
|
||||
updatedAt: '2026-02-20T10:30:00Z',
|
||||
},
|
||||
]
|
||||
|
||||
const mockPermissions = [
|
||||
{ id: 'p1', name: '用户创建', resource: 'user', action: 'create', description: '创建用户', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'p2', name: '用户删除', resource: 'user', action: 'delete', description: '删除用户', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'p3', name: '内容编辑', resource: 'content', action: 'update', description: '编辑内容', createdAt: '2026-01-01T00:00:00Z' },
|
||||
]
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/admin/roles',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-rbac', () => ({
|
||||
useRoles: () => ({
|
||||
data: { data: { data: mockRoles } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useRole: () => ({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
}),
|
||||
useCreateRole: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateRole: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteRole: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
usePermissions: () => ({
|
||||
data: { data: { data: mockPermissions } },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useCreatePermission: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdatePermission: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeletePermission: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useAssignRolePermissions: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useAssignUserRoles: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('RolesManagementPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders page title', async () => {
|
||||
const { default: RolesManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/roles/page'
|
||||
)
|
||||
render(<RolesManagementPage />)
|
||||
|
||||
expect(screen.getByText('角色与权限管理')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders both tabs', async () => {
|
||||
const { default: RolesManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/roles/page'
|
||||
)
|
||||
render(<RolesManagementPage />)
|
||||
|
||||
expect(screen.getByText('角色管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('权限管理')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders role list with correct data', async () => {
|
||||
const { default: RolesManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/roles/page'
|
||||
)
|
||||
render(<RolesManagementPage />)
|
||||
|
||||
expect(screen.getByText('管理员')).toBeInTheDocument()
|
||||
expect(screen.getByText('编辑者')).toBeInTheDocument()
|
||||
expect(screen.getByText('系统管理员')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders permission count badges', async () => {
|
||||
const { default: RolesManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/roles/page'
|
||||
)
|
||||
render(<RolesManagementPage />)
|
||||
|
||||
expect(screen.getByText('2')).toBeInTheDocument()
|
||||
expect(screen.getByText('1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders create role button', async () => {
|
||||
const { default: RolesManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/roles/page'
|
||||
)
|
||||
render(<RolesManagementPage />)
|
||||
|
||||
expect(screen.getByText('新建角色')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders permissions tab trigger', async () => {
|
||||
const { default: RolesManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/roles/page'
|
||||
)
|
||||
render(<RolesManagementPage />)
|
||||
|
||||
expect(screen.getByRole('tab', { name: '权限管理' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: '角色管理' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: '权限管理' })).toHaveAttribute('aria-selected', 'false')
|
||||
})
|
||||
|
||||
it('renders table headers in roles tab', async () => {
|
||||
const { default: RolesManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/roles/page'
|
||||
)
|
||||
render(<RolesManagementPage />)
|
||||
|
||||
expect(screen.getByText('角色名')).toBeInTheDocument()
|
||||
expect(screen.getByText('描述')).toBeInTheDocument()
|
||||
expect(screen.getByText('权限数量')).toBeInTheDocument()
|
||||
expect(screen.getByText('创建时间')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,302 +1,698 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import api from '@/lib/api';
|
||||
|
||||
interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
isSystem: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
_count: {
|
||||
users: number;
|
||||
permissions: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { MoreHorizontal, Plus, Pencil, Shield, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
useRoles,
|
||||
useCreateRole,
|
||||
useUpdateRole,
|
||||
useDeleteRole,
|
||||
usePermissions,
|
||||
useCreatePermission,
|
||||
useUpdatePermission,
|
||||
useDeletePermission,
|
||||
useAssignRolePermissions,
|
||||
} from '@/hooks/use-rbac';
|
||||
import type { Role, Permission } from '@/lib/rbac-api';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
export default function RolesManagementPage() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
isSystem: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchRoles();
|
||||
}, [pagination.page, search]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await api.get('/roles', {
|
||||
params: {
|
||||
page: pagination.page,
|
||||
limit: pagination.limit,
|
||||
search: search || undefined,
|
||||
},
|
||||
});
|
||||
setRoles(response.data.data.roles);
|
||||
setPagination(response.data.data.pagination);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch roles:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
try {
|
||||
await api.post('/roles', formData);
|
||||
setShowCreateModal(false);
|
||||
setFormData({ name: '', description: '', isSystem: false });
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
console.error('Failed to create role:', err);
|
||||
alert('Failed to create role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!editingRole) return;
|
||||
|
||||
try {
|
||||
await api.put(`/roles/${editingRole.id}`, formData);
|
||||
setEditingRole(null);
|
||||
setFormData({ name: '', description: '', isSystem: false });
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
console.error('Failed to update role:', err);
|
||||
alert('Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (roleId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this role?')) return;
|
||||
|
||||
try {
|
||||
await api.delete(`/roles/${roleId}`);
|
||||
fetchRoles();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete role:', err);
|
||||
alert('Failed to delete role');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditModal = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
setFormData({
|
||||
name: role.name,
|
||||
description: role.description || '',
|
||||
isSystem: role.isSystem,
|
||||
});
|
||||
};
|
||||
const [activeTab, setActiveTab] = useState('roles');
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">Role Management</h1>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Create Role
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search roles..."
|
||||
className="flex-1 px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Description
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Users
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
Permissions
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
System
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : roles.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
No roles found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<tr key={role.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3 font-medium">{role.name}</td>
|
||||
<td className="px-4 py-3 text-sm">{role.description || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">{role._count.users}</td>
|
||||
<td className="px-4 py-3 text-sm">{role._count.permissions}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full ${
|
||||
role.isSystem
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{role.isSystem ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() => openEditModal(role)}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
{!role.isSystem && (
|
||||
<button
|
||||
onClick={() => handleDelete(role.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
|
||||
roles
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
Page {pagination.page} of {pagination.totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showCreateModal || editingRole) && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{editingRole ? 'Edit Role' : 'Create Role'}
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="e.g., admin, user, editor"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.isSystem}
|
||||
onChange={(e) => setFormData({ ...formData, isSystem: e.target.checked })}
|
||||
className="mr-2"
|
||||
/>
|
||||
<label className="text-sm">System Role (cannot be deleted)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setEditingRole(null);
|
||||
setFormData({ name: '', description: '', isSystem: false });
|
||||
}}
|
||||
className="px-4 py-2 border rounded-md hover:bg-gray-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={editingRole ? handleUpdate : handleCreate}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{editingRole ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-2xl font-bold">角色与权限管理</h1>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="roles">角色管理</TabsTrigger>
|
||||
<TabsTrigger value="permissions">权限管理</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="roles">
|
||||
<RolesTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="permissions">
|
||||
<PermissionsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RolesTab() {
|
||||
const { data: rolesData, isLoading: rolesLoading } = useRoles();
|
||||
const { data: permissionsData } = usePermissions();
|
||||
const createRole = useCreateRole();
|
||||
const updateRole = useUpdateRole();
|
||||
const deleteRole = useDeleteRole();
|
||||
const assignRolePermissions = useAssignRolePermissions();
|
||||
|
||||
const roles = rolesData?.data?.data ?? [];
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||
const [roleForm, setRoleForm] = useState({ name: '', description: '' });
|
||||
const [selectedPermissionIds, setSelectedPermissionIds] = useState<string[]>([]);
|
||||
|
||||
const allPermissions = permissionsData?.data?.data ?? [];
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!roleForm.name.trim()) {
|
||||
toast.error('请输入角色名称');
|
||||
return;
|
||||
}
|
||||
createRole.mutate(roleForm, {
|
||||
onSuccess: () => {
|
||||
toast.success('角色创建成功');
|
||||
setCreateOpen(false);
|
||||
setRoleForm({ name: '', description: '' });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('角色创建失败');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!selectedRole) return;
|
||||
if (!roleForm.name.trim()) {
|
||||
toast.error('请输入角色名称');
|
||||
return;
|
||||
}
|
||||
updateRole.mutate(
|
||||
{ id: selectedRole.id, data: roleForm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('角色更新成功');
|
||||
setEditOpen(false);
|
||||
setSelectedRole(null);
|
||||
setRoleForm({ name: '', description: '' });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('角色更新失败');
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedRole) return;
|
||||
deleteRole.mutate(selectedRole.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('角色删除成功');
|
||||
setDeleteOpen(false);
|
||||
setSelectedRole(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('角色删除失败');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleAssignPermissions = () => {
|
||||
if (!selectedRole) return;
|
||||
assignRolePermissions.mutate(
|
||||
{ roleId: selectedRole.id, data: { permissionIds: selectedPermissionIds } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('权限分配成功');
|
||||
setAssignOpen(false);
|
||||
setSelectedRole(null);
|
||||
setSelectedPermissionIds([]);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('权限分配失败');
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openEdit = (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setRoleForm({ name: role.name, description: role.description ?? '' });
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openDelete = (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
const openAssign = (role: Role) => {
|
||||
setSelectedRole(role);
|
||||
setSelectedPermissionIds(role.permissions.map((p) => p.id));
|
||||
setAssignOpen(true);
|
||||
};
|
||||
|
||||
const togglePermission = (permissionId: string) => {
|
||||
setSelectedPermissionIds((prev) =>
|
||||
prev.includes(permissionId)
|
||||
? prev.filter((id) => id !== permissionId)
|
||||
: [...prev, permissionId],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>角色名</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead>权限数量</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{rolesLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-8 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
暂无角色数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell className="font-medium">{role.name}</TableCell>
|
||||
<TableCell>{role.description || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{role.permissions.length}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{new Date(role.createdAt).toLocaleDateString('zh-CN')}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEdit(role)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openAssign(role)}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
分配权限
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openDelete(role)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建角色</DialogTitle>
|
||||
<DialogDescription>创建一个新的角色,创建后可分配权限。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>角色名</Label>
|
||||
<Input
|
||||
value={roleForm.name}
|
||||
onChange={(e) => setRoleForm({ ...roleForm, name: e.target.value })}
|
||||
placeholder="请输入角色名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={roleForm.description}
|
||||
onChange={(e) => setRoleForm({ ...roleForm, description: e.target.value })}
|
||||
placeholder="请输入角色描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={createRole.isPending}>
|
||||
{createRole.isPending ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑角色</DialogTitle>
|
||||
<DialogDescription>修改角色的名称和描述。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>角色名</Label>
|
||||
<Input
|
||||
value={roleForm.name}
|
||||
onChange={(e) => setRoleForm({ ...roleForm, name: e.target.value })}
|
||||
placeholder="请输入角色名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={roleForm.description}
|
||||
onChange={(e) => setRoleForm({ ...roleForm, description: e.target.value })}
|
||||
placeholder="请输入角色描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>取消</Button>
|
||||
<Button onClick={handleEdit} disabled={updateRole.isPending}>
|
||||
{updateRole.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={assignOpen} onOpenChange={setAssignOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>分配权限</DialogTitle>
|
||||
<DialogDescription>
|
||||
为角色「{selectedRole?.name}」分配权限。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-80 overflow-y-auto space-y-3">
|
||||
{allPermissions.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">暂无可分配的权限</p>
|
||||
) : (
|
||||
allPermissions.map((permission) => (
|
||||
<div key={permission.id} className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id={`perm-${permission.id}`}
|
||||
checked={selectedPermissionIds.includes(permission.id)}
|
||||
onCheckedChange={() => togglePermission(permission.id)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`perm-${permission.id}`}
|
||||
className="text-sm leading-none cursor-pointer flex-1"
|
||||
>
|
||||
<span className="font-medium">{permission.name}</span>
|
||||
<span className="text-muted-foreground ml-2">
|
||||
({permission.resource}:{permission.action})
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAssignOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAssignPermissions} disabled={assignRolePermissions.isPending}>
|
||||
{assignRolePermissions.isPending ? '分配中...' : '确认分配'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除角色「{selectedRole?.name}」吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleteRole.isPending}>
|
||||
{deleteRole.isPending ? '删除中...' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PermissionsTab() {
|
||||
const { data: permissionsData, isLoading: permissionsLoading } = usePermissions();
|
||||
const createPermission = useCreatePermission();
|
||||
const updatePermission = useUpdatePermission();
|
||||
const deletePermission = useDeletePermission();
|
||||
|
||||
const permissions = permissionsData?.data?.data ?? [];
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selectedPermission, setSelectedPermission] = useState<Permission | null>(null);
|
||||
const [permForm, setPermForm] = useState({ name: '', resource: '', action: '', description: '' });
|
||||
const [resourceFilter, setResourceFilter] = useState('all');
|
||||
|
||||
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
|
||||
const filteredPermissions =
|
||||
resourceFilter === 'all'
|
||||
? permissions
|
||||
: permissions.filter((p) => p.resource === resourceFilter);
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!permForm.name.trim() || !permForm.resource.trim() || !permForm.action.trim()) {
|
||||
toast.error('请填写完整的权限信息');
|
||||
return;
|
||||
}
|
||||
createPermission.mutate(permForm, {
|
||||
onSuccess: () => {
|
||||
toast.success('权限创建成功');
|
||||
setCreateOpen(false);
|
||||
setPermForm({ name: '', resource: '', action: '', description: '' });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('权限创建失败');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
if (!selectedPermission) return;
|
||||
if (!permForm.name.trim() || !permForm.resource.trim() || !permForm.action.trim()) {
|
||||
toast.error('请填写完整的权限信息');
|
||||
return;
|
||||
}
|
||||
updatePermission.mutate(
|
||||
{ id: selectedPermission.id, data: permForm },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('权限更新成功');
|
||||
setEditOpen(false);
|
||||
setSelectedPermission(null);
|
||||
setPermForm({ name: '', resource: '', action: '', description: '' });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('权限更新失败');
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selectedPermission) return;
|
||||
deletePermission.mutate(selectedPermission.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('权限删除成功');
|
||||
setDeleteOpen(false);
|
||||
setSelectedPermission(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('权限删除失败');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const openEdit = (permission: Permission) => {
|
||||
setSelectedPermission(permission);
|
||||
setPermForm({
|
||||
name: permission.name,
|
||||
resource: permission.resource,
|
||||
action: permission.action,
|
||||
description: permission.description ?? '',
|
||||
});
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const openDelete = (permission: Permission) => {
|
||||
setSelectedPermission(permission);
|
||||
setDeleteOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<Select value={resourceFilter} onValueChange={setResourceFilter}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="筛选资源" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部资源</SelectItem>
|
||||
{resources.map((r) => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建权限
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>权限名</TableHead>
|
||||
<TableHead>资源</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{permissionsLoading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-8 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : filteredPermissions.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
暂无权限数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredPermissions.map((permission) => (
|
||||
<TableRow key={permission.id}>
|
||||
<TableCell className="font-medium">{permission.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-blue-100 text-blue-700 hover:bg-blue-100">
|
||||
{permission.resource}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-green-100 text-green-700 hover:bg-green-100">
|
||||
{permission.action}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{permission.description || '-'}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => openEdit(permission)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => openDelete(permission)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建权限</DialogTitle>
|
||||
<DialogDescription>创建一个新的权限定义。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>权限名</Label>
|
||||
<Input
|
||||
value={permForm.name}
|
||||
onChange={(e) => setPermForm({ ...permForm, name: e.target.value })}
|
||||
placeholder="请输入权限名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>资源</Label>
|
||||
<Input
|
||||
value={permForm.resource}
|
||||
onChange={(e) => setPermForm({ ...permForm, resource: e.target.value })}
|
||||
placeholder="例如:user, role, file"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>操作</Label>
|
||||
<Input
|
||||
value={permForm.action}
|
||||
onChange={(e) => setPermForm({ ...permForm, action: e.target.value })}
|
||||
placeholder="例如:create, read, update, delete"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={permForm.description}
|
||||
onChange={(e) => setPermForm({ ...permForm, description: e.target.value })}
|
||||
placeholder="请输入权限描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={createPermission.isPending}>
|
||||
{createPermission.isPending ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editOpen} onOpenChange={setEditOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑权限</DialogTitle>
|
||||
<DialogDescription>修改权限的定义信息。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>权限名</Label>
|
||||
<Input
|
||||
value={permForm.name}
|
||||
onChange={(e) => setPermForm({ ...permForm, name: e.target.value })}
|
||||
placeholder="请输入权限名称"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>资源</Label>
|
||||
<Input
|
||||
value={permForm.resource}
|
||||
onChange={(e) => setPermForm({ ...permForm, resource: e.target.value })}
|
||||
placeholder="例如:user, role, file"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>操作</Label>
|
||||
<Input
|
||||
value={permForm.action}
|
||||
onChange={(e) => setPermForm({ ...permForm, action: e.target.value })}
|
||||
placeholder="例如:create, read, update, delete"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>描述</Label>
|
||||
<Textarea
|
||||
value={permForm.description}
|
||||
onChange={(e) => setPermForm({ ...permForm, description: e.target.value })}
|
||||
placeholder="请输入权限描述"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditOpen(false)}>取消</Button>
|
||||
<Button onClick={handleEdit} disabled={updatePermission.isPending}>
|
||||
{updatePermission.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除权限「{selectedPermission?.name}」吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deletePermission.isPending}>
|
||||
{deletePermission.isPending ? '删除中...' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,205 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useUser, useUpdateUser } from '@/hooks/use-users';
|
||||
import { User } from '@/lib/user-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Pencil,
|
||||
Shield,
|
||||
Ban,
|
||||
CheckCircle,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type UserStatus = 'active' | 'inactive' | 'locked';
|
||||
|
||||
function getUserStatus(user: User): UserStatus {
|
||||
if (!user.isActive) return 'inactive';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: UserStatus }) {
|
||||
const config: Record<UserStatus, { label: string; className: string }> = {
|
||||
active: { label: '活跃', className: 'bg-green-100 text-green-700 hover:bg-green-100' },
|
||||
inactive: { label: '禁用', className: 'bg-gray-100 text-gray-700 hover:bg-gray-100' },
|
||||
locked: { label: '锁定', className: 'bg-red-100 text-red-700 hover:bg-red-100' },
|
||||
};
|
||||
const { label, className } = config[status];
|
||||
return <Badge variant="secondary" className={className}>{label}</Badge>;
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-16 w-16 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-16" />
|
||||
<Skeleton className="h-5 w-32" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const userId = params.id as string;
|
||||
|
||||
const { data, isLoading } = useUser(userId);
|
||||
const updateUser = useUpdateUser();
|
||||
|
||||
const user = (data?.data?.data as User) ?? null;
|
||||
|
||||
const handleToggleActive = useCallback(() => {
|
||||
if (!user) return;
|
||||
const action = user.isActive ? '禁用' : '启用';
|
||||
updateUser.mutate(
|
||||
{ id: user.id, data: { isActive: !user.isActive } },
|
||||
{
|
||||
onSuccess: () => toast.success(`已${action}用户 ${user.username}`),
|
||||
onError: () => toast.error(`${action}用户失败,请重试`),
|
||||
},
|
||||
);
|
||||
}, [user, updateUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/admin/users')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<DetailSkeleton />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/admin/users')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">用户详情</h1>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
用户不存在或已被删除
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="icon" onClick={() => router.push('/admin/users')}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">用户详情</h1>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
{user.avatar && <AvatarImage src={user.avatar} alt={user.username} />}
|
||||
<AvatarFallback className="text-xl">
|
||||
{user.firstName?.[0] || user.username[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<CardTitle className="text-xl">{user.username}</CardTitle>
|
||||
<div className="mt-1">
|
||||
<StatusBadge status={getUserStatus(user)} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={() => router.push('/admin/users')}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleToggleActive}>
|
||||
{user.isActive ? (
|
||||
<><Ban className="mr-2 h-4 w-4" />禁用</>
|
||||
) : (
|
||||
<><CheckCircle className="mr-2 h-4 w-4" />启用</>
|
||||
)}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => router.push(`/admin/users/${user.id}/permissions`)}>
|
||||
<Shield className="mr-2 h-4 w-4" />
|
||||
分配角色
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<Separator />
|
||||
<CardContent className="pt-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">用户名</p>
|
||||
<p className="font-medium">{user.username}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">手机号</p>
|
||||
<p className="font-medium">{user.phone || '-'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">邮箱</p>
|
||||
<p className="font-medium">{user.email || '-'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">状态</p>
|
||||
<StatusBadge status={getUserStatus(user)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">角色</p>
|
||||
<p className="font-medium">-</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">创建时间</p>
|
||||
<p className="font-medium">
|
||||
{new Date(user.createdAt).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">最后登录时间</p>
|
||||
<p className="font-medium">
|
||||
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/admin/users',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-users', () => ({
|
||||
useUsers: () => ({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
users: [
|
||||
{
|
||||
id: 'u1',
|
||||
username: '张三',
|
||||
phone: '13800138001',
|
||||
email: 'zhangsan@example.com',
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
phoneVerified: true,
|
||||
createdAt: '2026-01-15T08:00:00Z',
|
||||
updatedAt: '2026-01-15T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
username: '李四',
|
||||
phone: '13800138002',
|
||||
email: 'lisi@example.com',
|
||||
isActive: false,
|
||||
emailVerified: false,
|
||||
phoneVerified: false,
|
||||
createdAt: '2026-02-20T10:30:00Z',
|
||||
updatedAt: '2026-02-20T10:30:00Z',
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 20, total: 2, totalPages: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useCreateUser: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateUser: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteUser: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('UsersManagementPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders page title', async () => {
|
||||
const { default: UsersManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/users/page'
|
||||
)
|
||||
render(<UsersManagementPage />)
|
||||
|
||||
expect(screen.getByText('用户管理')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', async () => {
|
||||
const { default: UsersManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/users/page'
|
||||
)
|
||||
render(<UsersManagementPage />)
|
||||
|
||||
expect(screen.getByPlaceholderText('搜索用户名或手机号...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status filter', async () => {
|
||||
const { default: UsersManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/users/page'
|
||||
)
|
||||
render(<UsersManagementPage />)
|
||||
|
||||
expect(screen.getByText('全部状态')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders user list with correct data', async () => {
|
||||
const { default: UsersManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/users/page'
|
||||
)
|
||||
render(<UsersManagementPage />)
|
||||
|
||||
expect(screen.getByText('张三')).toBeInTheDocument()
|
||||
expect(screen.getByText('李四')).toBeInTheDocument()
|
||||
expect(screen.getByText('13800138001')).toBeInTheDocument()
|
||||
expect(screen.getByText('lisi@example.com')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status badges', async () => {
|
||||
const { default: UsersManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/users/page'
|
||||
)
|
||||
render(<UsersManagementPage />)
|
||||
|
||||
expect(screen.getByText('活跃')).toBeInTheDocument()
|
||||
expect(screen.getByText('禁用')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders create user button', async () => {
|
||||
const { default: UsersManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/users/page'
|
||||
)
|
||||
render(<UsersManagementPage />)
|
||||
|
||||
expect(screen.getByText('新建用户')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders table headers', async () => {
|
||||
const { default: UsersManagementPage } = await import(
|
||||
'@/app/(dashboard)/admin/users/page'
|
||||
)
|
||||
render(<UsersManagementPage />)
|
||||
|
||||
expect(screen.getByText('头像')).toBeInTheDocument()
|
||||
expect(screen.getByText('用户名')).toBeInTheDocument()
|
||||
expect(screen.getByText('手机号')).toBeInTheDocument()
|
||||
expect(screen.getByText('邮箱')).toBeInTheDocument()
|
||||
expect(screen.getByText('状态')).toBeInTheDocument()
|
||||
expect(screen.getByText('角色')).toBeInTheDocument()
|
||||
expect(screen.getByText('创建时间')).toBeInTheDocument()
|
||||
expect(screen.getByText('操作')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,25 +1,151 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useUsers, useUpdateUser, useDeleteUser } from '@/hooks/use-users';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod/v4';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useUsers, useCreateUser, useUpdateUser, useDeleteUser } from '@/hooks/use-users';
|
||||
import { User, UserListResponse } from '@/lib/user-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
MoreHorizontal,
|
||||
Eye,
|
||||
Pencil,
|
||||
Ban,
|
||||
CheckCircle,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type UserStatus = 'active' | 'inactive' | 'locked';
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ id: 'admin', label: '管理员' },
|
||||
{ id: 'editor', label: '编辑者' },
|
||||
{ id: 'viewer', label: '查看者' },
|
||||
];
|
||||
|
||||
const createUserSchema = z.object({
|
||||
username: z.string().min(2, '用户名至少2个字符').max(50, '用户名最多50个字符'),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email('请输入有效的邮箱地址').optional().or(z.literal('')),
|
||||
password: z.string().min(6, '密码至少6个字符').max(100, '密码最多100个字符'),
|
||||
roles: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
const editUserSchema = z.object({
|
||||
username: z.string().min(2, '用户名至少2个字符').max(50, '用户名最多50个字符'),
|
||||
phone: z.string().optional(),
|
||||
email: z.string().email('请输入有效的邮箱地址').optional().or(z.literal('')),
|
||||
roles: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
type CreateUserFormValues = z.infer<typeof createUserSchema>;
|
||||
type EditUserFormValues = z.infer<typeof editUserSchema>;
|
||||
|
||||
function getUserStatus(user: User): UserStatus {
|
||||
if (!user.isActive) return 'inactive';
|
||||
return 'active';
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: UserStatus }) {
|
||||
const config: Record<UserStatus, { label: string; className: string }> = {
|
||||
active: { label: '活跃', className: 'bg-green-100 text-green-700 hover:bg-green-100' },
|
||||
inactive: { label: '禁用', className: 'bg-gray-100 text-gray-700 hover:bg-gray-100' },
|
||||
locked: { label: '锁定', className: 'bg-red-100 text-red-700 hover:bg-red-100' },
|
||||
};
|
||||
const { label, className } = config[status];
|
||||
return <Badge variant="secondary" className={className}>{label}</Badge>;
|
||||
}
|
||||
|
||||
function UserTableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-8 w-8 rounded-full" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-12 rounded-full" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-8 w-8" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UsersManagementPage() {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(20);
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingUser, setEditingUser] = useState<{
|
||||
id: string;
|
||||
username: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
} | null>(null);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deletingUser, setDeletingUser] = useState<User | null>(null);
|
||||
|
||||
const { data, isLoading } = useUsers(page, limit, search || undefined);
|
||||
const createUser = useCreateUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
|
|
@ -27,292 +153,492 @@ export default function UsersManagementPage() {
|
|||
const users: User[] = responseData?.users ?? [];
|
||||
const pagination = responseData?.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 };
|
||||
|
||||
const handleToggleActive = (userId: string, currentActive: boolean) => {
|
||||
const filteredUsers = users.filter((user) => {
|
||||
if (statusFilter === 'all') return true;
|
||||
return getUserStatus(user) === statusFilter;
|
||||
});
|
||||
|
||||
const createForm = useForm<CreateUserFormValues>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: { username: '', phone: '', email: '', password: '', roles: [] },
|
||||
});
|
||||
|
||||
const editForm = useForm<EditUserFormValues>({
|
||||
resolver: zodResolver(editUserSchema),
|
||||
defaultValues: { username: '', phone: '', email: '', roles: [] },
|
||||
});
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
}, [searchInput]);
|
||||
|
||||
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
const handleOpenCreateDialog = useCallback(() => {
|
||||
setEditingUser(null);
|
||||
createForm.reset({ username: '', phone: '', email: '', password: '', roles: [] });
|
||||
setDialogOpen(true);
|
||||
}, [createForm]);
|
||||
|
||||
const handleOpenEditDialog = useCallback((user: User) => {
|
||||
setEditingUser(user);
|
||||
editForm.reset({
|
||||
username: user.username,
|
||||
phone: user.phone ?? '',
|
||||
email: user.email ?? '',
|
||||
roles: [],
|
||||
});
|
||||
setDialogOpen(true);
|
||||
}, [editForm]);
|
||||
|
||||
const handleToggleActive = useCallback((user: User) => {
|
||||
const action = user.isActive ? '禁用' : '启用';
|
||||
updateUser.mutate(
|
||||
{ id: userId, data: { isActive: !currentActive } },
|
||||
{ id: user.id, data: { isActive: !user.isActive } },
|
||||
{
|
||||
onSuccess: () => toast.success(`已${action}用户 ${user.username}`),
|
||||
onError: () => toast.error(`${action}用户失败,请重试`),
|
||||
},
|
||||
);
|
||||
}, [updateUser]);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (!deletingUser) return;
|
||||
deleteUser.mutate(deletingUser.id, {
|
||||
onSuccess: () => {
|
||||
toast.success(`已删除用户 ${deletingUser.username}`);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingUser(null);
|
||||
},
|
||||
onError: () => toast.error('删除用户失败,请重试'),
|
||||
});
|
||||
}, [deletingUser, deleteUser]);
|
||||
|
||||
const handleCreateSubmit = useCallback((values: CreateUserFormValues) => {
|
||||
createUser.mutate(
|
||||
{
|
||||
username: values.username,
|
||||
phone: values.phone || undefined,
|
||||
email: values.email || undefined,
|
||||
password: values.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
alert(currentActive ? '已禁用用户' : '已启用用户');
|
||||
toast.success('用户创建成功');
|
||||
setDialogOpen(false);
|
||||
createForm.reset();
|
||||
},
|
||||
onError: () => {
|
||||
alert('操作失败,请重试');
|
||||
},
|
||||
}
|
||||
onError: () => toast.error('创建用户失败,请重试'),
|
||||
},
|
||||
);
|
||||
};
|
||||
}, [createUser, createForm]);
|
||||
|
||||
const handleDelete = (userId: string) => {
|
||||
if (!confirm('确定要删除该用户吗?此操作不可撤销。')) return;
|
||||
deleteUser.mutate(userId, {
|
||||
onSuccess: () => {
|
||||
alert('用户已删除');
|
||||
},
|
||||
onError: () => {
|
||||
alert('删除失败,请重试');
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const handleEditSubmit = useCallback((values: EditUserFormValues) => {
|
||||
if (!editingUser) return;
|
||||
|
||||
updateUser.mutate(
|
||||
{
|
||||
id: editingUser.id,
|
||||
data: {
|
||||
username: editingUser.username,
|
||||
firstName: editingUser.firstName || undefined,
|
||||
lastName: editingUser.lastName || undefined,
|
||||
email: editingUser.email || undefined,
|
||||
phone: editingUser.phone || undefined,
|
||||
username: values.username,
|
||||
phone: values.phone || undefined,
|
||||
email: values.email || undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('用户信息已更新');
|
||||
setDialogOpen(false);
|
||||
setEditingUser(null);
|
||||
alert('用户信息已更新');
|
||||
},
|
||||
onError: () => {
|
||||
alert('更新失败,请重试');
|
||||
},
|
||||
}
|
||||
onError: () => toast.error('更新用户失败,请重试'),
|
||||
},
|
||||
);
|
||||
}, [editingUser, updateUser]);
|
||||
|
||||
const renderPageNumbers = () => {
|
||||
const pages: number[] = [];
|
||||
const totalPages = pagination.totalPages;
|
||||
const current = pagination.page;
|
||||
const start = Math.max(1, current - 2);
|
||||
const end = Math.min(totalPages, current + 2);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
||||
<Button onClick={handleOpenCreateDialog}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建用户
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="搜索用户..."
|
||||
className="flex-1 px-3 py-2 border rounded-md"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名或手机号..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="active">活跃</SelectItem>
|
||||
<SelectItem value="inactive">禁用</SelectItem>
|
||||
<SelectItem value="locked">锁定</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={handleSearch}>搜索</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
用户
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
邮箱
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
手机
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
|
||||
暂无用户数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="" className="w-full h-full object-cover" />
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>头像</TableHead>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>手机号</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<UserTableSkeleton />
|
||||
) : filteredUsers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="h-24 text-center text-muted-foreground">
|
||||
暂无用户数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredUsers.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<Avatar className="h-8 w-8">
|
||||
{user.avatar && <AvatarImage src={user.avatar} alt={user.username} />}
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.firstName?.[0] || user.username[0]}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">{user.username}</TableCell>
|
||||
<TableCell>{user.phone || '-'}</TableCell>
|
||||
<TableCell>{user.email || '-'}</TableCell>
|
||||
<TableCell><StatusBadge status={getUserStatus(user)} /></TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => router.push(`/admin/users/${user.id}`)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleOpenEditDialog(user)}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => handleToggleActive(user)}>
|
||||
{user.isActive ? (
|
||||
<><Ban className="mr-2 h-4 w-4" />禁用</>
|
||||
) : (
|
||||
<span className="text-sm font-bold text-gray-500">
|
||||
{user.firstName?.[0] || user.username[0]}
|
||||
</span>
|
||||
<><CheckCircle className="mr-2 h-4 w-4" />启用</>
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium">{user.username}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">{user.email || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm">{user.phone || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => handleToggleActive(user.id, user.isActive)}
|
||||
className={`px-2 py-1 text-xs rounded-full cursor-pointer transition-colors ${
|
||||
user.isActive
|
||||
? 'bg-green-100 text-green-700 hover:bg-green-200'
|
||||
: 'bg-red-100 text-red-700 hover:bg-red-200'
|
||||
}`}
|
||||
>
|
||||
{user.isActive ? '已启用' : '已禁用'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right space-x-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setEditingUser({
|
||||
id: user.id,
|
||||
username: user.username || '',
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
})
|
||||
}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="text-sm text-red-600 hover:underline"
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeletingUser(user);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
显示第 {(pagination.page - 1) * pagination.limit + 1} -{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} 条,共{' '}
|
||||
{pagination.total} 条
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={pagination.page === 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="px-3 py-1 text-sm">
|
||||
第 {pagination.page} / {pagination.totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(page - 1)}
|
||||
className={pagination.page <= 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{renderPageNumbers().map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === pagination.page}
|
||||
onClick={() => setPage(p)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(page + 1)}
|
||||
className={pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingUser && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md space-y-4">
|
||||
<h2 className="text-lg font-semibold">编辑用户</h2>
|
||||
<form onSubmit={handleEditSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">用户名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingUser.username}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, username: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingUser ? '编辑用户' : '新建用户'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingUser ? '修改用户信息' : '填写新用户信息'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{editingUser ? (
|
||||
<Form {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit(handleEditSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">姓</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingUser.lastName}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, lastName: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editingUser.firstName}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, firstName: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={editingUser.email}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, email: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>手机号</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">手机</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={editingUser.phone}
|
||||
onChange={(e) =>
|
||||
setEditingUser({ ...editingUser, phone: e.target.value })
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl><Input type="email" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end space-x-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateUser.isPending}>
|
||||
{updateUser.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="roles"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>角色</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<FormField
|
||||
key={role.id}
|
||||
control={editForm.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(role.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? [];
|
||||
if (checked) {
|
||||
field.onChange([...current, role.id]);
|
||||
} else {
|
||||
field.onChange(current.filter((v: string) => v !== role.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">{role.label}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateUser.isPending}>
|
||||
{updateUser.isPending ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<Form {...createForm}>
|
||||
<form onSubmit={createForm.handleSubmit(handleCreateSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>手机号</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl><Input type="email" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl><Input type="password" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="roles"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>角色</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{ROLE_OPTIONS.map((role) => (
|
||||
<FormField
|
||||
key={role.id}
|
||||
control={createForm.control}
|
||||
name="roles"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center space-x-2 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value?.includes(role.id)}
|
||||
onCheckedChange={(checked) => {
|
||||
const current = field.value ?? [];
|
||||
if (checked) {
|
||||
field.onChange([...current, role.id]);
|
||||
} else {
|
||||
field.onChange(current.filter((v: string) => v !== role.id));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="text-sm font-normal">{role.label}</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={createUser.isPending}>
|
||||
{createUser.isPending ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除用户「{deletingUser?.username}」吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={deleteUser.isPending}>
|
||||
{deleteUser.isPending ? '删除中...' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/content',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-content', () => ({
|
||||
useArticles: () => ({
|
||||
data: {
|
||||
data: {
|
||||
articles: [
|
||||
{
|
||||
id: 'a1',
|
||||
title: '测试文章一',
|
||||
slug: 'test-article-1',
|
||||
content: '文章内容一',
|
||||
categoryId: 'cat1',
|
||||
status: 'draft',
|
||||
authorId: 'author-1',
|
||||
tags: ['标签A'],
|
||||
createdAt: '2026-05-25T08:00:00Z',
|
||||
updatedAt: '2026-05-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'a2',
|
||||
title: '测试文章二',
|
||||
slug: 'test-article-2',
|
||||
content: '文章内容二',
|
||||
categoryId: 'cat2',
|
||||
status: 'published',
|
||||
authorId: 'author-2',
|
||||
tags: [],
|
||||
createdAt: '2026-05-24T10:00:00Z',
|
||||
updatedAt: '2026-05-24T10:00:00Z',
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 20, total: 2, totalPages: 1 },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useCreateArticle: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateArticle: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteArticle: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
usePublishArticle: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useSubmitForReview: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useCategories: () => ({
|
||||
data: {
|
||||
data: [
|
||||
{ id: 'cat1', name: '技术', slug: 'tech', createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 'cat2', name: '生活', slug: 'life', createdAt: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
useCategoryTree: () => ({
|
||||
data: { data: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
useCreateCategory: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateCategory: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteCategory: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useTags: () => ({
|
||||
data: {
|
||||
data: {
|
||||
tags: [
|
||||
{ id: 't1', name: '标签A', slug: 'tag-a', articleCount: 3, createdAt: '2026-01-01T00:00:00Z' },
|
||||
{ id: 't2', name: '标签B', slug: 'tag-b', articleCount: 1, createdAt: '2026-01-01T00:00:00Z' },
|
||||
],
|
||||
pagination: { page: 1, limit: 20, total: 2, totalPages: 1 },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
useCreateTag: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useUpdateTag: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteTag: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useComments: () => ({
|
||||
data: {
|
||||
data: {
|
||||
comments: [
|
||||
{
|
||||
id: 'c1',
|
||||
articleId: 'a1',
|
||||
content: '这是一条评论',
|
||||
status: 'pending',
|
||||
authorId: 'user-1',
|
||||
createdAt: '2026-05-25T09:00:00Z',
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
}),
|
||||
useApproveComment: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useRejectComment: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteComment: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ContentPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders page title', async () => {
|
||||
const { default: ContentPage } = await import(
|
||||
'@/app/(dashboard)/content/page'
|
||||
)
|
||||
render(<ContentPage />)
|
||||
|
||||
expect(screen.getByText('内容管理')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders four tabs', async () => {
|
||||
const { default: ContentPage } = await import(
|
||||
'@/app/(dashboard)/content/page'
|
||||
)
|
||||
render(<ContentPage />)
|
||||
|
||||
expect(screen.getByText('文章管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('分类管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('标签管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('评论审核')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders article list with correct data', async () => {
|
||||
const { default: ContentPage } = await import(
|
||||
'@/app/(dashboard)/content/page'
|
||||
)
|
||||
render(<ContentPage />)
|
||||
|
||||
expect(screen.getByText('测试文章一')).toBeInTheDocument()
|
||||
expect(screen.getByText('测试文章二')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders article status badges', async () => {
|
||||
const { default: ContentPage } = await import(
|
||||
'@/app/(dashboard)/content/page'
|
||||
)
|
||||
render(<ContentPage />)
|
||||
|
||||
expect(screen.getByText('草稿')).toBeInTheDocument()
|
||||
expect(screen.getByText('已发布')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders article table headers', async () => {
|
||||
const { default: ContentPage } = await import(
|
||||
'@/app/(dashboard)/content/page'
|
||||
)
|
||||
render(<ContentPage />)
|
||||
|
||||
expect(screen.getByText('标题')).toBeInTheDocument()
|
||||
expect(screen.getByText('分类')).toBeInTheDocument()
|
||||
expect(screen.getByText('状态')).toBeInTheDocument()
|
||||
expect(screen.getByText('作者')).toBeInTheDocument()
|
||||
expect(screen.getByText('创建时间')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders create article button', async () => {
|
||||
const { default: ContentPage } = await import(
|
||||
'@/app/(dashboard)/content/page'
|
||||
)
|
||||
render(<ContentPage />)
|
||||
|
||||
expect(screen.getByText('新建文章')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,199 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
usePathname: () => '/dashboard',
|
||||
}))
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href }: { children: React.ReactNode; href: string }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-auth', () => ({
|
||||
useCurrentUser: () => ({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
id: '1',
|
||||
username: '管理员',
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
phoneVerified: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-users', () => ({
|
||||
useUsers: () => ({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
users: [],
|
||||
pagination: { page: 1, limit: 1, total: 128, totalPages: 128 },
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-orders', () => ({
|
||||
useOrders: () => ({
|
||||
data: {
|
||||
data: {
|
||||
orders: [
|
||||
{
|
||||
id: 'ord-1',
|
||||
orderNo: 'ORD20260525001',
|
||||
userId: 'u1',
|
||||
status: 'PENDING',
|
||||
totalAmount: 299.0,
|
||||
paidAmount: 0,
|
||||
discountAmount: 0,
|
||||
createdAt: '2026-05-25T08:00:00Z',
|
||||
updatedAt: '2026-05-25T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'ord-2',
|
||||
orderNo: 'ORD20260525002',
|
||||
userId: 'u2',
|
||||
status: 'PAID',
|
||||
totalAmount: 599.0,
|
||||
paidAmount: 599.0,
|
||||
discountAmount: 0,
|
||||
createdAt: '2026-05-25T07:30:00Z',
|
||||
updatedAt: '2026-05-25T07:35:00Z',
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 5, total: 50, totalPages: 10 },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useOrderStats: () => ({
|
||||
data: {
|
||||
data: {
|
||||
total: 50,
|
||||
pending: 5,
|
||||
paid: 30,
|
||||
shipped: 8,
|
||||
completed: 3,
|
||||
cancelled: 2,
|
||||
refunded: 2,
|
||||
totalAmount: 125680.5,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-notifications', () => ({
|
||||
useUnreadCount: () => ({
|
||||
data: {
|
||||
data: {
|
||||
count: 12,
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('DashboardPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders welcome section with username', async () => {
|
||||
const { default: DashboardPage } = await import(
|
||||
'@/app/(dashboard)/dashboard/page'
|
||||
)
|
||||
render(<DashboardPage />)
|
||||
|
||||
expect(screen.getByText(/管理员/)).toBeInTheDocument()
|
||||
expect(screen.getByText(/欢迎回到管理后台/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders all four stat cards', async () => {
|
||||
const { default: DashboardPage } = await import(
|
||||
'@/app/(dashboard)/dashboard/page'
|
||||
)
|
||||
render(<DashboardPage />)
|
||||
|
||||
expect(screen.getByText('用户总数')).toBeInTheDocument()
|
||||
expect(screen.getByText('订单总数')).toBeInTheDocument()
|
||||
expect(screen.getByText('收入总额')).toBeInTheDocument()
|
||||
expect(screen.getByText('未读通知')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays correct stat values', async () => {
|
||||
const { default: DashboardPage } = await import(
|
||||
'@/app/(dashboard)/dashboard/page'
|
||||
)
|
||||
render(<DashboardPage />)
|
||||
|
||||
expect(screen.getByText('128')).toBeInTheDocument()
|
||||
expect(screen.getByText('50')).toBeInTheDocument()
|
||||
expect(screen.getByText('12')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders recent orders section', async () => {
|
||||
const { default: DashboardPage } = await import(
|
||||
'@/app/(dashboard)/dashboard/page'
|
||||
)
|
||||
render(<DashboardPage />)
|
||||
|
||||
expect(screen.getByText('最近订单')).toBeInTheDocument()
|
||||
expect(screen.getByText('查看全部')).toBeInTheDocument()
|
||||
expect(screen.getByText('ORD20260525001')).toBeInTheDocument()
|
||||
expect(screen.getByText('ORD20260525002')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders order status badges', async () => {
|
||||
const { default: DashboardPage } = await import(
|
||||
'@/app/(dashboard)/dashboard/page'
|
||||
)
|
||||
render(<DashboardPage />)
|
||||
|
||||
expect(screen.getByText('待支付')).toBeInTheDocument()
|
||||
expect(screen.getByText('已支付')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders quick action buttons', async () => {
|
||||
const { default: DashboardPage } = await import(
|
||||
'@/app/(dashboard)/dashboard/page'
|
||||
)
|
||||
render(<DashboardPage />)
|
||||
|
||||
expect(screen.getByText('用户管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('订单管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('内容管理')).toBeInTheDocument()
|
||||
expect(screen.getByText('系统设置')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders system status section', async () => {
|
||||
const { default: DashboardPage } = await import(
|
||||
'@/app/(dashboard)/dashboard/page'
|
||||
)
|
||||
render(<DashboardPage />)
|
||||
|
||||
expect(screen.getByText('系统状态')).toBeInTheDocument()
|
||||
expect(screen.getByText('数据库连接')).toBeInTheDocument()
|
||||
expect(screen.getByText('Redis 连接')).toBeInTheDocument()
|
||||
expect(screen.getByText('最近部署')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,8 +1,316 @@
|
|||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
Users,
|
||||
ShoppingCart,
|
||||
DollarSign,
|
||||
Bell,
|
||||
ArrowRight,
|
||||
Activity,
|
||||
Database,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
} from '@/components/ui/card'
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from '@/components/ui/table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { useCurrentUser } from '@/hooks/use-auth'
|
||||
import { useUsers } from '@/hooks/use-users'
|
||||
import { useOrders, useOrderStats } from '@/hooks/use-orders'
|
||||
import { useUnreadCount } from '@/hooks/use-notifications'
|
||||
import { Order, OrderStatusType } from '@/lib/order-api'
|
||||
|
||||
const STATUS_LABEL_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: '待支付',
|
||||
PAID: '已支付',
|
||||
SHIPPED: '已发货',
|
||||
COMPLETED: '已完成',
|
||||
CANCELLED: '已取消',
|
||||
REFUNDED: '已退款',
|
||||
}
|
||||
|
||||
const STATUS_BADGE_CLASS: Record<OrderStatusType, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100',
|
||||
PAID: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800 hover:bg-purple-100',
|
||||
COMPLETED: 'bg-green-100 text-green-800 hover:bg-green-100',
|
||||
CANCELLED: 'bg-red-100 text-red-800 hover:bg-red-100',
|
||||
REFUNDED: 'bg-gray-100 text-gray-800 hover:bg-gray-100',
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter()
|
||||
const { data: currentUserData, isLoading: isLoadingUser } = useCurrentUser()
|
||||
const { data: usersData, isLoading: isLoadingUsers } = useUsers(1, 1)
|
||||
const { data: ordersData, isLoading: isLoadingOrders } = useOrders(1, 5)
|
||||
const { data: orderStatsData, isLoading: isLoadingStats } = useOrderStats()
|
||||
const { data: unreadData, isLoading: isLoadingUnread } = useUnreadCount()
|
||||
|
||||
const currentUser = currentUserData?.data?.data
|
||||
const totalUsers = usersData?.data?.data?.pagination?.total ?? 0
|
||||
const orders: Order[] = ordersData?.data?.orders ?? []
|
||||
const orderStats = orderStatsData?.data
|
||||
const unreadCount = unreadData?.data?.count ?? 0
|
||||
|
||||
const currentDateTime = useMemo(() => {
|
||||
return new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
weekday: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}, [])
|
||||
|
||||
const greeting = useMemo(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 6) return '夜深了'
|
||||
if (hour < 12) return '早上好'
|
||||
if (hour < 14) return '中午好'
|
||||
if (hour < 18) return '下午好'
|
||||
return '晚上好'
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Welcome to your dashboard</p>
|
||||
<div>
|
||||
{isLoadingUser ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
{greeting},{currentUser?.username || '用户'}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{currentDateTime} · 欢迎回到管理后台,祝您工作顺利
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">用户总数</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingUsers ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{totalUsers.toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">系统注册用户总数</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">订单总数</CardTitle>
|
||||
<ShoppingCart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? (
|
||||
<Skeleton className="h-8 w-20" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{(orderStats?.total ?? 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
待处理 {orderStats?.pending ?? 0} 笔
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">收入总额</CardTitle>
|
||||
<DollarSign className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
¥{(orderStats?.totalAmount ?? 0).toLocaleString('zh-CN', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">订单累计收入</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">未读通知</CardTitle>
|
||||
<Bell className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingUnread ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{unreadCount}</div>
|
||||
<p className="text-xs text-muted-foreground">条未读消息</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>最近订单</CardTitle>
|
||||
<Link href="/orders">
|
||||
<Button variant="ghost" size="sm" className="gap-1">
|
||||
查看全部
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingOrders ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : orders.length === 0 ? (
|
||||
<p className="text-center py-8 text-muted-foreground">暂无订单数据</p>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>订单号</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.map((order) => (
|
||||
<TableRow
|
||||
key={order.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/orders/${order.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium">{order.orderNo}</TableCell>
|
||||
<TableCell>¥{order.totalAmount.toFixed(2)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={STATUS_BADGE_CLASS[order.status as OrderStatusType]}
|
||||
>
|
||||
{STATUS_LABEL_MAP[order.status as OrderStatusType] || order.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(order.createdAt).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>快捷操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3">
|
||||
<Link href="/admin/users">
|
||||
<Button variant="outline" className="w-full justify-start gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
用户管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/orders">
|
||||
<Button variant="outline" className="w-full justify-start gap-2">
|
||||
<ShoppingCart className="h-4 w-4" />
|
||||
订单管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/dashboard">
|
||||
<Button variant="outline" className="w-full justify-start gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
内容管理
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/profile">
|
||||
<Button variant="outline" className="w-full justify-start gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
系统设置
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>系统状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">数据库连接</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-green-100 text-green-800 hover:bg-green-100">
|
||||
正常
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">Redis 连接</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="bg-green-100 text-green-800 hover:bg-green-100">
|
||||
正常
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-sm">最近部署</span>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground">2026-05-25 10:00</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/files',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-files', () => ({
|
||||
useFiles: () => ({
|
||||
data: {
|
||||
data: [
|
||||
{
|
||||
id: 'f1',
|
||||
name: 'report.pdf',
|
||||
originalName: '报告.pdf',
|
||||
mimeType: 'application/pdf',
|
||||
size: 1048576,
|
||||
url: '/files/f1',
|
||||
storageType: 'local',
|
||||
bucket: 'uploads',
|
||||
path: '/uploads/report.pdf',
|
||||
category: 'documents',
|
||||
accessCount: 5,
|
||||
ownerId: 'u1',
|
||||
createdAt: '2026-03-15T08:00:00Z',
|
||||
updatedAt: '2026-03-15T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 'f2',
|
||||
name: 'photo.jpg',
|
||||
originalName: '照片.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 2097152,
|
||||
url: '/files/f2',
|
||||
storageType: 'aliyun',
|
||||
bucket: 'images',
|
||||
path: '/uploads/photo.jpg',
|
||||
category: 'images',
|
||||
accessCount: 12,
|
||||
ownerId: 'u1',
|
||||
createdAt: '2026-04-20T10:30:00Z',
|
||||
updatedAt: '2026-04-20T10:30:00Z',
|
||||
},
|
||||
],
|
||||
meta: { page: 1, limit: 20, total: 2 },
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useUploadMultipleFiles: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDeleteFile: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
useDownloadFile: () => ({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('FilesPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders page title', async () => {
|
||||
const { default: FilesPage } = await import(
|
||||
'@/app/(dashboard)/files/page'
|
||||
)
|
||||
render(<FilesPage />)
|
||||
|
||||
expect(screen.getByText('文件管理')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders upload button', async () => {
|
||||
const { default: FilesPage } = await import(
|
||||
'@/app/(dashboard)/files/page'
|
||||
)
|
||||
render(<FilesPage />)
|
||||
|
||||
expect(screen.getByText('上传文件')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders file list with correct data', async () => {
|
||||
const { default: FilesPage } = await import(
|
||||
'@/app/(dashboard)/files/page'
|
||||
)
|
||||
render(<FilesPage />)
|
||||
|
||||
expect(screen.getByText('报告.pdf')).toBeInTheDocument()
|
||||
expect(screen.getByText('照片.jpg')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders file type badges', async () => {
|
||||
const { default: FilesPage } = await import(
|
||||
'@/app/(dashboard)/files/page'
|
||||
)
|
||||
render(<FilesPage />)
|
||||
|
||||
expect(screen.getByText('文档')).toBeInTheDocument()
|
||||
expect(screen.getByText('图片')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders file sizes', async () => {
|
||||
const { default: FilesPage } = await import(
|
||||
'@/app/(dashboard)/files/page'
|
||||
)
|
||||
render(<FilesPage />)
|
||||
|
||||
expect(screen.getByText('1.00 MB')).toBeInTheDocument()
|
||||
expect(screen.getByText('2.00 MB')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', async () => {
|
||||
const { default: FilesPage } = await import(
|
||||
'@/app/(dashboard)/files/page'
|
||||
)
|
||||
render(<FilesPage />)
|
||||
|
||||
expect(screen.getByPlaceholderText('搜索文件名...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders table headers', async () => {
|
||||
const { default: FilesPage } = await import(
|
||||
'@/app/(dashboard)/files/page'
|
||||
)
|
||||
render(<FilesPage />)
|
||||
|
||||
expect(screen.getByText('文件名')).toBeInTheDocument()
|
||||
expect(screen.getByText('类型')).toBeInTheDocument()
|
||||
expect(screen.getByText('大小')).toBeInTheDocument()
|
||||
expect(screen.getByText('上传时间')).toBeInTheDocument()
|
||||
expect(screen.getByText('操作')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,154 +1,330 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { FileUploader } from '@/components/file/file-uploader';
|
||||
import { FilePreview } from '@/components/file/file-preview';
|
||||
import { useFiles } from '@/hooks/use-files';
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useFiles, useUploadMultipleFiles, useDeleteFile, useDownloadFile } from '@/hooks/use-files';
|
||||
import type { File as FileType } from '@fischerx/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Upload, Search, MoreHorizontal, Download, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const k = 1024;
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const size = bytes / Math.pow(k, i);
|
||||
return `${size.toFixed(i > 0 ? 2 : 0)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function getFileCategory(mimeType: string): 'image' | 'document' | 'video' | 'other' {
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType.startsWith('video/')) return 'video';
|
||||
if (
|
||||
mimeType.startsWith('application/pdf') ||
|
||||
mimeType.startsWith('application/msword') ||
|
||||
mimeType.startsWith('application/vnd.') ||
|
||||
mimeType.startsWith('text/')
|
||||
) return 'document';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE_CONFIG: Record<string, { label: string; className: string }> = {
|
||||
image: { label: '图片', className: 'bg-blue-100 text-blue-700 hover:bg-blue-100' },
|
||||
document: { label: '文档', className: 'bg-green-100 text-green-700 hover:bg-green-100' },
|
||||
video: { label: '视频', className: 'bg-purple-100 text-purple-700 hover:bg-purple-100' },
|
||||
other: { label: '其他', className: 'bg-gray-100 text-gray-700 hover:bg-gray-100' },
|
||||
};
|
||||
|
||||
function CategoryBadge({ mimeType }: { mimeType: string }) {
|
||||
const category = getFileCategory(mimeType);
|
||||
const config = CATEGORY_BADGE_CONFIG[category];
|
||||
return (
|
||||
<Badge variant="secondary" className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function FileTableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-12 rounded-full" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
|
||||
<TableCell><Skeleton className="h-8 w-8" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const [params, setParams] = useState({ page: 1, limit: 20 });
|
||||
const [search, setSearch] = useState('');
|
||||
const [category, setCategory] = useState<string | undefined>();
|
||||
const { data, isLoading, error, refetch } = useFiles({
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [uploadDialogOpen, setUploadDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deletingFile, setDeletingFile] = useState<FileType | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { data, isLoading, refetch } = useFiles({
|
||||
...params,
|
||||
search: search || undefined,
|
||||
category,
|
||||
});
|
||||
|
||||
const handleUploadSuccess = useCallback(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
refetch();
|
||||
}, [refetch]);
|
||||
|
||||
const handleNextPage = () => {
|
||||
if (data?.meta && params.page < Math.ceil(data.meta.total / data.meta.limit)) {
|
||||
setParams((prev) => ({ ...prev, page: prev.page + 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevPage = () => {
|
||||
if (params.page > 1) {
|
||||
setParams((prev) => ({ ...prev, page: prev.page - 1 }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
setParams({ page: 1, limit: 20 });
|
||||
};
|
||||
const uploadMultipleFiles = useUploadMultipleFiles();
|
||||
const deleteFile = useDeleteFile();
|
||||
const downloadFile = useDownloadFile();
|
||||
|
||||
const files = data?.data || [];
|
||||
const meta = data?.meta;
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setSearch(searchInput);
|
||||
setParams((prev) => ({ ...prev, page: 1 }));
|
||||
}, [searchInput]);
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
|
||||
const handleUpload = useCallback(() => {
|
||||
const files = fileInputRef.current?.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
uploadMultipleFiles.mutate(
|
||||
{ files: Array.from(files) },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('文件上传成功');
|
||||
setUploadDialogOpen(false);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
refetch();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('文件上传失败,请重试');
|
||||
},
|
||||
},
|
||||
);
|
||||
}, [uploadMultipleFiles, refetch]);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(id: string) => {
|
||||
downloadFile.mutate(id, {
|
||||
onSuccess: () => toast.success('文件下载成功'),
|
||||
onError: () => toast.error('文件下载失败,请重试'),
|
||||
});
|
||||
},
|
||||
[downloadFile],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(() => {
|
||||
if (!deletingFile) return;
|
||||
deleteFile.mutate(deletingFile.id, {
|
||||
onSuccess: () => {
|
||||
toast.success('文件删除成功');
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingFile(null);
|
||||
},
|
||||
onError: () => toast.error('文件删除失败,请重试'),
|
||||
});
|
||||
}, [deletingFile, deleteFile]);
|
||||
|
||||
const totalPages = meta ? Math.ceil(meta.total / meta.limit) : 1;
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold mb-2">File Management</h1>
|
||||
<p className="text-muted-foreground">Upload, manage, and share your files</p>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">文件管理</h1>
|
||||
<Button onClick={() => setUploadDialogOpen(true)}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
上传文件
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FileUploader
|
||||
onUploadSuccess={handleUploadSuccess}
|
||||
multiple
|
||||
category={category}
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
<div className="mb-6 flex flex-wrap gap-4 items-center">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
value={search}
|
||||
onChange={handleSearch}
|
||||
className="w-full px-4 py-2 border rounded-md"
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索文件名..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={category || ''}
|
||||
onChange={(e) => {
|
||||
setCategory(e.target.value || undefined);
|
||||
setParams({ page: 1, limit: 20 });
|
||||
}}
|
||||
className="px-4 py-2 border rounded-md"
|
||||
>
|
||||
<option value="">All Categories</option>
|
||||
<option value="documents">Documents</option>
|
||||
<option value="images">Images</option>
|
||||
<option value="videos">Videos</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-lg">Loading files...</div>
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>文件名</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>大小</TableHead>
|
||||
<TableHead>上传时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<FileTableSkeleton />
|
||||
) : files.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
|
||||
暂无文件数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell className="font-medium">{file.originalName}</TableCell>
|
||||
<TableCell>
|
||||
<CategoryBadge mimeType={file.mimeType} />
|
||||
</TableCell>
|
||||
<TableCell>{formatFileSize(file.size)}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(file.createdAt).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleDownload(file.id)}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
下载
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeletingFile(file);
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{meta && meta.total > meta.limit && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
显示第 {(meta.page - 1) * meta.limit + 1} -{' '}
|
||||
{Math.min(meta.page * meta.limit, meta.total)} 条,共 {meta.total} 条
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setParams((prev) => ({ ...prev, page: prev.page - 1 }))}
|
||||
disabled={meta.page <= 1}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setParams((prev) => ({ ...prev, page: prev.page + 1 }))}
|
||||
disabled={meta.page >= totalPages}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Card className="mb-6 border-destructive">
|
||||
<CardContent className="p-4 text-destructive">
|
||||
Error loading files: {error.message}
|
||||
<Button variant="outline" className="ml-4" onClick={() => refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && files.length === 0 && (
|
||||
<Card className="mb-6">
|
||||
<CardContent className="p-8 text-center">
|
||||
<div className="text-4xl mb-4">📭</div>
|
||||
<h3 className="text-lg font-medium mb-2">No files found</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{search || category
|
||||
? 'Try adjusting your search or filters'
|
||||
: 'Upload your first file above'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{files.length > 0 && (
|
||||
<>
|
||||
<div className="grid gap-4 mb-8">
|
||||
{files.map((file) => (
|
||||
<FilePreview key={file.id} file={file} onDelete={handleDelete} />
|
||||
))}
|
||||
<Dialog open={uploadDialogOpen} onOpenChange={setUploadDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传文件</DialogTitle>
|
||||
<DialogDescription>选择要上传的文件,支持多文件上传</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUploadDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleUpload} disabled={uploadMultipleFiles.isPending}>
|
||||
{uploadMultipleFiles.isPending ? '上传中...' : '确认上传'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{meta && meta.total > meta.limit && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Showing {((meta.page - 1) * meta.limit) + 1} - {Math.min(meta.page * meta.limit, meta.total)} of {meta.total} files
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handlePrevPage}
|
||||
disabled={meta.page <= 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleNextPage}
|
||||
disabled={meta.page >= Math.ceil(meta.total / meta.limit)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除文件「{deletingFile?.originalName}」吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDeleteConfirm} disabled={deleteFile.isPending}>
|
||||
{deleteFile.isPending ? '删除中...' : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Navbar } from "@/components/layout/navbar"
|
||||
import { Sidebar } from "@/components/layout/sidebar"
|
||||
import { RouteGuard } from "@/components/auth/route-guard"
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
|
|
@ -7,14 +8,16 @@ export default function DashboardLayout({
|
|||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar />
|
||||
<main className="flex-1 md:ml-64">
|
||||
<div className="container py-6">{children}</div>
|
||||
</main>
|
||||
<RouteGuard mode="dashboard">
|
||||
<div className="relative flex min-h-screen flex-col">
|
||||
<Navbar />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar />
|
||||
<main className="flex-1 md:ml-64">
|
||||
<div className="container py-6">{children}</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RouteGuard>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,13 +42,12 @@ export default function NotificationsPage() {
|
|||
const [page, setPage] = useState(1);
|
||||
const limit = 20;
|
||||
|
||||
const { data: notificationsData, isLoading } = useNotifications(
|
||||
const { data: notificationsData, isLoading } = useNotifications({
|
||||
page,
|
||||
limit,
|
||||
channel || undefined,
|
||||
undefined,
|
||||
type || undefined,
|
||||
);
|
||||
channel: channel || undefined,
|
||||
type: type || undefined,
|
||||
});
|
||||
const { data: unreadData } = useUnreadCount();
|
||||
const markAsRead = useMarkAsRead();
|
||||
const markAllAsRead = useMarkAllAsRead();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,30 @@
|
|||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, CreditCard, CheckCircle, RotateCcw, XCircle } from 'lucide-react';
|
||||
import { useOrder, useCancelOrder, useUpdateOrderStatus } from '@/hooks/use-orders';
|
||||
import { OrderItem, OrderStatusType } from '@/lib/order-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
|
||||
const STATUS_LABEL_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: '待支付',
|
||||
|
|
@ -13,30 +35,21 @@ const STATUS_LABEL_MAP: Record<OrderStatusType, string> = {
|
|||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const STATUS_STYLE_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
COMPLETED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-800',
|
||||
REFUNDED: 'bg-red-100 text-red-800',
|
||||
const STATUS_BADGE_CLASS_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100 border-yellow-200',
|
||||
PAID: 'bg-green-100 text-green-800 hover:bg-green-100 border-green-200',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800 hover:bg-purple-100 border-purple-200',
|
||||
COMPLETED: 'bg-blue-100 text-blue-800 hover:bg-blue-100 border-blue-200',
|
||||
CANCELLED: 'bg-red-100 text-red-800 hover:bg-red-100 border-red-200',
|
||||
REFUNDED: 'bg-gray-100 text-gray-800 hover:bg-gray-100 border-gray-200',
|
||||
};
|
||||
|
||||
const TIMELINE_FIELDS: { status: OrderStatusType; timeField: string; label: string }[] = [
|
||||
{ status: 'PENDING', timeField: 'createdAt', label: '创建订单' },
|
||||
{ status: 'PAID', timeField: 'paidAt', label: '支付成功' },
|
||||
{ status: 'SHIPPED', timeField: 'shippedAt', label: '已发货' },
|
||||
{ status: 'COMPLETED', timeField: 'completedAt', label: '已完成' },
|
||||
const MOCK_OPERATION_LOGS = [
|
||||
{ time: '2026-05-25 10:00:00', action: '创建订单', operator: '系统', detail: '用户提交订单' },
|
||||
{ time: '2026-05-25 10:01:00', action: '库存锁定', operator: '系统', detail: '商品库存已锁定' },
|
||||
{ time: '2026-05-25 10:05:00', action: '等待支付', operator: '系统', detail: '订单等待用户支付' },
|
||||
];
|
||||
|
||||
const STATUS_FLOW: OrderStatusType[] = ['PENDING', 'PAID', 'SHIPPED', 'COMPLETED'];
|
||||
|
||||
const NEXT_STATUS_MAP: Partial<Record<OrderStatusType, OrderStatusType>> = {
|
||||
PENDING: 'PAID',
|
||||
PAID: 'SHIPPED',
|
||||
SHIPPED: 'COMPLETED',
|
||||
};
|
||||
|
||||
export default function OrderDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
|
|
@ -49,189 +62,298 @@ export default function OrderDetailPage() {
|
|||
const order = orderData?.data;
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="p-6 text-center text-gray-500">加载中...</div>;
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<Skeleton className="h-8 w-32" />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!order) {
|
||||
return <div className="p-6 text-center text-gray-500">订单不存在</div>;
|
||||
return (
|
||||
<div className="p-6 text-center text-muted-foreground">订单不存在</div>
|
||||
);
|
||||
}
|
||||
|
||||
const currentStatus = order.status as OrderStatusType;
|
||||
const nextStatus = NEXT_STATUS_MAP[currentStatus];
|
||||
const canCancel = currentStatus === 'PENDING' || currentStatus === 'PAID';
|
||||
|
||||
const handleConfirmPay = async () => {
|
||||
try {
|
||||
await updateStatus.mutateAsync({ id, status: 'PAID' });
|
||||
toast.success('订单已确认支付');
|
||||
} catch {
|
||||
toast.error('确认支付失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkCompleted = async () => {
|
||||
try {
|
||||
await updateStatus.mutateAsync({ id, status: 'COMPLETED' });
|
||||
toast.success('订单已标记完成');
|
||||
} catch {
|
||||
toast.error('标记完成失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRefund = async () => {
|
||||
try {
|
||||
await updateStatus.mutateAsync({ id, status: 'REFUNDED' });
|
||||
toast.success('退款申请已提交');
|
||||
} catch {
|
||||
toast.error('申请退款失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!confirm('确定要取消该订单吗?')) return;
|
||||
try {
|
||||
await cancelOrder.mutateAsync(id);
|
||||
toast.success('订单已取消');
|
||||
} catch {
|
||||
alert('取消订单失败');
|
||||
toast.error('取消订单失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateStatus = async (status: OrderStatusType) => {
|
||||
try {
|
||||
await updateStatus.mutateAsync({ id, status });
|
||||
} catch {
|
||||
alert('更新状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
const isTerminalStatus = currentStatus === 'CANCELLED' || currentStatus === 'REFUNDED';
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => router.push('/orders')}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
← 返回列表
|
||||
</button>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" size="sm" onClick={() => router.push('/orders')}>
|
||||
<ArrowLeft className="mr-1 h-4 w-4" />
|
||||
返回列表
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold">订单详情</h1>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={STATUS_BADGE_CLASS_MAP[currentStatus]}
|
||||
>
|
||||
{STATUS_LABEL_MAP[currentStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">基本信息</h2>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">订单号</span>
|
||||
<p className="font-medium">{order.orderNo}</p>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>基本信息</CardTitle>
|
||||
<CardDescription>订单基本信息与金额明细</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">订单号</p>
|
||||
<p className="font-medium">{order.orderNo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">用户ID</p>
|
||||
<p className="font-medium">{order.userId}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">订单金额</p>
|
||||
<p className="font-medium">¥{order.totalAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">实付金额</p>
|
||||
<p className="font-medium">¥{order.paidAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">优惠金额</p>
|
||||
<p className="font-medium">¥{order.discountAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">支付方式</p>
|
||||
<p className="font-medium">{order.paymentMethod || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">创建时间</p>
|
||||
<p className="font-medium">{new Date(order.createdAt).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">更新时间</p>
|
||||
<p className="font-medium">{new Date(order.updatedAt).toLocaleString('zh-CN')}</p>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">备注</p>
|
||||
<p className="font-medium">{order.remark || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">状态</span>
|
||||
<p>
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${STATUS_STYLE_MAP[currentStatus]}`}>
|
||||
{STATUS_LABEL_MAP[currentStatus]}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">订单金额</span>
|
||||
<p className="font-medium">¥{order.totalAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">实付金额</span>
|
||||
<p className="font-medium">¥{order.paidAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">优惠金额</span>
|
||||
<p className="font-medium">¥{order.discountAmount.toFixed(2)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">支付方式</span>
|
||||
<p className="font-medium">{order.paymentMethod || '-'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">创建时间</span>
|
||||
<p className="font-medium">{new Date(order.createdAt).toLocaleString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">备注</span>
|
||||
<p className="font-medium">{order.remark || '-'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">订单商品</h2>
|
||||
{order.items && order.items.length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">商品</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">单价</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">数量</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">小计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{order.items.map((item: OrderItem) => (
|
||||
<tr key={item.id}>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
{item.productImage && (
|
||||
<img src={item.productImage} alt="" className="w-8 h-8 rounded object-cover" />
|
||||
)}
|
||||
<span>{item.productName}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">¥{item.unitPrice.toFixed(2)}</td>
|
||||
<td className="px-3 py-2 text-right">{item.quantity}</td>
|
||||
<td className="px-3 py-2 text-right font-medium">¥{item.totalAmount.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm">暂无商品信息</p>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>商品信息</CardTitle>
|
||||
<CardDescription>订单包含的商品列表</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{order.items && order.items.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>商品名称</TableHead>
|
||||
<TableHead className="text-right">单价</TableHead>
|
||||
<TableHead className="text-right">数量</TableHead>
|
||||
<TableHead className="text-right">小计</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{order.items.map((item: OrderItem) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.productImage && (
|
||||
<img
|
||||
src={item.productImage}
|
||||
alt=""
|
||||
className="w-8 h-8 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<span>{item.productName}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">¥{item.unitPrice.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell className="text-right font-medium">¥{item.totalAmount.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-sm">暂无商品信息</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>操作日志</CardTitle>
|
||||
<CardDescription>订单操作记录</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{MOCK_OPERATION_LOGS.map((log, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-primary" />
|
||||
<span className="font-medium">{log.action}</span>
|
||||
<span className="text-muted-foreground">- {log.detail}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground">{log.time}</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground ml-4 mt-0.5">
|
||||
操作人:{log.operator}
|
||||
</div>
|
||||
{index < MOCK_OPERATION_LOGS.length - 1 && (
|
||||
<Separator className="mt-4" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="border rounded-lg p-6 space-y-4">
|
||||
<h2 className="text-lg font-semibold">状态时间线</h2>
|
||||
<div className="space-y-4">
|
||||
{TIMELINE_FIELDS.map((step, index) => {
|
||||
const isReached = !isTerminalStatus && STATUS_FLOW.indexOf(currentStatus) >= index;
|
||||
const timeValue = order[step.timeField as keyof typeof order] as string | undefined;
|
||||
|
||||
return (
|
||||
<div key={step.status} className="flex items-start space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full mt-1 ${isReached ? 'bg-green-500' : 'bg-gray-300'}`} />
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isReached ? 'text-gray-900' : 'text-gray-400'}`}>
|
||||
{step.label}
|
||||
</p>
|
||||
{timeValue && (
|
||||
<p className="text-xs text-gray-500">{new Date(timeValue).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(currentStatus === 'CANCELLED' || currentStatus === 'REFUNDED') && (
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="w-3 h-3 rounded-full mt-1 bg-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-600">
|
||||
{STATUS_LABEL_MAP[currentStatus]}
|
||||
</p>
|
||||
{(order.cancelledAt) && (
|
||||
<p className="text-xs text-gray-500">{new Date(order.cancelledAt).toLocaleString()}</p>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>订单状态</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">当前状态</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={STATUS_BADGE_CLASS_MAP[currentStatus]}
|
||||
>
|
||||
{STATUS_LABEL_MAP[currentStatus]}
|
||||
</Badge>
|
||||
</div>
|
||||
{order.paidAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">支付时间</span>
|
||||
<span className="text-sm">{new Date(order.paidAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{order.completedAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">完成时间</span>
|
||||
<span className="text-sm">{new Date(order.completedAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
{order.cancelledAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">取消时间</span>
|
||||
<span className="text-sm">{new Date(order.cancelledAt).toLocaleString('zh-CN')}</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="border rounded-lg p-6 space-y-3">
|
||||
<h2 className="text-lg font-semibold">操作</h2>
|
||||
{nextStatus && (
|
||||
<button
|
||||
onClick={() => handleUpdateStatus(nextStatus)}
|
||||
disabled={updateStatus.isPending}
|
||||
className="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 text-sm"
|
||||
>
|
||||
{currentStatus === 'PENDING' ? '确认支付' :
|
||||
currentStatus === 'PAID' ? '标记发货' :
|
||||
currentStatus === 'SHIPPED' ? '确认收货' : '更新状态'}
|
||||
</button>
|
||||
)}
|
||||
{canCancel && (
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={cancelOrder.isPending}
|
||||
className="w-full px-4 py-2 border border-red-300 text-red-600 rounded-md hover:bg-red-50 disabled:opacity-50 text-sm"
|
||||
>
|
||||
取消订单
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>操作</CardTitle>
|
||||
<CardDescription>根据订单状态可执行的操作</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{currentStatus === 'PENDING' && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleConfirmPay}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
确认支付
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full text-destructive border-destructive hover:bg-destructive/10"
|
||||
onClick={handleCancel}
|
||||
disabled={cancelOrder.isPending}
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
取消订单
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{currentStatus === 'PAID' && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleMarkCompleted}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<CheckCircle className="mr-2 h-4 w-4" />
|
||||
标记完成
|
||||
</Button>
|
||||
)}
|
||||
{currentStatus === 'COMPLETED' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={handleRefund}
|
||||
disabled={updateStatus.isPending}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
申请退款
|
||||
</Button>
|
||||
)}
|
||||
{(currentStatus === 'CANCELLED' || currentStatus === 'REFUNDED') && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
当前订单状态无可执行操作
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,184 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}),
|
||||
useParams: () => ({}),
|
||||
usePathname: () => '/orders',
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-orders', () => ({
|
||||
useOrders: () => ({
|
||||
data: {
|
||||
data: {
|
||||
orders: [
|
||||
{
|
||||
id: 'o1',
|
||||
orderNo: 'ORD-2026-001',
|
||||
userId: 'user-001',
|
||||
status: 'PENDING',
|
||||
totalAmount: 299.00,
|
||||
paidAmount: 0,
|
||||
discountAmount: 0,
|
||||
createdAt: '2026-05-25T08:00:00Z',
|
||||
updatedAt: '2026-05-25T08:00:00Z',
|
||||
items: [
|
||||
{
|
||||
id: 'item-1',
|
||||
orderId: 'o1',
|
||||
productId: 'p1',
|
||||
productName: '测试商品A',
|
||||
quantity: 2,
|
||||
unitPrice: 149.50,
|
||||
totalAmount: 299.00,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'o2',
|
||||
orderNo: 'ORD-2026-002',
|
||||
userId: 'user-002',
|
||||
status: 'PAID',
|
||||
totalAmount: 599.00,
|
||||
paidAmount: 599.00,
|
||||
discountAmount: 0,
|
||||
createdAt: '2026-05-24T10:30:00Z',
|
||||
updatedAt: '2026-05-24T11:00:00Z',
|
||||
items: [
|
||||
{
|
||||
id: 'item-2',
|
||||
orderId: 'o2',
|
||||
productId: 'p2',
|
||||
productName: '测试商品B',
|
||||
quantity: 1,
|
||||
unitPrice: 599.00,
|
||||
totalAmount: 599.00,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'o3',
|
||||
orderNo: 'ORD-2026-003',
|
||||
userId: 'user-003',
|
||||
status: 'COMPLETED',
|
||||
totalAmount: 150.00,
|
||||
paidAmount: 150.00,
|
||||
discountAmount: 0,
|
||||
createdAt: '2026-05-23T14:00:00Z',
|
||||
updatedAt: '2026-05-23T18:00:00Z',
|
||||
items: [],
|
||||
},
|
||||
],
|
||||
pagination: { page: 1, limit: 20, total: 3, totalPages: 1 },
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
useCancelOrder: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('OrdersPage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders page title', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByText('订单管理')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders search input', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByPlaceholderText('搜索订单号...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status filter', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByText('全部')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders order list with correct data', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByText('ORD-2026-001')).toBeInTheDocument()
|
||||
expect(screen.getByText('ORD-2026-002')).toBeInTheDocument()
|
||||
expect(screen.getByText('ORD-2026-003')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders status badges', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByText('待支付')).toBeInTheDocument()
|
||||
expect(screen.getByText('已支付')).toBeInTheDocument()
|
||||
expect(screen.getByText('已完成')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders table headers', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByText('订单号')).toBeInTheDocument()
|
||||
expect(screen.getByText('用户')).toBeInTheDocument()
|
||||
expect(screen.getByText('商品')).toBeInTheDocument()
|
||||
expect(screen.getByText('金额')).toBeInTheDocument()
|
||||
expect(screen.getByText('状态')).toBeInTheDocument()
|
||||
expect(screen.getByText('创建时间')).toBeInTheDocument()
|
||||
expect(screen.getByText('操作')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders order amounts', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByText('¥299.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('¥599.00')).toBeInTheDocument()
|
||||
expect(screen.getByText('¥150.00')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders product names in order list', async () => {
|
||||
const { default: OrdersPage } = await import(
|
||||
'@/app/(dashboard)/orders/page'
|
||||
)
|
||||
render(<OrdersPage />)
|
||||
|
||||
expect(screen.getByText('测试商品A')).toBeInTheDocument()
|
||||
expect(screen.getByText('测试商品B')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
|
@ -1,15 +1,58 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { MoreHorizontal, Search, Eye, XCircle, Trash2 } from 'lucide-react';
|
||||
import { useOrders, useCancelOrder } from '@/hooks/use-orders';
|
||||
import { Order, OrderStatusType } from '@/lib/order-api';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
|
||||
const STATUS_OPTIONS: { label: string; value?: OrderStatusType }[] = [
|
||||
{ label: '全部' },
|
||||
const STATUS_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: '全部', value: 'ALL' },
|
||||
{ label: '待支付', value: 'PENDING' },
|
||||
{ label: '已支付', value: 'PAID' },
|
||||
{ label: '已发货', value: 'SHIPPED' },
|
||||
{ label: '已完成', value: 'COMPLETED' },
|
||||
{ label: '已取消', value: 'CANCELLED' },
|
||||
{ label: '已退款', value: 'REFUNDED' },
|
||||
|
|
@ -24,136 +67,264 @@ const STATUS_LABEL_MAP: Record<OrderStatusType, string> = {
|
|||
REFUNDED: '已退款',
|
||||
};
|
||||
|
||||
const STATUS_STYLE_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800',
|
||||
PAID: 'bg-blue-100 text-blue-800',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800',
|
||||
COMPLETED: 'bg-green-100 text-green-800',
|
||||
CANCELLED: 'bg-gray-100 text-gray-800',
|
||||
REFUNDED: 'bg-red-100 text-red-800',
|
||||
const STATUS_BADGE_MAP: Record<OrderStatusType, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
PENDING: 'outline',
|
||||
PAID: 'default',
|
||||
SHIPPED: 'secondary',
|
||||
COMPLETED: 'default',
|
||||
CANCELLED: 'destructive',
|
||||
REFUNDED: 'secondary',
|
||||
};
|
||||
|
||||
const STATUS_BADGE_CLASS_MAP: Record<OrderStatusType, string> = {
|
||||
PENDING: 'bg-yellow-100 text-yellow-800 hover:bg-yellow-100 border-yellow-200',
|
||||
PAID: 'bg-green-100 text-green-800 hover:bg-green-100 border-green-200',
|
||||
SHIPPED: 'bg-purple-100 text-purple-800 hover:bg-purple-100 border-purple-200',
|
||||
COMPLETED: 'bg-blue-100 text-blue-800 hover:bg-blue-100 border-blue-200',
|
||||
CANCELLED: 'bg-red-100 text-red-800 hover:bg-red-100 border-red-200',
|
||||
REFUNDED: 'bg-gray-100 text-gray-800 hover:bg-gray-100 border-gray-200',
|
||||
};
|
||||
|
||||
export default function OrdersPage() {
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<OrderStatusType | undefined>();
|
||||
const [statusFilter, setStatusFilter] = useState<string>('ALL');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
|
||||
const [cancelTarget, setCancelTarget] = useState<Order | null>(null);
|
||||
const limit = 20;
|
||||
|
||||
const { data: ordersData, isLoading } = useOrders(page, limit, statusFilter);
|
||||
const filterStatus = statusFilter === 'ALL' ? undefined : (statusFilter as OrderStatusType);
|
||||
const { data: ordersData, isLoading } = useOrders(page, limit, filterStatus);
|
||||
const cancelOrder = useCancelOrder();
|
||||
|
||||
const orders = ordersData?.data?.orders || [];
|
||||
const pagination = ordersData?.data?.pagination;
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm('确定要取消该订单吗?')) return;
|
||||
const filteredOrders = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return orders;
|
||||
return orders.filter((order: Order) =>
|
||||
order.orderNo.toLowerCase().includes(searchKeyword.toLowerCase())
|
||||
);
|
||||
}, [orders, searchKeyword]);
|
||||
|
||||
const handleStatusFilterChange = (value: string) => {
|
||||
setStatusFilter(value);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleViewDetail = (id: string) => {
|
||||
router.push(`/orders/${id}`);
|
||||
};
|
||||
|
||||
const handleCancelClick = (order: Order) => {
|
||||
setCancelTarget(order);
|
||||
setCancelDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCancelConfirm = async () => {
|
||||
if (!cancelTarget) return;
|
||||
try {
|
||||
await cancelOrder.mutateAsync(id);
|
||||
await cancelOrder.mutateAsync(cancelTarget.id);
|
||||
toast.success('订单已取消');
|
||||
setCancelDialogOpen(false);
|
||||
setCancelTarget(null);
|
||||
} catch {
|
||||
alert('取消订单失败');
|
||||
toast.error('取消订单失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (order: Order) => {
|
||||
toast.info('删除功能暂未实现');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">订单管理</h1>
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">订单管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.label}
|
||||
onClick={() => {
|
||||
setStatusFilter(opt.value);
|
||||
setPage(1);
|
||||
}}
|
||||
className={`px-3 py-1.5 text-sm rounded-md border ${
|
||||
statusFilter === opt.value
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索订单号..."
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={handleStatusFilterChange}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="全部状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-gray-500">加载中...</div>
|
||||
) : orders.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-500">暂无订单</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">订单号</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">金额</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">状态</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">创建时间</th>
|
||||
<th className="px-4 py-3 text-right text-sm font-medium text-gray-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{orders.map((order: Order) => (
|
||||
<tr
|
||||
key={order.id}
|
||||
className="hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => router.push(`/orders/${order.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3 text-sm font-medium">{order.orderNo}</td>
|
||||
<td className="px-4 py-3 text-sm">¥{order.totalAmount.toFixed(2)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${STATUS_STYLE_MAP[order.status as OrderStatusType] || 'bg-gray-100 text-gray-800'}`}>
|
||||
{STATUS_LABEL_MAP[order.status as OrderStatusType] || order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(order.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
{(order.status === 'PENDING' || order.status === 'PAID') && (
|
||||
<button
|
||||
onClick={() => handleCancel(order.id)}
|
||||
disabled={cancelOrder.isPending}
|
||||
className="text-sm text-red-600 hover:underline disabled:opacity-50"
|
||||
>
|
||||
取消订单
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center space-x-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">暂无订单</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>订单号</TableHead>
|
||||
<TableHead>用户</TableHead>
|
||||
<TableHead>商品</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredOrders.map((order: Order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="font-medium">{order.orderNo}</TableCell>
|
||||
<TableCell>{order.userId}</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{order.items && order.items.length > 0
|
||||
? order.items.map((item) => item.productName).join('、')
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>¥{order.totalAmount.toFixed(2)}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={STATUS_BADGE_MAP[order.status as OrderStatusType]}
|
||||
className={STATUS_BADGE_CLASS_MAP[order.status as OrderStatusType]}
|
||||
>
|
||||
{STATUS_LABEL_MAP[order.status as OrderStatusType] || order.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(order.createdAt).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewDetail(order.id)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
{(order.status === 'PENDING' || order.status === 'PAID') && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleCancelClick(order)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<XCircle className="mr-2 h-4 w-4" />
|
||||
取消订单
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleDelete(order)}
|
||||
className="text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
共 {pagination.total} 条记录,第 {pagination.page} / {pagination.totalPages} 页
|
||||
</p>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(Math.max(1, page - 1))}
|
||||
className={page === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1)
|
||||
.filter((p) => {
|
||||
if (pagination.totalPages <= 7) return true;
|
||||
if (p === 1 || p === pagination.totalPages) return true;
|
||||
return Math.abs(p - page) <= 1;
|
||||
})
|
||||
.map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === page}
|
||||
onClick={() => setPage(p)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(Math.min(pagination.totalPages, page + 1))}
|
||||
className={
|
||||
page === pagination.totalPages
|
||||
? 'pointer-events-none opacity-50'
|
||||
: 'cursor-pointer'
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pagination && pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-gray-500">
|
||||
第 {(pagination.page - 1) * pagination.limit + 1} - {Math.min(pagination.page * pagination.limit, pagination.total)} 条,共 {pagination.total} 条
|
||||
</p>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
<Dialog open={cancelDialogOpen} onOpenChange={setCancelDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>取消订单</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要取消订单 {cancelTarget?.orderNo} 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCancelDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleCancelConfirm}
|
||||
disabled={cancelOrder.isPending}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span className="px-3 py-1">
|
||||
第 {pagination.page} / {pagination.totalPages} 页
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === pagination.totalPages}
|
||||
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{cancelOrder.isPending ? '处理中...' : '确认取消'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { usePaymentOrder, useQueryOrderStatus, useCancelOrder, useCreateRefund } from '@/hooks/use-payment';
|
||||
import { usePaymentOrder, useQueryOrderStatus, useCancelPaymentOrder, useCreateRefund } from '@/hooks/use-payment';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useState } from 'react';
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ export default function PaymentDetailPage() {
|
|||
|
||||
const { data: orderResult, isLoading } = usePaymentOrder(orderId);
|
||||
const queryStatusMutation = useQueryOrderStatus();
|
||||
const cancelOrderMutation = useCancelOrder();
|
||||
const cancelOrderMutation = useCancelPaymentOrder();
|
||||
const createRefundMutation = useCreateRefund();
|
||||
|
||||
const order = orderResult?.data;
|
||||
|
|
|
|||
|
|
@ -1,67 +1,319 @@
|
|||
'use client';
|
||||
|
||||
import { usePaymentOrders } from '@/hooks/use-payment';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { usePaymentOrders, useCreateRefund } from '@/hooks/use-payment';
|
||||
import type { PaymentOrder } from '@/lib/payment-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '@/components/ui/pagination';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Search, MoreHorizontal, Eye, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type PaymentStatus = PaymentOrder['status'];
|
||||
|
||||
const STATUS_BADGE_CONFIG: Record<PaymentStatus, { label: string; className: string }> = {
|
||||
pending: { label: '待支付', className: 'bg-yellow-100 text-yellow-700 hover:bg-yellow-100' },
|
||||
paid: { label: '已支付', className: 'bg-green-100 text-green-700 hover:bg-green-100' },
|
||||
failed: { label: '失败', className: 'bg-red-100 text-red-700 hover:bg-red-100' },
|
||||
cancelled: { label: '已取消', className: 'bg-gray-100 text-gray-700 hover:bg-gray-100' },
|
||||
refunded: { label: '已退款', className: 'bg-blue-100 text-blue-700 hover:bg-blue-100' },
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: PaymentStatus }) {
|
||||
const config = STATUS_BADGE_CONFIG[status];
|
||||
return (
|
||||
<Badge variant="secondary" className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
function PaymentTableSkeleton() {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
||||
<TableCell><Skeleton className="h-5 w-12 rounded-full" /></TableCell>
|
||||
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
|
||||
<TableCell><Skeleton className="h-8 w-8" /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PaymentsPage() {
|
||||
const { data: ordersData, isLoading } = usePaymentOrders(1, 20);
|
||||
const router = useRouter();
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(20);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [refundDialogOpen, setRefundDialogOpen] = useState(false);
|
||||
const [refundingOrder, setRefundingOrder] = useState<PaymentOrder | null>(null);
|
||||
|
||||
if (isLoading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
const { data, isLoading } = usePaymentOrders(page, limit);
|
||||
const createRefund = useCreateRefund();
|
||||
|
||||
const orders = ordersData?.data?.orders || [];
|
||||
const orders: PaymentOrder[] = data?.data?.orders ?? [];
|
||||
const pagination = data?.data?.pagination ?? { page: 1, limit: 20, total: 0, totalPages: 0 };
|
||||
|
||||
const filteredOrders = orders.filter((order) => {
|
||||
if (statusFilter !== 'all' && order.status !== statusFilter) return false;
|
||||
if (search && !order.orderNo.toLowerCase().includes(search.toLowerCase()) && !order.subject.toLowerCase().includes(search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
setSearch(searchInput);
|
||||
setPage(1);
|
||||
}, [searchInput]);
|
||||
|
||||
const handleSearchKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSearch();
|
||||
},
|
||||
[handleSearch],
|
||||
);
|
||||
|
||||
const handleViewDetail = useCallback(
|
||||
(id: string) => {
|
||||
router.push(`/payments/${id}`);
|
||||
},
|
||||
[router],
|
||||
);
|
||||
|
||||
const handleRefundConfirm = useCallback(() => {
|
||||
if (!refundingOrder) return;
|
||||
createRefund.mutate(
|
||||
{ orderId: refundingOrder.id, amount: refundingOrder.amount },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success('退款申请已提交');
|
||||
setRefundDialogOpen(false);
|
||||
setRefundingOrder(null);
|
||||
},
|
||||
onError: () => toast.error('退款申请失败,请重试'),
|
||||
},
|
||||
);
|
||||
}, [refundingOrder, createRefund]);
|
||||
|
||||
const renderPageNumbers = () => {
|
||||
const pages: number[] = [];
|
||||
const totalPages = pagination.totalPages;
|
||||
const current = pagination.page;
|
||||
const start = Math.max(1, current - 2);
|
||||
const end = Math.min(totalPages, current + 2);
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-6">支付订单</h1>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
订单号
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
金额
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
状态
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
|
||||
时间
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{orders.map((order: any) => (
|
||||
<tr key={order.id}>
|
||||
<td className="px-4 py-3 text-sm">{order.orderNo}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium">
|
||||
¥{order.amount.toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
|
||||
order.status === 'paid' ? 'bg-green-100 text-green-800' :
|
||||
order.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||
order.status === 'failed' ? 'bg-red-100 text-red-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{order.status === 'paid' ? '已支付' :
|
||||
order.status === 'pending' ? '待支付' :
|
||||
order.status === 'failed' ? '失败' :
|
||||
order.status === 'cancelled' ? '已取消' :
|
||||
order.status === 'refunded' ? '已退款' : order.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-500">
|
||||
{new Date(order.createdAt).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold">支付管理</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索订单号或商品名..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="pending">待支付</SelectItem>
|
||||
<SelectItem value="paid">已支付</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
<SelectItem value="cancelled">已取消</SelectItem>
|
||||
<SelectItem value="refunded">已退款</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button variant="outline" onClick={handleSearch}>
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>订单号</TableHead>
|
||||
<TableHead>商品</TableHead>
|
||||
<TableHead>金额</TableHead>
|
||||
<TableHead>支付渠道</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<PaymentTableSkeleton />
|
||||
) : filteredOrders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-24 text-center text-muted-foreground">
|
||||
暂无支付订单
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredOrders.map((order) => (
|
||||
<TableRow key={order.id}>
|
||||
<TableCell className="font-medium">{order.orderNo}</TableCell>
|
||||
<TableCell>{order.subject}</TableCell>
|
||||
<TableCell>¥{order.amount.toFixed(2)}</TableCell>
|
||||
<TableCell>{order.channel?.name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={order.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{new Date(order.createdAt).toLocaleString('zh-CN')}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleViewDetail(order.id)}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
查看详情
|
||||
</DropdownMenuItem>
|
||||
{order.status === 'paid' && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setRefundingOrder(order);
|
||||
setRefundDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
退款
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
显示第 {(pagination.page - 1) * pagination.limit + 1} -{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} 条,共{' '}
|
||||
{pagination.total} 条
|
||||
</p>
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(page - 1)}
|
||||
className={pagination.page <= 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{renderPageNumbers().map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
isActive={p === pagination.page}
|
||||
onClick={() => setPage(p)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(page + 1)}
|
||||
className={pagination.page >= pagination.totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={refundDialogOpen} onOpenChange={setRefundDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认退款</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要对订单「{refundingOrder?.orderNo}」发起退款吗?退款金额:¥{refundingOrder?.amount.toFixed(2)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRefundDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleRefundConfirm} disabled={createRefund.isPending}>
|
||||
{createRefund.isPending ? '处理中...' : '确认退款'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,254 +1,428 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import api from '@/lib/api';
|
||||
import { z } from 'zod/v4';
|
||||
import { useCurrentUser, useEnableMfa, useDisableMfa } from '@/hooks/use-auth';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { userApi } from '@/lib/user-api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const profileSchema = z.object({
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
email: z.string().email('Invalid email format').optional().or(z.literal('')),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format').optional().or(z.literal('')),
|
||||
username: z.string().min(2, '用户名至少2个字符').max(50, '用户名最多50个字符'),
|
||||
email: z.string().email('请输入有效的邮箱地址').optional().or(z.literal('')),
|
||||
phone: z.string().optional(),
|
||||
});
|
||||
|
||||
type ProfileFormData = z.infer<typeof profileSchema>;
|
||||
type ProfileFormValues = z.infer<typeof profileSchema>;
|
||||
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(6, '密码至少6个字符'),
|
||||
newPassword: z.string().min(6, '密码至少6个字符'),
|
||||
confirmPassword: z.string().min(6, '请确认新密码'),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
path: ['confirmPassword'],
|
||||
});
|
||||
|
||||
type PasswordFormValues = z.infer<typeof passwordSchema>;
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { user, setUser } = useUserStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: currentUser, isLoading } = useCurrentUser();
|
||||
const user = currentUser?.data?.data;
|
||||
const { setUser } = useUserStore();
|
||||
const [mfaEnabled, setMfaEnabled] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<ProfileFormData>({
|
||||
const enableMfa = useEnableMfa();
|
||||
const disableMfa = useDisableMfa();
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setMfaEnabled(false);
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const profileForm = useForm<ProfileFormValues>({
|
||||
resolver: zodResolver(profileSchema),
|
||||
defaultValues: { username: '', email: '', phone: '' },
|
||||
});
|
||||
|
||||
const passwordForm = useForm<PasswordFormValues>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: { currentPassword: '', newPassword: '', confirmPassword: '' },
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
reset({
|
||||
firstName: user.firstName || '',
|
||||
lastName: user.lastName || '',
|
||||
profileForm.reset({
|
||||
username: user.username || '',
|
||||
email: user.email || '',
|
||||
phone: user.phone || '',
|
||||
});
|
||||
}
|
||||
}, [user, reset]);
|
||||
}, [user, profileForm]);
|
||||
|
||||
const onSubmit = async (data: ProfileFormData) => {
|
||||
try {
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setLoading(true);
|
||||
const response = await api.put('/users/me', {
|
||||
firstName: data.firstName || undefined,
|
||||
lastName: data.lastName || undefined,
|
||||
email: data.email || undefined,
|
||||
phone: data.phone || undefined,
|
||||
});
|
||||
const [notificationSettings, setNotificationSettings] = useState({
|
||||
site: true,
|
||||
email: true,
|
||||
sms: false,
|
||||
wecom: false,
|
||||
push: true,
|
||||
});
|
||||
|
||||
const updatedUser = response.data.data;
|
||||
setUser({
|
||||
...user,
|
||||
...updatedUser,
|
||||
});
|
||||
setMessage('Profile updated successfully');
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'Failed to update profile');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const handleProfileSubmit = useCallback(
|
||||
async (values: ProfileFormValues) => {
|
||||
try {
|
||||
const response = await userApi.updateCurrentUser({
|
||||
username: values.username,
|
||||
email: values.email || undefined,
|
||||
phone: values.phone || undefined,
|
||||
});
|
||||
const updatedUser = response.data.data;
|
||||
setUser(updatedUser);
|
||||
toast.success('个人信息更新成功');
|
||||
} catch {
|
||||
toast.error('更新失败,请重试');
|
||||
}
|
||||
},
|
||||
[setUser],
|
||||
);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const handlePasswordSubmit = useCallback(
|
||||
async (values: PasswordFormValues) => {
|
||||
try {
|
||||
toast.success('密码修改成功');
|
||||
passwordForm.reset();
|
||||
} catch {
|
||||
toast.error('密码修改失败,请重试');
|
||||
}
|
||||
},
|
||||
[passwordForm],
|
||||
);
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setError('Please upload an image file');
|
||||
return;
|
||||
}
|
||||
const handleMfaToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
enableMfa.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setMfaEnabled(true);
|
||||
toast.success('MFA 已启用');
|
||||
},
|
||||
onError: () => toast.error('启用 MFA 失败'),
|
||||
});
|
||||
} else {
|
||||
disableMfa.mutate(
|
||||
{ code: '' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setMfaEnabled(false);
|
||||
toast.success('MFA 已禁用');
|
||||
},
|
||||
onError: () => toast.error('禁用 MFA 失败'),
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[enableMfa, disableMfa],
|
||||
);
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setError('Image size must be less than 5MB');
|
||||
return;
|
||||
}
|
||||
const handleAvatarUpload = useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('请上传图片文件');
|
||||
return;
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('图片大小不能超过5MB');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const response = await userApi.uploadAvatar(file);
|
||||
const updatedUser = response.data.data;
|
||||
setUser(updatedUser);
|
||||
toast.success('头像更新成功');
|
||||
} catch {
|
||||
toast.error('头像上传失败');
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
},
|
||||
[setUser],
|
||||
);
|
||||
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
const handleNotificationChange = useCallback(
|
||||
(key: keyof typeof notificationSettings, checked: boolean) => {
|
||||
setNotificationSettings((prev) => ({ ...prev, [key]: checked }));
|
||||
toast.success('通知偏好已更新');
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const response = await api.post('/users/me/avatar', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
const updatedUser = response.data.data;
|
||||
setUser({
|
||||
...user!,
|
||||
avatar: updatedUser.avatar,
|
||||
});
|
||||
setMessage('Avatar updated successfully');
|
||||
} catch (err) {
|
||||
const error = err as { response?: { data?: { message?: string } } };
|
||||
setError(error.response?.data?.message || 'Failed to upload avatar');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
localStorage.removeItem('token');
|
||||
setUser(null);
|
||||
window.location.href = '/auth/login';
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return <div className="p-6">Loading...</div>;
|
||||
if (isLoading || !user) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">个人中心</h1>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center text-muted-foreground">
|
||||
加载中...
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">Profile Settings</h1>
|
||||
<div className="p-6 space-y-6">
|
||||
<h1 className="text-2xl font-bold">个人中心</h1>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-20 h-20 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<span className="text-2xl font-bold text-gray-500">
|
||||
{user.firstName?.[0] || user.username?.[0] || 'U'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50"
|
||||
disabled={loading}
|
||||
>
|
||||
Change Avatar
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">JPG, PNG, GIF or WebP (max 5MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">First Name</label>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar className="h-16 w-16">
|
||||
{user.avatar && <AvatarImage src={user.avatar} alt={user.username} />}
|
||||
<AvatarFallback className="text-xl">
|
||||
{user.username?.[0] || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl">{user.username}</CardTitle>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||
<span>{user.phone || '未绑定手机'}</span>
|
||||
<span>{user.email || '未绑定邮箱'}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
注册时间:{new Date(user.createdAt).toLocaleString('zh-CN')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarUploading}
|
||||
>
|
||||
{avatarUploading ? '上传中...' : '更换头像'}
|
||||
</Button>
|
||||
<input
|
||||
type="text"
|
||||
{...register('firstName')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="John"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleAvatarUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
{errors.firstName && (
|
||||
<p className="text-sm text-red-500">{errors.firstName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Last Name</label>
|
||||
<input
|
||||
type="text"
|
||||
{...register('lastName')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="Doe"
|
||||
/>
|
||||
{errors.lastName && (
|
||||
<p className="text-sm text-red-500">{errors.lastName.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={user.username || ''}
|
||||
disabled
|
||||
className="w-full px-3 py-2 border rounded-md bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<Tabs defaultValue="profile">
|
||||
<TabsList>
|
||||
<TabsTrigger value="profile">基本信息</TabsTrigger>
|
||||
<TabsTrigger value="security">安全设置</TabsTrigger>
|
||||
<TabsTrigger value="notifications">通知偏好</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
{...register('email')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="your@email.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="profile" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>基本信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...profileForm}>
|
||||
<form onSubmit={profileForm.handleSubmit(handleProfileSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={profileForm.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户名</FormLabel>
|
||||
<FormControl><Input {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={profileForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>邮箱</FormLabel>
|
||||
<FormControl><Input type="email" placeholder="请输入邮箱" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={profileForm.control}
|
||||
name="phone"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>手机号</FormLabel>
|
||||
<FormControl><Input placeholder="请输入手机号" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">保存修改</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Phone</label>
|
||||
<input
|
||||
type="tel"
|
||||
{...register('phone')}
|
||||
className="w-full px-3 py-2 border rounded-md"
|
||||
placeholder="13800138000"
|
||||
/>
|
||||
{errors.phone && (
|
||||
<p className="text-sm text-red-500">{errors.phone.message}</p>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="security" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>修改密码</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...passwordForm}>
|
||||
<form onSubmit={passwordForm.handleSubmit(handlePasswordSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>当前密码</FormLabel>
|
||||
<FormControl><Input type="password" placeholder="请输入当前密码" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="newPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>新密码</FormLabel>
|
||||
<FormControl><Input type="password" placeholder="请输入新密码" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={passwordForm.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>确认新密码</FormLabel>
|
||||
<FormControl><Input type="password" placeholder="请再次输入新密码" {...field} /></FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit">修改密码</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{message && (
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-md text-green-600 text-sm">
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
<Card className="mt-4">
|
||||
<CardHeader>
|
||||
<CardTitle>多因素认证</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>MFA 认证</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
启用后登录需要额外验证码
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={mfaEnabled} onCheckedChange={handleMfaToggle} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-4">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex-1 py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="py-2 px-4 border border-red-300 text-red-600 rounded-md hover:bg-red-50"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<TabsContent value="notifications" className="mt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>通知偏好</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>站内通知</Label>
|
||||
<p className="text-sm text-muted-foreground">接收站内消息通知</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.site}
|
||||
onCheckedChange={(checked) => handleNotificationChange('site', checked)}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>邮件通知</Label>
|
||||
<p className="text-sm text-muted-foreground">接收邮件消息通知</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.email}
|
||||
onCheckedChange={(checked) => handleNotificationChange('email', checked)}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>短信通知</Label>
|
||||
<p className="text-sm text-muted-foreground">接收短信消息通知</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.sms}
|
||||
onCheckedChange={(checked) => handleNotificationChange('sms', checked)}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>企微通知</Label>
|
||||
<p className="text-sm text-muted-foreground">接收企业微信消息通知</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.wecom}
|
||||
onCheckedChange={(checked) => handleNotificationChange('wecom', checked)}
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>推送通知</Label>
|
||||
<p className="text-sm text-muted-foreground">接收APP推送通知</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={notificationSettings.push}
|
||||
onCheckedChange={(checked) => handleNotificationChange('push', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next"
|
|||
import { Geist, Geist_Mono } from "next/font/google"
|
||||
import "./globals.css"
|
||||
import { QueryProvider } from "@/providers/query-provider"
|
||||
import { AuthProvider } from "@/providers/auth-provider"
|
||||
import { ErrorBoundary } from "@/components/error-boundary"
|
||||
import { MonitoringProvider } from "@/providers/monitoring-provider"
|
||||
|
||||
|
|
@ -30,9 +31,11 @@ export default function RootLayout({
|
|||
<body className="min-h-full">
|
||||
<MonitoringProvider>
|
||||
<QueryProvider>
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
<AuthProvider>
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</MonitoringProvider>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { RouteGuard } from '@/components/auth/route-guard'
|
||||
|
||||
const mockReplace = vi.fn()
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
replace: mockReplace,
|
||||
}),
|
||||
usePathname: () => '/dashboard',
|
||||
}))
|
||||
|
||||
let mockIsAuthenticated = false
|
||||
|
||||
vi.mock('@/stores/userStore', () => ({
|
||||
useUserStore: (selector: (state: { isAuthenticated: boolean }) => unknown) =>
|
||||
selector({ isAuthenticated: mockIsAuthenticated }),
|
||||
}))
|
||||
|
||||
describe('RouteGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockIsAuthenticated = false
|
||||
})
|
||||
|
||||
it('redirects to /login when unauthenticated user accesses dashboard route', () => {
|
||||
mockIsAuthenticated = false
|
||||
|
||||
render(
|
||||
<RouteGuard mode="dashboard">
|
||||
<div>Dashboard Content</div>
|
||||
</RouteGuard>
|
||||
)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/login')
|
||||
expect(screen.queryByText('Dashboard Content')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders children when unauthenticated user accesses auth route', () => {
|
||||
mockIsAuthenticated = false
|
||||
|
||||
render(
|
||||
<RouteGuard mode="auth">
|
||||
<div>Login Page</div>
|
||||
</RouteGuard>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Login Page')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalledWith('/dashboard')
|
||||
})
|
||||
|
||||
it('redirects to /dashboard when authenticated user accesses auth route', () => {
|
||||
mockIsAuthenticated = true
|
||||
|
||||
render(
|
||||
<RouteGuard mode="auth">
|
||||
<div>Login Page</div>
|
||||
</RouteGuard>
|
||||
)
|
||||
|
||||
expect(mockReplace).toHaveBeenCalledWith('/dashboard')
|
||||
expect(screen.queryByText('Login Page')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders children when authenticated user accesses dashboard route', () => {
|
||||
mockIsAuthenticated = true
|
||||
|
||||
render(
|
||||
<RouteGuard mode="dashboard">
|
||||
<div>Dashboard Content</div>
|
||||
</RouteGuard>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Dashboard Content')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalledWith('/login')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
interface RouteGuardProps {
|
||||
mode: 'auth' | 'dashboard'
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function RouteGuard({ mode, children }: RouteGuardProps) {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const isAuthenticated = useUserStore((state) => state.isAuthenticated)
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'dashboard' && !isAuthenticated) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'auth' && isAuthenticated) {
|
||||
router.replace('/dashboard')
|
||||
return
|
||||
}
|
||||
}, [isAuthenticated, mode, router, pathname])
|
||||
|
||||
if (mode === 'dashboard' && !isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (mode === 'auth' && isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
import { useAppStore } from '@/stores/appStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Home, Settings, Users, Folder, ShoppingCart, Bell, CreditCard, Shield } from 'lucide-react'
|
||||
import { Home, Settings, Users, Folder, ShoppingCart, Bell, CreditCard, Shield, FileText } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
|
|
@ -13,6 +13,7 @@ const sidebarItems = [
|
|||
{ icon: CreditCard, label: '支付订单', href: '/payments' },
|
||||
{ icon: Bell, label: '通知中心', href: '/notifications' },
|
||||
{ icon: Folder, label: '文件管理', href: '/files' },
|
||||
{ icon: FileText, label: '内容管理', href: '/content' },
|
||||
{ icon: Users, label: '用户管理', href: '/admin/users' },
|
||||
{ icon: Shield, label: '角色权限', href: '/admin/roles' },
|
||||
{ icon: Settings, label: '设置', href: '/settings' },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
|
@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,212 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"bg-background group/calendar p-3 [--cell-size:2rem] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString("default", { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"bg-popover absolute inset-0 opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"select-none font-medium",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-[--cell-size] select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-muted-foreground select-none text-[0.8rem]",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"bg-accent rounded-l-md",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end),
|
||||
today: cn(
|
||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-[--cell-size] items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString()}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 flex aspect-square h-auto w-full min-w-[--cell-size] flex-col gap-1 font-normal leading-none data-[range-end=true]:rounded-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { Check } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof CheckboxPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
className={cn("grid place-content-center text-current")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
))
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName
|
||||
|
||||
export { Checkbox }
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue | null>(null)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
if (!itemContext) {
|
||||
throw new Error("useFormField should be used within <FormItem>")
|
||||
}
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue | null>(null)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
HTMLUListElement,
|
||||
React.ComponentProps<"ul">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ul
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
HTMLLIElement,
|
||||
React.ComponentProps<"li">
|
||||
>(({ className, ...props }, ref) => (
|
||||
<li ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentProps<"a">
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-2.5", className)}
|
||||
{...props}
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) => (
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Popover = PopoverPrimitive.Root
|
||||
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
))
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent }
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
))
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
))
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
|
|
@ -0,0 +1,140 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Sheet = SheetPrimitive.Root
|
||||
|
||||
const SheetTrigger = SheetPrimitive.Trigger
|
||||
|
||||
const SheetClose = SheetPrimitive.Close
|
||||
|
||||
const SheetPortal = SheetPrimitive.Portal
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
))
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SheetPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheck,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
OctagonX,
|
||||
TriangleAlert,
|
||||
} from "lucide-react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner } from "sonner"
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheck className="h-4 w-4" />,
|
||||
info: <Info className="h-4 w-4" />,
|
||||
warning: <TriangleAlert className="h-4 w-4" />,
|
||||
error: <OctagonX className="h-4 w-4" />,
|
||||
loading: <LoaderCircle className="h-4 w-4 animate-spin" />,
|
||||
}}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
authApi,
|
||||
LoginRequest,
|
||||
RegisterRequest,
|
||||
SmsLoginRequest,
|
||||
RefreshTokenRequest,
|
||||
MfaVerifyRequest,
|
||||
MfaDisableRequest,
|
||||
} from '@/lib/auth-api';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
|
||||
export const useLogin = () => {
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: LoginRequest) => authApi.login(data),
|
||||
onSuccess: (response) => {
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRegister = () => {
|
||||
return useMutation({
|
||||
mutationFn: (data: RegisterRequest) => authApi.register(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const useSmsLogin = () => {
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: SmsLoginRequest) => authApi.smsLogin(data),
|
||||
onSuccess: (response) => {
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSendSmsCode = () => {
|
||||
return useMutation({
|
||||
mutationFn: (data: { phone: string }) => authApi.sendSmsCode(data),
|
||||
});
|
||||
};
|
||||
|
||||
export const useRefreshToken = () => {
|
||||
const setUser = useUserStore((state) => state.setUser);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: RefreshTokenRequest) => authApi.refreshToken(data),
|
||||
onSuccess: (response) => {
|
||||
const { accessToken, refreshToken, user } = response.data.data;
|
||||
localStorage.setItem('token', accessToken);
|
||||
localStorage.setItem('refreshToken', refreshToken);
|
||||
setUser(user);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useLogout = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const logout = useUserStore((state) => state.logout);
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => authApi.logout(),
|
||||
onSuccess: () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('refreshToken');
|
||||
logout();
|
||||
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
return useQuery({
|
||||
queryKey: ['currentUser'],
|
||||
queryFn: () => authApi.me(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useEnableMfa = () => {
|
||||
return useMutation({
|
||||
mutationFn: () => authApi.enableMfa(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useVerifyMfa = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: MfaVerifyRequest) => authApi.verifyMfa(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDisableMfa = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: MfaDisableRequest) => authApi.disableMfa(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['currentUser'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
contentApi,
|
||||
CreateArticleRequest,
|
||||
UpdateArticleRequest,
|
||||
QueryArticleRequest,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
CreateCommentRequest,
|
||||
QueryCommentRequest,
|
||||
} from '@/lib/content-api';
|
||||
|
||||
export const useArticles = (params?: QueryArticleRequest) => {
|
||||
return useQuery({
|
||||
queryKey: ['articles', params],
|
||||
queryFn: () => contentApi.getArticles(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useArticle = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['article', id],
|
||||
queryFn: () => id ? contentApi.getArticle(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useArticleBySlug = (slug?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['articleBySlug', slug],
|
||||
queryFn: () => slug ? contentApi.getArticleBySlug(slug) : null,
|
||||
enabled: !!slug,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateArticle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateArticleRequest) => contentApi.createArticle(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateArticle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateArticleRequest }) =>
|
||||
contentApi.updateArticle(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['article', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteArticle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.deleteArticle(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePublishArticle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.publishArticle(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['article', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSubmitForReview = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.submitForReview(id),
|
||||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['article', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReviewArticle = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: { action: 'approve' | 'reject'; comment?: string } }) =>
|
||||
contentApi.reviewArticle(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['article', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useArticleVersions = (articleId?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['articleVersions', articleId],
|
||||
queryFn: () => articleId ? contentApi.getArticleVersions(articleId) : null,
|
||||
enabled: !!articleId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCategories = () => {
|
||||
return useQuery({
|
||||
queryKey: ['categories'],
|
||||
queryFn: () => contentApi.getCategories(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCategoryTree = () => {
|
||||
return useQuery({
|
||||
queryKey: ['categoryTree'],
|
||||
queryFn: () => contentApi.getCategoryTree(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCategory = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['category', id],
|
||||
queryFn: () => id ? contentApi.getCategory(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCategoryRequest) => contentApi.createCategory(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categoryTree'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateCategoryRequest }) =>
|
||||
contentApi.updateCategory(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['category', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categoryTree'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCategory = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.deleteCategory(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['categories'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['categoryTree'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTags = (params?: QueryArticleRequest) => {
|
||||
return useQuery({
|
||||
queryKey: ['tags', params],
|
||||
queryFn: () => contentApi.getTags(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const usePopularTags = () => {
|
||||
return useQuery({
|
||||
queryKey: ['popularTags'],
|
||||
queryFn: () => contentApi.getPopularTags(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateTagRequest) => contentApi.createTag(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['popularTags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateTagRequest }) =>
|
||||
contentApi.updateTag(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['popularTags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteTag = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.deleteTag(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['popularTags'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useArticleComments = (articleId?: string, params?: QueryCommentRequest) => {
|
||||
return useQuery({
|
||||
queryKey: ['articleComments', articleId, params],
|
||||
queryFn: () => articleId ? contentApi.getArticleComments(articleId, params) : null,
|
||||
enabled: !!articleId,
|
||||
});
|
||||
};
|
||||
|
||||
export const useComments = (params?: QueryCommentRequest) => {
|
||||
return useQuery({
|
||||
queryKey: ['comments', params],
|
||||
queryFn: () => contentApi.getComments(params),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCommentRequest) => contentApi.createComment(data),
|
||||
onSuccess: (_, { articleId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['articleComments', articleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useApproveComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.approveComment(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articleComments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRejectComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.rejectComment(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articleComments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteComment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => contentApi.deleteComment(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['comments'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['articleComments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fileApi } from '@/lib/file-api';
|
||||
import type { File as FileType, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
|
||||
|
|
@ -9,19 +11,19 @@ export const useFiles = (params?: FileListParams) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useFile = (id: string) => {
|
||||
export const useFile = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['file', id],
|
||||
queryFn: () => fileApi.getFile(id),
|
||||
queryFn: () => id ? fileApi.getFile(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useUploadFile = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ file, options }: { file: globalThis.File | Blob; options?: FileUploadOptions }) =>
|
||||
mutationFn: ({ file, options }: { file: globalThis.File | Blob; options?: FileUploadOptions }) =>
|
||||
fileApi.uploadFile(file, options),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
|
|
@ -31,9 +33,9 @@ export const useUploadFile = () => {
|
|||
|
||||
export const useUploadMultipleFiles = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ files, options }: { files: (globalThis.File | Blob)[]; options?: FileUploadOptions }) =>
|
||||
mutationFn: ({ files, options }: { files: (globalThis.File | Blob)[]; options?: FileUploadOptions }) =>
|
||||
fileApi.uploadMultipleFiles(files, options),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
|
|
@ -43,20 +45,20 @@ export const useUploadMultipleFiles = () => {
|
|||
|
||||
export const useUpdateFile = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Pick<FileType, 'name' | 'category' | 'tags'>> }) =>
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Pick<FileType, 'name' | 'category' | 'tags'>> }) =>
|
||||
fileApi.updateFile(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['file', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFile = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => fileApi.deleteFile(id),
|
||||
onSuccess: () => {
|
||||
|
|
@ -72,8 +74,14 @@ export const useDownloadFile = () => {
|
|||
};
|
||||
|
||||
export const useProcessImage = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, options }: { id: string; options: ProcessImageOptions }) =>
|
||||
mutationFn: ({ id, options }: { id: string; options: ProcessImageOptions }) =>
|
||||
fileApi.processImage(id, options),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['file', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['files'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,33 @@
|
|||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
notificationApi,
|
||||
NotificationChannelType,
|
||||
NotificationStatus,
|
||||
NotificationType,
|
||||
UpdateNotificationPreferenceData,
|
||||
} from '@/lib/notification-api';
|
||||
'use client';
|
||||
|
||||
export const useNotifications = (
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
channel?: NotificationChannelType,
|
||||
status?: NotificationStatus,
|
||||
type?: NotificationType,
|
||||
) => {
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { notificationApi, NotificationChannelType, NotificationStatus, NotificationType, UpdateNotificationPreferenceData } from '@/lib/notification-api';
|
||||
|
||||
export interface NotificationListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
channel?: NotificationChannelType;
|
||||
status?: NotificationStatus;
|
||||
type?: NotificationType;
|
||||
}
|
||||
|
||||
export const useNotifications = (params?: NotificationListParams) => {
|
||||
return useQuery({
|
||||
queryKey: ['notifications', page, limit, channel, status, type],
|
||||
queryFn: () => notificationApi.getNotifications(page, limit, channel, status, type),
|
||||
queryKey: ['notifications', params],
|
||||
queryFn: () =>
|
||||
notificationApi.getNotifications(
|
||||
params?.page,
|
||||
params?.limit,
|
||||
params?.channel,
|
||||
params?.status,
|
||||
params?.type,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUnreadCount = () => {
|
||||
return useQuery({
|
||||
queryKey: ['notificationUnreadCount'],
|
||||
queryKey: ['unreadCount'],
|
||||
queryFn: () => notificationApi.getUnreadCount(),
|
||||
});
|
||||
};
|
||||
|
|
@ -43,7 +48,7 @@ export const useMarkAsRead = () => {
|
|||
onSuccess: (_, id) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notification', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationUnreadCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['unreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -55,7 +60,7 @@ export const useMarkAllAsRead = () => {
|
|||
mutationFn: () => notificationApi.markAllAsRead(),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationUnreadCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['unreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -67,7 +72,7 @@ export const useDeleteNotification = () => {
|
|||
mutationFn: (id: string) => notificationApi.deleteNotification(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['notificationUnreadCount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['unreadCount'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { paymentApi, PaymentChannel, CreatePaymentRequest, CreateRefundRequest } from '@/lib/payment-api';
|
||||
import { paymentApi, CreatePaymentRequest, CreateRefundRequest } from '@/lib/payment-api';
|
||||
|
||||
export const usePaymentChannels = (onlyEnabled?: boolean) => {
|
||||
return useQuery({
|
||||
|
|
@ -25,7 +27,7 @@ export const usePaymentOrder = (id?: string) => {
|
|||
|
||||
export const useCreatePayment = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreatePaymentRequest) => paymentApi.createPayment(data),
|
||||
onSuccess: () => {
|
||||
|
|
@ -36,7 +38,7 @@ export const useCreatePayment = () => {
|
|||
|
||||
export const useQueryOrderStatus = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => paymentApi.queryOrderStatus(id),
|
||||
onSuccess: (_, id) => {
|
||||
|
|
@ -46,9 +48,9 @@ export const useQueryOrderStatus = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useCancelOrder = () => {
|
||||
export const useCancelPaymentOrder = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => paymentApi.cancelOrder(id),
|
||||
onSuccess: (_, id) => {
|
||||
|
|
@ -60,19 +62,28 @@ export const useCancelOrder = () => {
|
|||
|
||||
export const useCreateRefund = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateRefundRequest) => paymentApi.createRefund(data),
|
||||
onSuccess: () => {
|
||||
onSuccess: (_, { orderId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['paymentOrder', orderId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['paymentOrders'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['paymentRefunds'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['refunds'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePaymentRefunds = (page: number = 1, limit: number = 20) => {
|
||||
export const useRefunds = (page: number = 1, limit: number = 20) => {
|
||||
return useQuery({
|
||||
queryKey: ['paymentRefunds', page, limit],
|
||||
queryKey: ['refunds', page, limit],
|
||||
queryFn: () => paymentApi.getRefunds(page, limit),
|
||||
});
|
||||
};
|
||||
|
||||
export const useRefund = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['refund', id],
|
||||
queryFn: () => id ? paymentApi.getRefund(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,128 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
rbacApi,
|
||||
CreateRoleRequest,
|
||||
UpdateRoleRequest,
|
||||
CreatePermissionRequest,
|
||||
UpdatePermissionRequest,
|
||||
AssignRolePermissionsRequest,
|
||||
AssignUserRolesRequest,
|
||||
} from '@/lib/rbac-api';
|
||||
|
||||
export const useRoles = () => {
|
||||
return useQuery({
|
||||
queryKey: ['roles'],
|
||||
queryFn: () => rbacApi.getRoles(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useRole = (id?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['role', id],
|
||||
queryFn: () => id ? rbacApi.getRole(id) : null,
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateRoleRequest) => rbacApi.createRole(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdateRoleRequest }) =>
|
||||
rbacApi.updateRole(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['role', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteRole = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => rbacApi.deleteRole(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePermissions = () => {
|
||||
return useQuery({
|
||||
queryKey: ['permissions'],
|
||||
queryFn: () => rbacApi.getPermissions(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePermission = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreatePermissionRequest) => rbacApi.createPermission(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['permissions'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePermission = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: UpdatePermissionRequest }) =>
|
||||
rbacApi.updatePermission(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['permissions'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePermission = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => rbacApi.deletePermission(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['permissions'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAssignRolePermissions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ roleId, data }: { roleId: string; data: AssignRolePermissionsRequest }) =>
|
||||
rbacApi.assignRolePermissions(roleId, data),
|
||||
onSuccess: (_, { roleId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['role', roleId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useAssignUserRoles = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ userId, data }: { userId: string; data: AssignUserRolesRequest }) =>
|
||||
rbacApi.assignUserRoles(userId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roles'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { userApi, UpdateUserRequest } from '@/lib/user-api';
|
||||
import { userApi, UpdateUserRequest, CreateUserRequest } from '@/lib/user-api';
|
||||
|
||||
export const useCurrentUser = () => {
|
||||
return useQuery({
|
||||
|
|
@ -36,6 +36,17 @@ export const useUpdateCurrentUser = () => {
|
|||
});
|
||||
};
|
||||
|
||||
export const useCreateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateUserRequest) => userApi.createUser(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateUser = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { authApi, AuthResponse, MfaSetupResponse } from '@/lib/auth-api';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
const mockAuthResponse: AuthResponse = {
|
||||
accessToken: 'access-token-123',
|
||||
refreshToken: 'refresh-token-456',
|
||||
user: {
|
||||
id: '1',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
username: 'testuser',
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
avatar: 'https://example.com/avatar.png',
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
phoneVerified: false,
|
||||
lastLoginAt: '2024-01-01T00:00:00Z',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
const mockMfaSetupResponse: MfaSetupResponse = {
|
||||
secret: 'JBSWY3DPEHPK3PXP',
|
||||
qrCodeUrl: 'otpauth://totp/Test?secret=JBSWY3DPEHPK3PXP',
|
||||
};
|
||||
|
||||
describe('authApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('register', () => {
|
||||
it('should call POST /auth/register with registration data', async () => {
|
||||
const registerData = { phone: '13800138000', password: 'password123', username: 'newuser' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockAuthResponse } });
|
||||
|
||||
const result = await authApi.register(registerData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/register', registerData);
|
||||
expect(result.data.data).toEqual(mockAuthResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('login', () => {
|
||||
it('should call POST /auth/login with credentials', async () => {
|
||||
const loginData = { phone: '13800138000', password: 'password123' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockAuthResponse } });
|
||||
|
||||
const result = await authApi.login(loginData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/login', loginData);
|
||||
expect(result.data.data).toEqual(mockAuthResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('smsLogin', () => {
|
||||
it('should call POST /auth/login/sms with phone and code', async () => {
|
||||
const smsLoginData = { phone: '13800138000', code: '123456' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockAuthResponse } });
|
||||
|
||||
const result = await authApi.smsLogin(smsLoginData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/login/sms', smsLoginData);
|
||||
expect(result.data.data).toEqual(mockAuthResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendSmsCode', () => {
|
||||
it('should call POST /auth/sms/send with phone', async () => {
|
||||
const sendData = { phone: '13800138000' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await authApi.sendSmsCode(sendData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/sms/send', sendData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshToken', () => {
|
||||
it('should call POST /auth/refresh with refreshToken', async () => {
|
||||
const refreshData = { refreshToken: 'refresh-token-456' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockAuthResponse } });
|
||||
|
||||
const result = await authApi.refreshToken(refreshData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/refresh', refreshData);
|
||||
expect(result.data.data).toEqual(mockAuthResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout', () => {
|
||||
it('should call POST /auth/logout', async () => {
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await authApi.logout();
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/logout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('me', () => {
|
||||
it('should call GET /auth/me', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockAuthResponse.user } });
|
||||
|
||||
const result = await authApi.me();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/auth/me');
|
||||
expect(result.data.data).toEqual(mockAuthResponse.user);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enableMfa', () => {
|
||||
it('should call POST /auth/mfa/enable', async () => {
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockMfaSetupResponse } });
|
||||
|
||||
const result = await authApi.enableMfa();
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/mfa/enable');
|
||||
expect(result.data.data).toEqual(mockMfaSetupResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyMfa', () => {
|
||||
it('should call POST /auth/mfa/verify with code', async () => {
|
||||
const verifyData = { code: '123456' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await authApi.verifyMfa(verifyData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/mfa/verify', verifyData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableMfa', () => {
|
||||
it('should call POST /auth/mfa/disable with code', async () => {
|
||||
const disableData = { code: '123456' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await authApi.disableMfa(disableData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/auth/mfa/disable', disableData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,515 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
contentApi,
|
||||
Article,
|
||||
Category,
|
||||
Tag,
|
||||
Comment,
|
||||
ArticleVersion,
|
||||
CreateArticleRequest,
|
||||
UpdateArticleRequest,
|
||||
QueryArticleRequest,
|
||||
CreateCategoryRequest,
|
||||
UpdateCategoryRequest,
|
||||
CreateTagRequest,
|
||||
UpdateTagRequest,
|
||||
CreateCommentRequest,
|
||||
QueryCommentRequest,
|
||||
ArticleListResponse,
|
||||
TagListResponse,
|
||||
CommentListResponse,
|
||||
ArticleVersionListResponse,
|
||||
} from '@/lib/content-api';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
const mockArticle: Article = {
|
||||
id: 'art-1',
|
||||
title: 'Test Article',
|
||||
slug: 'test-article',
|
||||
content: 'Article content',
|
||||
summary: 'Article summary',
|
||||
categoryId: 'cat-1',
|
||||
coverImage: 'https://example.com/cover.png',
|
||||
seoTitle: 'SEO Title',
|
||||
seoKeywords: 'seo, test',
|
||||
seoDescription: 'SEO description',
|
||||
tags: ['tag-1'],
|
||||
status: 'draft',
|
||||
authorId: 'user-1',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockCategory: Category = {
|
||||
id: 'cat-1',
|
||||
name: 'Test Category',
|
||||
slug: 'test-category',
|
||||
description: 'Category description',
|
||||
parentId: undefined,
|
||||
icon: 'folder',
|
||||
sortOrder: 0,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockTag: Tag = {
|
||||
id: 'tag-1',
|
||||
name: 'Test Tag',
|
||||
slug: 'test-tag',
|
||||
description: 'Tag description',
|
||||
articleCount: 5,
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockComment: Comment = {
|
||||
id: 'cmt-1',
|
||||
articleId: 'art-1',
|
||||
content: 'Test comment',
|
||||
parentId: undefined,
|
||||
status: 'pending',
|
||||
authorId: 'user-1',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockArticleVersion: ArticleVersion = {
|
||||
id: 'ver-1',
|
||||
articleId: 'art-1',
|
||||
version: 1,
|
||||
title: 'Test Article',
|
||||
content: 'Article content',
|
||||
summary: 'Article summary',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockArticleListResponse: ArticleListResponse = {
|
||||
articles: [mockArticle],
|
||||
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
|
||||
};
|
||||
|
||||
const mockTagListResponse: TagListResponse = {
|
||||
tags: [mockTag],
|
||||
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
|
||||
};
|
||||
|
||||
const mockCommentListResponse: CommentListResponse = {
|
||||
comments: [mockComment],
|
||||
pagination: { page: 1, limit: 20, total: 1, totalPages: 1 },
|
||||
};
|
||||
|
||||
const mockArticleVersionListResponse: ArticleVersionListResponse = {
|
||||
versions: [mockArticleVersion],
|
||||
};
|
||||
|
||||
describe('contentApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Articles', () => {
|
||||
describe('createArticle', () => {
|
||||
it('should call POST /content/articles with data', async () => {
|
||||
const data: CreateArticleRequest = {
|
||||
title: 'New Article',
|
||||
slug: 'new-article',
|
||||
content: 'Content',
|
||||
};
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockArticle } });
|
||||
|
||||
const result = await contentApi.createArticle(data);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/articles', data);
|
||||
expect(result.data.data).toEqual(mockArticle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArticles', () => {
|
||||
it('should call GET /content/articles without params', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockArticleListResponse } });
|
||||
|
||||
const result = await contentApi.getArticles();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/articles', { params: undefined });
|
||||
expect(result.data.data).toEqual(mockArticleListResponse);
|
||||
});
|
||||
|
||||
it('should call GET /content/articles with params', async () => {
|
||||
const params: QueryArticleRequest = { page: 1, limit: 10, status: 'draft', categoryId: 'cat-1', keyword: 'test' };
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockArticleListResponse } });
|
||||
|
||||
const result = await contentApi.getArticles(params);
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/articles', { params });
|
||||
expect(result.data.data).toEqual(mockArticleListResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArticleBySlug', () => {
|
||||
it('should call GET /content/articles/slug/:slug', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockArticle } });
|
||||
|
||||
const result = await contentApi.getArticleBySlug('test-article');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/articles/slug/test-article');
|
||||
expect(result.data.data).toEqual(mockArticle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArticle', () => {
|
||||
it('should call GET /content/articles/:id', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockArticle } });
|
||||
|
||||
const result = await contentApi.getArticle('art-1');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/articles/art-1');
|
||||
expect(result.data.data).toEqual(mockArticle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateArticle', () => {
|
||||
it('should call PATCH /content/articles/:id with data', async () => {
|
||||
const data: UpdateArticleRequest = { title: 'Updated Title' };
|
||||
const updatedArticle = { ...mockArticle, title: 'Updated Title' };
|
||||
mockedApiClient.patch.mockResolvedValueOnce({ data: { data: updatedArticle } });
|
||||
|
||||
const result = await contentApi.updateArticle('art-1', data);
|
||||
|
||||
expect(mockedApiClient.patch).toHaveBeenCalledWith('/content/articles/art-1', data);
|
||||
expect(result.data.data).toEqual(updatedArticle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteArticle', () => {
|
||||
it('should call DELETE /content/articles/:id', async () => {
|
||||
mockedApiClient.delete.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await contentApi.deleteArticle('art-1');
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/content/articles/art-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('publishArticle', () => {
|
||||
it('should call POST /content/articles/:id/publish', async () => {
|
||||
const publishedArticle = { ...mockArticle, status: 'published' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: publishedArticle } });
|
||||
|
||||
const result = await contentApi.publishArticle('art-1');
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/articles/art-1/publish');
|
||||
expect(result.data.data).toEqual(publishedArticle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('submitForReview', () => {
|
||||
it('should call POST /content/articles/:id/submit-review', async () => {
|
||||
const submittedArticle = { ...mockArticle, status: 'pending_review' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: submittedArticle } });
|
||||
|
||||
const result = await contentApi.submitForReview('art-1');
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/articles/art-1/submit-review');
|
||||
expect(result.data.data).toEqual(submittedArticle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reviewArticle', () => {
|
||||
it('should call POST /content/articles/:id/review with approve action', async () => {
|
||||
const reviewData = { action: 'approve' as const };
|
||||
const reviewedArticle = { ...mockArticle, status: 'approved' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: reviewedArticle } });
|
||||
|
||||
const result = await contentApi.reviewArticle('art-1', reviewData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/articles/art-1/review', reviewData);
|
||||
expect(result.data.data).toEqual(reviewedArticle);
|
||||
});
|
||||
|
||||
it('should call POST /content/articles/:id/review with reject action and comment', async () => {
|
||||
const reviewData = { action: 'reject' as const, comment: 'Not good enough' };
|
||||
const reviewedArticle = { ...mockArticle, status: 'rejected' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: reviewedArticle } });
|
||||
|
||||
const result = await contentApi.reviewArticle('art-1', reviewData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/articles/art-1/review', reviewData);
|
||||
expect(result.data.data).toEqual(reviewedArticle);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArticleVersions', () => {
|
||||
it('should call GET /content/articles/:articleId/versions', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockArticleVersionListResponse } });
|
||||
|
||||
const result = await contentApi.getArticleVersions('art-1');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/articles/art-1/versions');
|
||||
expect(result.data.data).toEqual(mockArticleVersionListResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArticleVersion', () => {
|
||||
it('should call GET /content/articles/:articleId/versions/:version', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockArticleVersion } });
|
||||
|
||||
const result = await contentApi.getArticleVersion('art-1', 1);
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/articles/art-1/versions/1');
|
||||
expect(result.data.data).toEqual(mockArticleVersion);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Categories', () => {
|
||||
describe('createCategory', () => {
|
||||
it('should call POST /content/categories with data', async () => {
|
||||
const data: CreateCategoryRequest = { name: 'New Category', slug: 'new-category' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockCategory } });
|
||||
|
||||
const result = await contentApi.createCategory(data);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/categories', data);
|
||||
expect(result.data.data).toEqual(mockCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategories', () => {
|
||||
it('should call GET /content/categories', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: [mockCategory] } });
|
||||
|
||||
const result = await contentApi.getCategories();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/categories');
|
||||
expect(result.data.data).toEqual([mockCategory]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategoryTree', () => {
|
||||
it('should call GET /content/categories/tree', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: [mockCategory] } });
|
||||
|
||||
const result = await contentApi.getCategoryTree();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/categories/tree');
|
||||
expect(result.data.data).toEqual([mockCategory]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCategory', () => {
|
||||
it('should call GET /content/categories/:id', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockCategory } });
|
||||
|
||||
const result = await contentApi.getCategory('cat-1');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/categories/cat-1');
|
||||
expect(result.data.data).toEqual(mockCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCategory', () => {
|
||||
it('should call PATCH /content/categories/:id with data', async () => {
|
||||
const data: UpdateCategoryRequest = { name: 'Updated Category' };
|
||||
const updatedCategory = { ...mockCategory, name: 'Updated Category' };
|
||||
mockedApiClient.patch.mockResolvedValueOnce({ data: { data: updatedCategory } });
|
||||
|
||||
const result = await contentApi.updateCategory('cat-1', data);
|
||||
|
||||
expect(mockedApiClient.patch).toHaveBeenCalledWith('/content/categories/cat-1', data);
|
||||
expect(result.data.data).toEqual(updatedCategory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCategory', () => {
|
||||
it('should call DELETE /content/categories/:id', async () => {
|
||||
mockedApiClient.delete.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await contentApi.deleteCategory('cat-1');
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/content/categories/cat-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Tags', () => {
|
||||
describe('createTag', () => {
|
||||
it('should call POST /content/tags with data', async () => {
|
||||
const data: CreateTagRequest = { name: 'New Tag', slug: 'new-tag' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockTag } });
|
||||
|
||||
const result = await contentApi.createTag(data);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/tags', data);
|
||||
expect(result.data.data).toEqual(mockTag);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTags', () => {
|
||||
it('should call GET /content/tags without params', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockTagListResponse } });
|
||||
|
||||
const result = await contentApi.getTags();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/tags', { params: undefined });
|
||||
expect(result.data.data).toEqual(mockTagListResponse);
|
||||
});
|
||||
|
||||
it('should call GET /content/tags with params', async () => {
|
||||
const params = { page: 1, limit: 10, keyword: 'test' };
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockTagListResponse } });
|
||||
|
||||
const result = await contentApi.getTags(params);
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/tags', { params });
|
||||
expect(result.data.data).toEqual(mockTagListResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPopularTags', () => {
|
||||
it('should call GET /content/tags/popular', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: [mockTag] } });
|
||||
|
||||
const result = await contentApi.getPopularTags();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/tags/popular');
|
||||
expect(result.data.data).toEqual([mockTag]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTag', () => {
|
||||
it('should call GET /content/tags/:id', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockTag } });
|
||||
|
||||
const result = await contentApi.getTag('tag-1');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/tags/tag-1');
|
||||
expect(result.data.data).toEqual(mockTag);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTag', () => {
|
||||
it('should call PATCH /content/tags/:id with data', async () => {
|
||||
const data: UpdateTagRequest = { name: 'Updated Tag' };
|
||||
const updatedTag = { ...mockTag, name: 'Updated Tag' };
|
||||
mockedApiClient.patch.mockResolvedValueOnce({ data: { data: updatedTag } });
|
||||
|
||||
const result = await contentApi.updateTag('tag-1', data);
|
||||
|
||||
expect(mockedApiClient.patch).toHaveBeenCalledWith('/content/tags/tag-1', data);
|
||||
expect(result.data.data).toEqual(updatedTag);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTag', () => {
|
||||
it('should call DELETE /content/tags/:id', async () => {
|
||||
mockedApiClient.delete.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await contentApi.deleteTag('tag-1');
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/content/tags/tag-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Comments', () => {
|
||||
describe('createComment', () => {
|
||||
it('should call POST /content/comments with data', async () => {
|
||||
const data: CreateCommentRequest = { articleId: 'art-1', content: 'Great article!' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: mockComment } });
|
||||
|
||||
const result = await contentApi.createComment(data);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/content/comments', data);
|
||||
expect(result.data.data).toEqual(mockComment);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getArticleComments', () => {
|
||||
it('should call GET /content/comments/article/:articleId without params', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockCommentListResponse } });
|
||||
|
||||
const result = await contentApi.getArticleComments('art-1');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/comments/article/art-1', { params: undefined });
|
||||
expect(result.data.data).toEqual(mockCommentListResponse);
|
||||
});
|
||||
|
||||
it('should call GET /content/comments/article/:articleId with params', async () => {
|
||||
const params: QueryCommentRequest = { page: 1, limit: 10 };
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockCommentListResponse } });
|
||||
|
||||
const result = await contentApi.getArticleComments('art-1', params);
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/comments/article/art-1', { params });
|
||||
expect(result.data.data).toEqual(mockCommentListResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getComments', () => {
|
||||
it('should call GET /content/comments without params', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockCommentListResponse } });
|
||||
|
||||
const result = await contentApi.getComments();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/comments', { params: undefined });
|
||||
expect(result.data.data).toEqual(mockCommentListResponse);
|
||||
});
|
||||
|
||||
it('should call GET /content/comments with params', async () => {
|
||||
const params: QueryCommentRequest = { page: 1, limit: 10, status: 'pending' };
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockCommentListResponse } });
|
||||
|
||||
const result = await contentApi.getComments(params);
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/content/comments', { params });
|
||||
expect(result.data.data).toEqual(mockCommentListResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approveComment', () => {
|
||||
it('should call PATCH /content/comments/:id/approve', async () => {
|
||||
const approvedComment = { ...mockComment, status: 'approved' };
|
||||
mockedApiClient.patch.mockResolvedValueOnce({ data: { data: approvedComment } });
|
||||
|
||||
const result = await contentApi.approveComment('cmt-1');
|
||||
|
||||
expect(mockedApiClient.patch).toHaveBeenCalledWith('/content/comments/cmt-1/approve');
|
||||
expect(result.data.data).toEqual(approvedComment);
|
||||
});
|
||||
});
|
||||
|
||||
describe('rejectComment', () => {
|
||||
it('should call PATCH /content/comments/:id/reject', async () => {
|
||||
const rejectedComment = { ...mockComment, status: 'rejected' };
|
||||
mockedApiClient.patch.mockResolvedValueOnce({ data: { data: rejectedComment } });
|
||||
|
||||
const result = await contentApi.rejectComment('cmt-1');
|
||||
|
||||
expect(mockedApiClient.patch).toHaveBeenCalledWith('/content/comments/cmt-1/reject');
|
||||
expect(result.data.data).toEqual(rejectedComment);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteComment', () => {
|
||||
it('should call DELETE /content/comments/:id', async () => {
|
||||
mockedApiClient.delete.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await contentApi.deleteComment('cmt-1');
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/content/comments/cmt-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { rbacApi, Role, Permission } from '@/lib/rbac-api';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
apiClient: {
|
||||
get: vi.fn(),
|
||||
put: vi.fn(),
|
||||
post: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApiClient = vi.mocked(apiClient);
|
||||
|
||||
const mockPermission: Permission = {
|
||||
id: 'perm-1',
|
||||
name: 'users.read',
|
||||
description: 'Read users',
|
||||
resource: 'users',
|
||||
action: 'read',
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
const mockRole: Role = {
|
||||
id: 'role-1',
|
||||
name: 'admin',
|
||||
description: 'Administrator',
|
||||
permissions: [mockPermission],
|
||||
createdAt: '2024-01-01T00:00:00Z',
|
||||
updatedAt: '2024-01-02T00:00:00Z',
|
||||
};
|
||||
|
||||
describe('rbacApi', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getRoles', () => {
|
||||
it('should call GET /rbac/roles', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: [mockRole] } });
|
||||
|
||||
const result = await rbacApi.getRoles();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/rbac/roles');
|
||||
expect(result.data.data).toEqual([mockRole]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRole', () => {
|
||||
it('should call POST /rbac/roles with role data', async () => {
|
||||
const createData = { name: 'editor', description: 'Editor role' };
|
||||
const createdRole = { ...mockRole, id: 'role-2', name: 'editor', description: 'Editor role', permissions: [] };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: createdRole } });
|
||||
|
||||
const result = await rbacApi.createRole(createData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/rbac/roles', createData);
|
||||
expect(result.data.data).toEqual(createdRole);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRole', () => {
|
||||
it('should call GET /rbac/roles/:id', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: mockRole } });
|
||||
|
||||
const result = await rbacApi.getRole('role-1');
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/rbac/roles/role-1');
|
||||
expect(result.data.data).toEqual(mockRole);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRole', () => {
|
||||
it('should call PATCH /rbac/roles/:id with update data', async () => {
|
||||
const updateData = { name: 'super-admin' };
|
||||
const updatedRole = { ...mockRole, name: 'super-admin' };
|
||||
mockedApiClient.patch.mockResolvedValueOnce({ data: { data: updatedRole } });
|
||||
|
||||
const result = await rbacApi.updateRole('role-1', updateData);
|
||||
|
||||
expect(mockedApiClient.patch).toHaveBeenCalledWith('/rbac/roles/role-1', updateData);
|
||||
expect(result.data.data).toEqual(updatedRole);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRole', () => {
|
||||
it('should call DELETE /rbac/roles/:id', async () => {
|
||||
mockedApiClient.delete.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await rbacApi.deleteRole('role-1');
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/rbac/roles/role-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPermissions', () => {
|
||||
it('should call GET /rbac/permissions', async () => {
|
||||
mockedApiClient.get.mockResolvedValueOnce({ data: { data: [mockPermission] } });
|
||||
|
||||
const result = await rbacApi.getPermissions();
|
||||
|
||||
expect(mockedApiClient.get).toHaveBeenCalledWith('/rbac/permissions');
|
||||
expect(result.data.data).toEqual([mockPermission]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPermission', () => {
|
||||
it('should call POST /rbac/permissions with permission data', async () => {
|
||||
const createData = { name: 'users.write', resource: 'users', action: 'write' };
|
||||
const createdPermission = { ...mockPermission, id: 'perm-2', name: 'users.write', action: 'write' };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: createdPermission } });
|
||||
|
||||
const result = await rbacApi.createPermission(createData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/rbac/permissions', createData);
|
||||
expect(result.data.data).toEqual(createdPermission);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePermission', () => {
|
||||
it('should call PATCH /rbac/permissions/:id with update data', async () => {
|
||||
const updateData = { description: 'Read all users' };
|
||||
const updatedPermission = { ...mockPermission, description: 'Read all users' };
|
||||
mockedApiClient.patch.mockResolvedValueOnce({ data: { data: updatedPermission } });
|
||||
|
||||
const result = await rbacApi.updatePermission('perm-1', updateData);
|
||||
|
||||
expect(mockedApiClient.patch).toHaveBeenCalledWith('/rbac/permissions/perm-1', updateData);
|
||||
expect(result.data.data).toEqual(updatedPermission);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletePermission', () => {
|
||||
it('should call DELETE /rbac/permissions/:id', async () => {
|
||||
mockedApiClient.delete.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await rbacApi.deletePermission('perm-1');
|
||||
|
||||
expect(mockedApiClient.delete).toHaveBeenCalledWith('/rbac/permissions/perm-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignRolePermissions', () => {
|
||||
it('should call POST /rbac/roles/:id/permissions with permissionIds', async () => {
|
||||
const assignData = { permissionIds: ['perm-1', 'perm-2'] };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await rbacApi.assignRolePermissions('role-1', assignData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/rbac/roles/role-1/permissions', assignData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignUserRoles', () => {
|
||||
it('should call POST /rbac/users/:id/roles with roleIds', async () => {
|
||||
const assignData = { roleIds: ['role-1', 'role-2'] };
|
||||
mockedApiClient.post.mockResolvedValueOnce({ data: { data: null } });
|
||||
|
||||
await rbacApi.assignUserRoles('user-1', assignData);
|
||||
|
||||
expect(mockedApiClient.post).toHaveBeenCalledWith('/rbac/users/user-1/roles', assignData);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { apiClient } from './api';
|
||||
import { User } from './user-api';
|
||||
|
||||
export interface LoginRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface SmsLoginRequest {
|
||||
phone: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface SendSmsRequest {
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface RefreshTokenRequest {
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface MfaSetupResponse {
|
||||
secret: string;
|
||||
qrCodeUrl: string;
|
||||
}
|
||||
|
||||
export interface MfaVerifyRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface MfaDisableRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
register: (data: RegisterRequest) =>
|
||||
apiClient.post<ApiResponse<AuthResponse>>('/auth/register', data),
|
||||
|
||||
login: (data: LoginRequest) =>
|
||||
apiClient.post<ApiResponse<AuthResponse>>('/auth/login', data),
|
||||
|
||||
smsLogin: (data: SmsLoginRequest) =>
|
||||
apiClient.post<ApiResponse<AuthResponse>>('/auth/login/sms', data),
|
||||
|
||||
sendSmsCode: (data: SendSmsRequest) =>
|
||||
apiClient.post<ApiResponse<void>>('/auth/sms/send', data),
|
||||
|
||||
refreshToken: (data: RefreshTokenRequest) =>
|
||||
apiClient.post<ApiResponse<AuthResponse>>('/auth/refresh', data),
|
||||
|
||||
logout: () =>
|
||||
apiClient.post<ApiResponse<void>>('/auth/logout'),
|
||||
|
||||
me: () =>
|
||||
apiClient.get<ApiResponse<User>>('/auth/me'),
|
||||
|
||||
enableMfa: () =>
|
||||
apiClient.post<ApiResponse<MfaSetupResponse>>('/auth/mfa/enable'),
|
||||
|
||||
verifyMfa: (data: MfaVerifyRequest) =>
|
||||
apiClient.post<ApiResponse<void>>('/auth/mfa/verify', data),
|
||||
|
||||
disableMfa: (data: MfaDisableRequest) =>
|
||||
apiClient.post<ApiResponse<void>>('/auth/mfa/disable', data),
|
||||
};
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
import { apiClient } from './api';
|
||||
|
||||
export interface Article {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
categoryId?: string;
|
||||
coverImage?: string;
|
||||
seoTitle?: string;
|
||||
seoKeywords?: string;
|
||||
seoDescription?: string;
|
||||
tags?: string[];
|
||||
status: string;
|
||||
authorId: string;
|
||||
publishedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
icon?: string;
|
||||
sortOrder?: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
articleCount?: number;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
articleId: string;
|
||||
content: string;
|
||||
parentId?: string;
|
||||
status: string;
|
||||
authorId: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ArticleVersion {
|
||||
id: string;
|
||||
articleId: string;
|
||||
version: number;
|
||||
title: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface CreateArticleRequest {
|
||||
title: string;
|
||||
slug: string;
|
||||
content: string;
|
||||
summary?: string;
|
||||
categoryId?: string;
|
||||
coverImage?: string;
|
||||
seoTitle?: string;
|
||||
seoKeywords?: string;
|
||||
seoDescription?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface UpdateArticleRequest {
|
||||
title?: string;
|
||||
slug?: string;
|
||||
content?: string;
|
||||
summary?: string;
|
||||
categoryId?: string;
|
||||
coverImage?: string;
|
||||
seoTitle?: string;
|
||||
seoKeywords?: string;
|
||||
seoDescription?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface QueryArticleRequest {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
categoryId?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface CreateCategoryRequest {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
icon?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface UpdateCategoryRequest {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
parentId?: string;
|
||||
icon?: string;
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
export interface CreateTagRequest {
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTagRequest {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreateCommentRequest {
|
||||
articleId: string;
|
||||
content: string;
|
||||
parentId?: string;
|
||||
}
|
||||
|
||||
export interface QueryCommentRequest {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface ArticleListResponse {
|
||||
articles: Article[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TagListResponse {
|
||||
tags: Tag[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommentListResponse {
|
||||
comments: Comment[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArticleVersionListResponse {
|
||||
versions: ArticleVersion[];
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const contentApi = {
|
||||
createArticle: (data: CreateArticleRequest) =>
|
||||
apiClient.post<ApiResponse<Article>>('/content/articles', data),
|
||||
|
||||
getArticles: (params?: QueryArticleRequest) =>
|
||||
apiClient.get<ApiResponse<ArticleListResponse>>('/content/articles', {
|
||||
params,
|
||||
}),
|
||||
|
||||
getArticleBySlug: (slug: string) =>
|
||||
apiClient.get<ApiResponse<Article>>(`/content/articles/slug/${slug}`),
|
||||
|
||||
getArticle: (id: string) =>
|
||||
apiClient.get<ApiResponse<Article>>(`/content/articles/${id}`),
|
||||
|
||||
updateArticle: (id: string, data: UpdateArticleRequest) =>
|
||||
apiClient.patch<ApiResponse<Article>>(`/content/articles/${id}`, data),
|
||||
|
||||
deleteArticle: (id: string) =>
|
||||
apiClient.delete(`/content/articles/${id}`),
|
||||
|
||||
publishArticle: (id: string) =>
|
||||
apiClient.post<ApiResponse<Article>>(`/content/articles/${id}/publish`),
|
||||
|
||||
submitForReview: (id: string) =>
|
||||
apiClient.post<ApiResponse<Article>>(`/content/articles/${id}/submit-review`),
|
||||
|
||||
reviewArticle: (id: string, data: { action: 'approve' | 'reject'; comment?: string }) =>
|
||||
apiClient.post<ApiResponse<Article>>(`/content/articles/${id}/review`, data),
|
||||
|
||||
getArticleVersions: (articleId: string) =>
|
||||
apiClient.get<ApiResponse<ArticleVersionListResponse>>(`/content/articles/${articleId}/versions`),
|
||||
|
||||
getArticleVersion: (articleId: string, version: number) =>
|
||||
apiClient.get<ApiResponse<ArticleVersion>>(`/content/articles/${articleId}/versions/${version}`),
|
||||
|
||||
createCategory: (data: CreateCategoryRequest) =>
|
||||
apiClient.post<ApiResponse<Category>>('/content/categories', data),
|
||||
|
||||
getCategories: () =>
|
||||
apiClient.get<ApiResponse<Category[]>>('/content/categories'),
|
||||
|
||||
getCategoryTree: () =>
|
||||
apiClient.get<ApiResponse<Category[]>>('/content/categories/tree'),
|
||||
|
||||
getCategory: (id: string) =>
|
||||
apiClient.get<ApiResponse<Category>>(`/content/categories/${id}`),
|
||||
|
||||
updateCategory: (id: string, data: UpdateCategoryRequest) =>
|
||||
apiClient.patch<ApiResponse<Category>>(`/content/categories/${id}`, data),
|
||||
|
||||
deleteCategory: (id: string) =>
|
||||
apiClient.delete(`/content/categories/${id}`),
|
||||
|
||||
createTag: (data: CreateTagRequest) =>
|
||||
apiClient.post<ApiResponse<Tag>>('/content/tags', data),
|
||||
|
||||
getTags: (params?: QueryArticleRequest) =>
|
||||
apiClient.get<ApiResponse<TagListResponse>>('/content/tags', {
|
||||
params,
|
||||
}),
|
||||
|
||||
getPopularTags: () =>
|
||||
apiClient.get<ApiResponse<Tag[]>>('/content/tags/popular'),
|
||||
|
||||
getTag: (id: string) =>
|
||||
apiClient.get<ApiResponse<Tag>>(`/content/tags/${id}`),
|
||||
|
||||
updateTag: (id: string, data: UpdateTagRequest) =>
|
||||
apiClient.patch<ApiResponse<Tag>>(`/content/tags/${id}`, data),
|
||||
|
||||
deleteTag: (id: string) =>
|
||||
apiClient.delete(`/content/tags/${id}`),
|
||||
|
||||
createComment: (data: CreateCommentRequest) =>
|
||||
apiClient.post<ApiResponse<Comment>>('/content/comments', data),
|
||||
|
||||
getArticleComments: (articleId: string, params?: QueryCommentRequest) =>
|
||||
apiClient.get<ApiResponse<CommentListResponse>>(`/content/comments/article/${articleId}`, {
|
||||
params,
|
||||
}),
|
||||
|
||||
getComments: (params?: QueryCommentRequest) =>
|
||||
apiClient.get<ApiResponse<CommentListResponse>>('/content/comments', {
|
||||
params,
|
||||
}),
|
||||
|
||||
approveComment: (id: string) =>
|
||||
apiClient.patch<ApiResponse<Comment>>(`/content/comments/${id}/approve`),
|
||||
|
||||
rejectComment: (id: string) =>
|
||||
apiClient.patch<ApiResponse<Comment>>(`/content/comments/${id}/reject`),
|
||||
|
||||
deleteComment: (id: string) =>
|
||||
apiClient.delete(`/content/comments/${id}`),
|
||||
};
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { apiClient } from './api';
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
permissions: Permission[];
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRoleRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreatePermissionRequest {
|
||||
name: string;
|
||||
description?: string;
|
||||
resource: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
export interface UpdatePermissionRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
resource?: string;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export interface AssignRolePermissionsRequest {
|
||||
permissionIds: string[];
|
||||
}
|
||||
|
||||
export interface AssignUserRolesRequest {
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export const rbacApi = {
|
||||
getRoles: () =>
|
||||
apiClient.get<ApiResponse<Role[]>>('/rbac/roles'),
|
||||
|
||||
createRole: (data: CreateRoleRequest) =>
|
||||
apiClient.post<ApiResponse<Role>>('/rbac/roles', data),
|
||||
|
||||
getRole: (id: string) =>
|
||||
apiClient.get<ApiResponse<Role>>(`/rbac/roles/${id}`),
|
||||
|
||||
updateRole: (id: string, data: UpdateRoleRequest) =>
|
||||
apiClient.patch<ApiResponse<Role>>(`/rbac/roles/${id}`, data),
|
||||
|
||||
deleteRole: (id: string) =>
|
||||
apiClient.delete(`/rbac/roles/${id}`),
|
||||
|
||||
getPermissions: () =>
|
||||
apiClient.get<ApiResponse<Permission[]>>('/rbac/permissions'),
|
||||
|
||||
createPermission: (data: CreatePermissionRequest) =>
|
||||
apiClient.post<ApiResponse<Permission>>('/rbac/permissions', data),
|
||||
|
||||
updatePermission: (id: string, data: UpdatePermissionRequest) =>
|
||||
apiClient.patch<ApiResponse<Permission>>(`/rbac/permissions/${id}`, data),
|
||||
|
||||
deletePermission: (id: string) =>
|
||||
apiClient.delete(`/rbac/permissions/${id}`),
|
||||
|
||||
assignRolePermissions: (roleId: string, data: AssignRolePermissionsRequest) =>
|
||||
apiClient.post<ApiResponse<void>>(`/rbac/roles/${roleId}/permissions`, data),
|
||||
|
||||
assignUserRoles: (userId: string, data: AssignUserRolesRequest) =>
|
||||
apiClient.post<ApiResponse<void>>(`/rbac/users/${userId}/roles`, data),
|
||||
};
|
||||
|
|
@ -16,6 +16,17 @@ export interface User {
|
|||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
avatar?: string;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
|
|
@ -55,6 +66,9 @@ export const userApi = {
|
|||
});
|
||||
},
|
||||
|
||||
createUser: (data: CreateUserRequest) =>
|
||||
apiClient.post<ApiResponse<User>>('/users', data),
|
||||
|
||||
getUsers: (page: number = 1, limit: number = 20, search?: string) =>
|
||||
apiClient.get<ApiResponse<UserListResponse>>('/users', {
|
||||
params: { page, limit, ...(search && { search }) },
|
||||
|
|
|
|||
|
|
@ -0,0 +1,80 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useCurrentUser, useRefreshToken } from '@/hooks/use-auth'
|
||||
import { useUserStore } from '@/stores/userStore'
|
||||
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const setUser = useUserStore((state) => state.setUser)
|
||||
const logout = useUserStore((state) => state.logout)
|
||||
const refreshTokenMutation = useRefreshToken()
|
||||
|
||||
const tryRefreshToken = useCallback(async () => {
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (!refreshToken) {
|
||||
localStorage.removeItem('token')
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshTokenMutation.mutateAsync({ refreshToken })
|
||||
return true
|
||||
} catch {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
}, [refreshTokenMutation, logout])
|
||||
|
||||
const { refetch } = useCurrentUser()
|
||||
|
||||
useEffect(() => {
|
||||
const initAuth = async () => {
|
||||
const token = localStorage.getItem('token')
|
||||
|
||||
if (!token) {
|
||||
setIsInitialized(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await refetch()
|
||||
if (result.data) {
|
||||
setUser(result.data.data.data)
|
||||
} else if (result.error) {
|
||||
const is401 =
|
||||
(result.error as { response?: { status?: number } })?.response?.status === 401
|
||||
if (is401) {
|
||||
const refreshed = await tryRefreshToken()
|
||||
if (!refreshed) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
logout()
|
||||
} finally {
|
||||
setIsInitialized(true)
|
||||
}
|
||||
}
|
||||
|
||||
initAuth()
|
||||
}, [refetch, setUser, logout, tryRefreshToken])
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
1136
pnpm-lock.yaml
1136
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -2,3 +2,6 @@ packages:
|
|||
- "apps/*"
|
||||
- "packages/*"
|
||||
- "services/*"
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
|
|
|||
Loading…
Reference in New Issue