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:
fischer 2026-05-26 07:26:57 +08:00
parent bdb509a611
commit 72063651c3
74 changed files with 11581 additions and 7753 deletions

View File

@ -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;

View File

@ -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

View File

@ -1,6 +0,0 @@
allowBuilds:
sharp: set this to true or false
unrs-resolver: set this to true or false
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
);
}

View File

@ -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()
})
})

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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()
})
})

View File

@ -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>
);
}

View File

@ -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

View File

@ -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()
})
})

View File

@ -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>
)
}

View File

@ -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()
})
})

View File

@ -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>
);
}

View File

@ -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>
)
}

View File

@ -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();

View File

@ -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>

View File

@ -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()
})
})

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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')
})
})

View File

@ -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}</>
}

View File

@ -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' },

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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: {

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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,
}

View File

@ -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 }

View File

@ -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 }

View File

@ -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 }

View File

@ -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'] });
},
});
};

View File

@ -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'] });
},
});
};

View File

@ -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'] });
},
});
};

View File

@ -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'] });
},
});
};

View File

@ -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,
});
};

View File

@ -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'] });
},
});
};

View File

@ -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();

View File

@ -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);
});
});
});

View File

@ -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');
});
});
});
});

View File

@ -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);
});
});
});

View File

@ -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),
};

View File

@ -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}`),
};

View File

@ -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),
};

View File

@ -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 }) },

View File

@ -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}</>
}

File diff suppressed because it is too large Load Diff

View File

@ -2,3 +2,6 @@ packages:
- "apps/*"
- "packages/*"
- "services/*"
onlyBuiltDependencies:
- sharp
- unrs-resolver