Compare commits

...

3 Commits

Author SHA1 Message Date
fischer 363deb433a fix: 安全审计修复 - 限流Guard注册、CORS优化、XSS集成、Swagger文档、缓存delPattern
- 全局注册 ThrottleGuard 限流守卫(APP_GUARD)
- CORS 配置从环境变量读取,生产环境限制域名
- XSS 中间件通过 NestModule.configure 全局注册
- 集成 Swagger/OpenAPI 文档(仅非生产环境启用)
- 完善 CacheService.delPattern 支持 Redis 和内存模式
- 新增通用装饰器、拦截器、中间件
2026-05-26 07:27:11 +08:00
fischer 72063651c3 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 配置
2026-05-26 07:26:57 +08:00
fischer bdb509a611 fix: Phase 2 可运行性保障 - 修复JWT用户ID字段不一致、添加缺失的Auth Guard、修复测试
关键修复:
- req.user?.id → req.user?.userId: Order/Notification/Payment 3个Controller共18处
- NotificationController/PaymentController 添加 @UseGuards(JwtAuthGuard)
- alipay/wechat auth 测试: oauthAccount → oAuthAccount, 补全mock数据
- order.controller.spec.ts: mockRequest.user.id → userId
- OpenTelemetry 改为可选加载 (OTEL_ENABLED)
- 前端: telemetry.ts改为no-op, web-vitals v4 API, Tailwind v4兼容
- Docker: postgres使用本地镜像, DATABASE_URL匹配docker-compose
- 布局: (auth)/(dashboard)分离, Sidebar仅Dashboard显示

验证结果:
- 后端331测试全部通过
- 前端构建成功(20页面)
- API端到端冒烟验证12个端点全部正常
- Docker PG+Redis启动, Prisma Migration 30张表创建成功
2026-05-25 17:44:37 +08:00
128 changed files with 17259 additions and 8136 deletions

View File

@ -1,25 +1,88 @@
NODE_ENV=development
DB_HOST=postgres
# API Server
API_PORT=3001
WEB_PORT=3000
# Database (Prisma)
DATABASE_URL="postgresql://fischerx:fischerx@localhost:5432/fischerx?schema=public"
DB_HOST=localhost
DB_PORT=5432
DB_USER=fischerx
DB_PASSWORD=fischerx
DB_NAME=fischerx
REDIS_HOST=redis
# Redis
REDIS_URL="redis://localhost:6379"
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
API_PORT=3001
WEB_PORT=3000
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
JWT_EXPIRATION="7d"
# Frontend
NEXT_PUBLIC_API_URL=http://localhost:3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
# Storage Configuration
STORAGE_TYPE="local"
ALIBABA_CLOUD_ACCESS_KEY_ID=
ALIBABA_CLOUD_ACCESS_KEY_SECRET=
OSS_REGION=
OSS_BUCKET=
OSS_ENDPOINT=
# Local Storage
LOCAL_STORAGE_PATH="./uploads"
LOCAL_STORAGE_URL="http://localhost:3001/uploads"
# Aliyun OSS
ALIYUN_OSS_REGION="oss-cn-hangzhou"
ALIYUN_OSS_ACCESS_KEY_ID="your-aliyun-access-key-id"
ALIYUN_OSS_ACCESS_KEY_SECRET="your-aliyun-access-key-secret"
ALIYUN_OSS_BUCKET="fischerx"
ALIYUN_OSS_ENDPOINT="https://oss-cn-hangzhou.aliyuncs.com"
ALIYUN_OSS_INTERNAL=false
ALIYUN_OSS_SECURE=true
# Tencent COS
TENCENT_COS_SECRET_ID="your-tencent-secret-id"
TENCENT_COS_SECRET_KEY="your-tencent-secret-key"
TENCENT_COS_BUCKET="fischerx-1234567890"
TENCENT_COS_REGION="ap-guangzhou"
# MinIO
MINIO_END_POINT="localhost"
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY="minioadmin"
MINIO_SECRET_KEY="minioadmin"
MINIO_BUCKET="fischerx"
# CDN
CDN_ENABLED=false
CDN_BASE_URL="https://cdn.yourdomain.com"
# Upload Settings
UPLOAD_MAX_FILE_SIZE=104857600
UPLOAD_ALLOWED_MIME_TYPES="*/*"
# Notification - Mock Mode (set to "true" in development to skip actual sending)
NOTIFICATION_MOCK_MODE="true"
# Notification - SMTP Email
SMTP_HOST="smtpdm.aliyun.com"
SMTP_PORT="465"
SMTP_USER="noreply@yourdomain.com"
SMTP_PASS="your-smtp-password"
SMTP_FROM='"FischerX" <noreply@yourdomain.com>'
# Notification - Aliyun SMS
ALIYUN_ACCESS_KEY_ID="your-aliyun-access-key-id"
ALIYUN_ACCESS_KEY_SECRET="your-aliyun-access-key-secret"
ALIYUN_SMS_SIGN_NAME="FischerX"
ALIYUN_SMS_TEMPLATE_CODE_VERIFY="SMS_123456"
# Notification - WeCom Robot Webhook
WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-default-key"
WECOM_WEBHOOK_URL_ALERT="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-alert-key"
# OpenTelemetry (optional, set to "true" to enable)
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces

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

@ -13,26 +13,46 @@
"test:e2e:ui": "playwright test --ui"
},
"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="flex min-h-screen items-center justify-center">
{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,7 +1,23 @@
import { Navbar } from "@/components/layout/navbar"
import { Sidebar } from "@/components/layout/sidebar"
import { RouteGuard } from "@/components/auth/route-guard"
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
return (
<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>
</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

@ -1,6 +1,30 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@import "tailwindcss";
@theme inline {
--color-background: hsl(var(--background));
--color-foreground: hsl(var(--foreground));
--color-card: hsl(var(--card));
--color-card-foreground: hsl(var(--card-foreground));
--color-popover: hsl(var(--popover));
--color-popover-foreground: hsl(var(--popover-foreground));
--color-primary: hsl(var(--primary));
--color-primary-foreground: hsl(var(--primary-foreground));
--color-secondary: hsl(var(--secondary));
--color-secondary-foreground: hsl(var(--secondary-foreground));
--color-muted: hsl(var(--muted));
--color-muted-foreground: hsl(var(--muted-foreground));
--color-accent: hsl(var(--accent));
--color-accent-foreground: hsl(var(--accent-foreground));
--color-destructive: hsl(var(--destructive));
--color-destructive-foreground: hsl(var(--destructive-foreground));
--color-border: hsl(var(--border));
--color-input: hsl(var(--input));
--color-ring: hsl(var(--ring));
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
:root {

View File

@ -2,7 +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 { Navbar, Sidebar } from "@/components/layout"
import { AuthProvider } from "@/providers/auth-provider"
import { ErrorBoundary } from "@/components/error-boundary"
import { MonitoringProvider } from "@/providers/monitoring-provider"
@ -27,21 +27,15 @@ export default function RootLayout({
children: React.ReactNode
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning>
<html lang="zh-CN" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning>
<body className="min-h-full">
<MonitoringProvider>
<QueryProvider>
<ErrorBoundary>
<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>
</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

@ -4,10 +4,10 @@ import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useDownloadFile, useDeleteFile } from '@/hooks/use-files';
import type { File } from '@fischerx/types';
import type { File as FileType } from '@fischerx/types';
interface FilePreviewProps {
file: File;
file: FileType;
onDelete?: () => void;
}

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,6 +1,8 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fileApi } from '@/lib/file-api';
import type { File, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
import type { File as FileType, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
export const useFiles = (params?: FileListParams) => {
return useQuery({
@ -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: 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: (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<File, '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

@ -1,8 +1,8 @@
import api from './api';
import type { File, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
import type { File as FileType, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
export const fileApi = {
uploadFile: async (file: File | Blob, options?: FileUploadOptions): Promise<{ success: boolean; data: File }> => {
uploadFile: async (file: globalThis.File | Blob, options?: FileUploadOptions): Promise<{ success: boolean; data: FileType }> => {
const formData = new FormData();
formData.append('file', file);
if (options?.category) formData.append('category', options.category);
@ -18,9 +18,9 @@ export const fileApi = {
},
uploadMultipleFiles: async (
files: (File | Blob)[],
files: (globalThis.File | Blob)[],
options?: FileUploadOptions
): Promise<{ success: boolean; data: File[]; errors: Array<{ index: number; error: string }> }> => {
): Promise<{ success: boolean; data: FileType[]; errors: Array<{ index: number; error: string }> }> => {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
if (options?.category) formData.append('category', options.category);
@ -35,12 +35,12 @@ export const fileApi = {
return response.data;
},
listFiles: async (params?: FileListParams): Promise<{ success: boolean; data: File[]; meta: { page: number; limit: number; total: number } }> => {
listFiles: async (params?: FileListParams): Promise<{ success: boolean; data: FileType[]; meta: { page: number; limit: number; total: number } }> => {
const response = await api.get('/files', { params });
return response.data;
},
getFile: async (id: string): Promise<{ success: boolean; data: File }> => {
getFile: async (id: string): Promise<{ success: boolean; data: FileType }> => {
const response = await api.get(`/files/${id}`);
return response.data;
},
@ -65,7 +65,7 @@ export const fileApi = {
return response.data;
},
updateFile: async (id: string, data: Partial<Pick<File, 'name' | 'category' | 'tags'>>): Promise<{ success: boolean; data: File }> => {
updateFile: async (id: string, data: Partial<Pick<FileType, 'name' | 'category' | 'tags'>>): Promise<{ success: boolean; data: FileType }> => {
const response = await api.put(`/files/${id}`, data);
return response.data;
},

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

@ -1,38 +1,7 @@
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { SimpleSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
export const initTelemetry = async () => {
if (process.env.NODE_ENV !== 'production') return;
const endpoint = process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT;
if (!endpoint) return;
const isProduction = process.env.NODE_ENV === 'production';
const resource = new Resource({
'service.name': 'fischerx-web',
});
const provider = new WebTracerProvider({ resource });
const traceExporter = isProduction
? new OTLPTraceExporter({
url: process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
})
: new ConsoleSpanExporter();
provider.addSpanProcessor(new SimpleSpanProcessor(traceExporter));
provider.register({
contextManager: new ZoneContextManager(),
});
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
ignoreUrls: ['/health', '/metrics'],
}),
new XMLHttpRequestInstrumentation(),
],
});
export const tracer = provider.getTracer('fischerx-web');
console.warn('OpenTelemetry packages not installed. Install @opentelemetry/* packages to enable tracing.');
};

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

@ -1,20 +1,17 @@
import { ReportCallback } from 'web-vitals';
export const reportWebVitals = (onPerfEntry?: ReportCallback) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
export const reportWebVitals = (onPerfEntry?: (metric: any) => void) => {
if (!onPerfEntry) return;
import('web-vitals').then(({ onCLS, onINP, onFCP, onLCP, onTTFB }) => {
onCLS(onPerfEntry);
onINP(onPerfEntry);
onFCP(onPerfEntry);
onLCP(onPerfEntry);
onTTFB(onPerfEntry);
}).catch(() => {});
};
export const logVitals = (metric: any) => {
console.log('Web Vital:', metric);
if (process.env.NODE_ENV === 'production') {
fetch('/api/vitals', {
method: 'POST',

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

View File

@ -1,7 +1,6 @@
'use client';
import React, { useEffect, ReactNode } from 'react';
import { logVitals, reportWebVitals } from '@/lib/web-vitals';
interface Props {
children: ReactNode;
@ -9,12 +8,16 @@ interface Props {
export function MonitoringProvider({ children }: Props) {
useEffect(() => {
reportWebVitals(logVitals);
if (typeof window !== 'undefined') {
import('@/lib/web-vitals').then(({ reportWebVitals, logVitals }) => {
reportWebVitals(logVitals);
}).catch(() => {});
}
}, []);
useEffect(() => {
if (process.env.NODE_ENV === 'production') {
import('@/lib/telemetry').catch(console.error);
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'production') {
import('@/lib/telemetry').then(m => m.initTelemetry()).catch(() => {});
}
}, []);

View File

@ -1,12 +1,6 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
name: string
email: string
avatar?: string
}
import { User } from '@/lib/user-api'
interface UserState {
user: User | null

View File

@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["react", "react-dom", "node"]
},
"include": [
"next-env.d.ts",

View File

@ -1,10 +1,6 @@
version: '3.8'
services:
postgres:
build:
context: ./infra/db
dockerfile: Dockerfile
image: postgres:15-alpine
container_name: fischerx-postgres
environment:
POSTGRES_USER: ${DB_USER:-fischerx}
@ -14,6 +10,7 @@ services:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
- ./infra/db/init.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- fischerx-network
healthcheck:

View File

@ -30,10 +30,55 @@ export interface File {
mimeType: string;
size: number;
url: string;
cdnUrl?: string;
storageType: 'aliyun' | 'tencent' | 'minio' | 'local';
bucket: string;
path: string;
fileHash?: string;
category?: string;
tags?: string[];
accessCount: number;
lastAccessedAt?: Date;
ownerId: string;
createdAt: Date;
updatedAt: Date;
}
export interface FileUploadOptions {
category?: string;
tags?: string[];
description?: string;
}
export interface FileListParams {
page?: number;
limit?: number;
category?: string;
search?: string;
sortBy?: 'createdAt' | 'size' | 'name';
sortOrder?: 'asc' | 'desc';
}
export interface ProcessImageOptions {
compress?: {
quality: number;
};
resize?: {
width?: number;
height?: number;
fit?: 'cover' | 'contain' | 'fill' | 'inside' | 'outside';
};
crop?: {
x: number;
y: number;
width: number;
height: number;
};
watermark?: {
text: string;
fontSize?: number;
color?: string;
position?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center';
opacity?: number;
};
}
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
@ -63,4 +108,3 @@ export interface AuthResponse {
refreshToken: string;
user: User;
}
//# sourceMappingURL=index.d.ts.map

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,5 +1,5 @@
# Database
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/fischerx?schema=public"
DATABASE_URL="postgresql://fischerx:fischerx@localhost:5432/fischerx?schema=public"
# JWT
JWT_SECRET="your-super-secret-jwt-key-change-this-in-production"
@ -73,3 +73,6 @@ ALIYUN_SMS_TEMPLATE_CODE_VERIFY="SMS_123456"
# Notification - WeCom Robot Webhook
WECOM_WEBHOOK_URL="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-default-key"
WECOM_WEBHOOK_URL_ALERT="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=your-alert-key"
OTEL_ENABLED=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318/v1/traces

View File

@ -49,6 +49,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cos-nodejs-sdk-v5": "^2.14.5",
"helmet": "^8.2.0",
"jsonwebtoken": "^9.0.3",
"minio": "^8.0.1",
"multer": "^1.4.5-lts.1",

View File

@ -0,0 +1,848 @@
-- CreateEnum
CREATE TYPE "OrderStatus" AS ENUM ('PENDING', 'PAID', 'SHIPPED', 'COMPLETED', 'CANCELLED', 'REFUNDED');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"username" TEXT NOT NULL,
"password" TEXT,
"firstName" TEXT,
"lastName" TEXT,
"avatar" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"phoneVerified" BOOLEAN NOT NULL DEFAULT false,
"lastLoginAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"mfaSecret" TEXT,
"mfaEnabled" BOOLEAN NOT NULL DEFAULT false,
"loginAttempts" INTEGER NOT NULL DEFAULT 0,
"lockedUntil" TIMESTAMP(3),
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "roles" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"isSystem" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "permissions" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"resource" TEXT NOT NULL,
"action" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "user_roles" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "user_roles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "role_permissions" (
"id" TEXT NOT NULL,
"roleId" TEXT NOT NULL,
"permissionId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "role_permissions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "files" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"originalName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL,
"path" TEXT NOT NULL,
"url" TEXT,
"cdnUrl" TEXT,
"storageType" TEXT NOT NULL DEFAULT 'local',
"bucket" TEXT,
"fileHash" TEXT,
"category" TEXT,
"tags" TEXT[],
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"accessCount" INTEGER NOT NULL DEFAULT 0,
"lastAccessedAt" TIMESTAMP(3),
"ownerId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "files_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sessions" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT,
"expiresAt" TIMESTAMP(3) NOT NULL,
"refreshExpiresAt" TIMESTAMP(3),
"ipAddress" TEXT,
"userAgent" TEXT,
"deviceName" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "oauth_accounts" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"providerData" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "oauth_accounts_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "realname_auth" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"realName" TEXT NOT NULL,
"idCardNumber" TEXT NOT NULL,
"idCardFrontUrl" TEXT,
"idCardBackUrl" TEXT,
"faceImageUrl" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"rejectReason" TEXT,
"verifiedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "realname_auth_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "login_history" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"deviceName" TEXT,
"loginMethod" TEXT NOT NULL,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "login_history_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_channels" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"isEnabled" BOOLEAN NOT NULL DEFAULT true,
"isSandbox" BOOLEAN NOT NULL DEFAULT true,
"config" JSONB NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_channels_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_orders" (
"id" TEXT NOT NULL,
"orderNo" TEXT NOT NULL,
"channelOrderNo" TEXT,
"userId" TEXT NOT NULL,
"channelId" TEXT,
"amount" DECIMAL(12,2) NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'CNY',
"subject" TEXT NOT NULL,
"body" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"paidAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"clientIp" TEXT,
"userAgent" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_refunds" (
"id" TEXT NOT NULL,
"refundNo" TEXT NOT NULL,
"channelRefundNo" TEXT,
"orderId" TEXT NOT NULL,
"channelId" TEXT,
"userId" TEXT NOT NULL,
"amount" DECIMAL(12,2) NOT NULL,
"reason" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"processedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "payment_refunds_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "payment_logs" (
"id" TEXT NOT NULL,
"orderId" TEXT,
"refundId" TEXT,
"type" TEXT NOT NULL,
"action" TEXT NOT NULL,
"request" JSONB,
"response" JSONB,
"status" TEXT NOT NULL,
"error" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "payment_logs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "orders" (
"id" TEXT NOT NULL,
"orderNo" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"status" "OrderStatus" NOT NULL DEFAULT 'PENDING',
"totalAmount" DECIMAL(12,2) NOT NULL,
"paidAmount" DECIMAL(12,2) NOT NULL,
"discountAmount" DECIMAL(12,2) NOT NULL DEFAULT 0,
"paymentMethod" TEXT,
"paymentOrderId" TEXT,
"remark" TEXT,
"paidAt" TIMESTAMP(3),
"shippedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
"cancelledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "orders_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "order_items" (
"id" TEXT NOT NULL,
"orderId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"productName" TEXT NOT NULL,
"productImage" TEXT,
"quantity" INTEGER NOT NULL,
"unitPrice" DECIMAL(12,2) NOT NULL,
"totalAmount" DECIMAL(12,2) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "order_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notifications" (
"id" TEXT NOT NULL,
"userId" TEXT,
"type" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"channel" TEXT NOT NULL,
"channelData" JSONB,
"status" TEXT NOT NULL DEFAULT 'pending',
"sentAt" TIMESTAMP(3),
"readAt" TIMESTAMP(3),
"priority" TEXT NOT NULL DEFAULT 'normal',
"metadata" JSONB,
"expiresAt" TIMESTAMP(3),
"templateId" TEXT,
"templateVersion" INTEGER,
"variables" JSONB,
"retryCount" INTEGER NOT NULL DEFAULT 0,
"maxRetries" INTEGER NOT NULL DEFAULT 3,
"errorMessage" TEXT,
"deduplicationId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_templates" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"channels" TEXT[],
"version" INTEGER NOT NULL DEFAULT 1,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_templates_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_template_locales" (
"id" TEXT NOT NULL,
"templateId" TEXT NOT NULL,
"locale" TEXT NOT NULL,
"subject" TEXT,
"title" TEXT,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"variables" TEXT[],
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_template_locales_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_channels" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"config" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"priority" INTEGER NOT NULL DEFAULT 0,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_channels_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_preferences" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"emailEnabled" BOOLEAN NOT NULL DEFAULT true,
"smsEnabled" BOOLEAN NOT NULL DEFAULT true,
"pushEnabled" BOOLEAN NOT NULL DEFAULT true,
"inAppEnabled" BOOLEAN NOT NULL DEFAULT true,
"securityEnabled" BOOLEAN NOT NULL DEFAULT true,
"marketingEnabled" BOOLEAN NOT NULL DEFAULT false,
"systemEnabled" BOOLEAN NOT NULL DEFAULT true,
"quietStartHour" INTEGER,
"quietEndHour" INTEGER,
"timezone" TEXT DEFAULT 'Asia/Shanghai',
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_preferences_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_batches" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"type" TEXT NOT NULL,
"scheduledAt" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'pending',
"totalCount" INTEGER NOT NULL DEFAULT 0,
"sentCount" INTEGER NOT NULL DEFAULT 0,
"failedCount" INTEGER NOT NULL DEFAULT 0,
"creatorId" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "notification_batches_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "notification_batch_items" (
"id" TEXT NOT NULL,
"batchId" TEXT NOT NULL,
"notificationId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'pending',
"sentAt" TIMESTAMP(3),
"errorMessage" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "notification_batch_items_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "articles" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"summary" TEXT,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"coverImage" TEXT,
"authorId" TEXT NOT NULL,
"categoryId" TEXT,
"status" TEXT NOT NULL DEFAULT 'draft',
"viewCount" INTEGER NOT NULL DEFAULT 0,
"likeCount" INTEGER NOT NULL DEFAULT 0,
"commentCount" INTEGER NOT NULL DEFAULT 0,
"isTop" BOOLEAN NOT NULL DEFAULT false,
"isRecommend" BOOLEAN NOT NULL DEFAULT false,
"publishedAt" TIMESTAMP(3),
"reviewedAt" TIMESTAMP(3),
"reviewedBy" TEXT,
"reviewComment" TEXT,
"seoTitle" TEXT,
"seoKeywords" TEXT,
"seoDescription" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "articles_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "article_versions" (
"id" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"contentHtml" TEXT,
"summary" TEXT,
"changeLog" TEXT,
"authorId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "article_versions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"icon" TEXT,
"parentId" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"articleCount" INTEGER NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"seoTitle" TEXT,
"seoKeywords" TEXT,
"seoDescription" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tags" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"articleCount" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "article_tags" (
"id" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"tagId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "article_tags_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "comments" (
"id" TEXT NOT NULL,
"content" TEXT NOT NULL,
"articleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"parentId" TEXT,
"status" TEXT NOT NULL DEFAULT 'pending',
"likeCount" INTEGER NOT NULL DEFAULT 0,
"ipAddress" TEXT,
"userAgent" TEXT,
"reviewedAt" TIMESTAMP(3),
"reviewedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
-- CreateIndex
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
-- CreateIndex
CREATE UNIQUE INDEX "roles_name_key" ON "roles"("name");
-- CreateIndex
CREATE UNIQUE INDEX "permissions_name_key" ON "permissions"("name");
-- CreateIndex
CREATE UNIQUE INDEX "user_roles_userId_roleId_key" ON "user_roles"("userId", "roleId");
-- CreateIndex
CREATE UNIQUE INDEX "role_permissions_roleId_permissionId_key" ON "role_permissions"("roleId", "permissionId");
-- CreateIndex
CREATE INDEX "files_ownerId_idx" ON "files"("ownerId");
-- CreateIndex
CREATE INDEX "files_storageType_idx" ON "files"("storageType");
-- CreateIndex
CREATE INDEX "files_category_idx" ON "files"("category");
-- CreateIndex
CREATE INDEX "files_fileHash_idx" ON "files"("fileHash");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_token_key" ON "sessions"("token");
-- CreateIndex
CREATE UNIQUE INDEX "sessions_refreshToken_key" ON "sessions"("refreshToken");
-- CreateIndex
CREATE UNIQUE INDEX "oauth_accounts_provider_providerId_key" ON "oauth_accounts"("provider", "providerId");
-- CreateIndex
CREATE UNIQUE INDEX "realname_auth_userId_key" ON "realname_auth"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "payment_channels_code_key" ON "payment_channels"("code");
-- CreateIndex
CREATE INDEX "payment_channels_type_idx" ON "payment_channels"("type");
-- CreateIndex
CREATE INDEX "payment_channels_isEnabled_idx" ON "payment_channels"("isEnabled");
-- CreateIndex
CREATE UNIQUE INDEX "payment_orders_orderNo_key" ON "payment_orders"("orderNo");
-- CreateIndex
CREATE INDEX "payment_orders_userId_idx" ON "payment_orders"("userId");
-- CreateIndex
CREATE INDEX "payment_orders_orderNo_idx" ON "payment_orders"("orderNo");
-- CreateIndex
CREATE INDEX "payment_orders_status_idx" ON "payment_orders"("status");
-- CreateIndex
CREATE INDEX "payment_orders_createdAt_idx" ON "payment_orders"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "payment_refunds_refundNo_key" ON "payment_refunds"("refundNo");
-- CreateIndex
CREATE INDEX "payment_refunds_orderId_idx" ON "payment_refunds"("orderId");
-- CreateIndex
CREATE INDEX "payment_refunds_userId_idx" ON "payment_refunds"("userId");
-- CreateIndex
CREATE INDEX "payment_refunds_status_idx" ON "payment_refunds"("status");
-- CreateIndex
CREATE INDEX "payment_logs_orderId_idx" ON "payment_logs"("orderId");
-- CreateIndex
CREATE INDEX "payment_logs_refundId_idx" ON "payment_logs"("refundId");
-- CreateIndex
CREATE INDEX "payment_logs_createdAt_idx" ON "payment_logs"("createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "orders_orderNo_key" ON "orders"("orderNo");
-- CreateIndex
CREATE UNIQUE INDEX "orders_paymentOrderId_key" ON "orders"("paymentOrderId");
-- CreateIndex
CREATE INDEX "orders_userId_idx" ON "orders"("userId");
-- CreateIndex
CREATE INDEX "orders_orderNo_idx" ON "orders"("orderNo");
-- CreateIndex
CREATE INDEX "orders_status_idx" ON "orders"("status");
-- CreateIndex
CREATE INDEX "orders_createdAt_idx" ON "orders"("createdAt");
-- CreateIndex
CREATE INDEX "order_items_orderId_idx" ON "order_items"("orderId");
-- CreateIndex
CREATE INDEX "order_items_productId_idx" ON "order_items"("productId");
-- CreateIndex
CREATE INDEX "notifications_userId_idx" ON "notifications"("userId");
-- CreateIndex
CREATE INDEX "notifications_status_idx" ON "notifications"("status");
-- CreateIndex
CREATE INDEX "notifications_channel_idx" ON "notifications"("channel");
-- CreateIndex
CREATE INDEX "notifications_type_idx" ON "notifications"("type");
-- CreateIndex
CREATE INDEX "notifications_priority_idx" ON "notifications"("priority");
-- CreateIndex
CREATE INDEX "notifications_deduplicationId_idx" ON "notifications"("deduplicationId");
-- CreateIndex
CREATE UNIQUE INDEX "notification_templates_code_key" ON "notification_templates"("code");
-- CreateIndex
CREATE INDEX "notification_templates_code_idx" ON "notification_templates"("code");
-- CreateIndex
CREATE INDEX "notification_templates_isActive_idx" ON "notification_templates"("isActive");
-- CreateIndex
CREATE INDEX "notification_template_locales_locale_idx" ON "notification_template_locales"("locale");
-- CreateIndex
CREATE UNIQUE INDEX "notification_template_locales_templateId_locale_key" ON "notification_template_locales"("templateId", "locale");
-- CreateIndex
CREATE INDEX "notification_channels_type_idx" ON "notification_channels"("type");
-- CreateIndex
CREATE INDEX "notification_channels_isActive_idx" ON "notification_channels"("isActive");
-- CreateIndex
CREATE UNIQUE INDEX "notification_preferences_userId_key" ON "notification_preferences"("userId");
-- CreateIndex
CREATE INDEX "notification_batches_status_idx" ON "notification_batches"("status");
-- CreateIndex
CREATE INDEX "notification_batches_creatorId_idx" ON "notification_batches"("creatorId");
-- CreateIndex
CREATE INDEX "notification_batch_items_batchId_idx" ON "notification_batch_items"("batchId");
-- CreateIndex
CREATE INDEX "notification_batch_items_notificationId_idx" ON "notification_batch_items"("notificationId");
-- CreateIndex
CREATE INDEX "notification_batch_items_status_idx" ON "notification_batch_items"("status");
-- CreateIndex
CREATE UNIQUE INDEX "articles_slug_key" ON "articles"("slug");
-- CreateIndex
CREATE INDEX "articles_authorId_idx" ON "articles"("authorId");
-- CreateIndex
CREATE INDEX "articles_categoryId_idx" ON "articles"("categoryId");
-- CreateIndex
CREATE INDEX "articles_status_idx" ON "articles"("status");
-- CreateIndex
CREATE INDEX "articles_publishedAt_idx" ON "articles"("publishedAt");
-- CreateIndex
CREATE INDEX "articles_slug_idx" ON "articles"("slug");
-- CreateIndex
CREATE INDEX "article_versions_articleId_idx" ON "article_versions"("articleId");
-- CreateIndex
CREATE UNIQUE INDEX "article_versions_articleId_version_key" ON "article_versions"("articleId", "version");
-- CreateIndex
CREATE UNIQUE INDEX "categories_slug_key" ON "categories"("slug");
-- CreateIndex
CREATE INDEX "categories_parentId_idx" ON "categories"("parentId");
-- CreateIndex
CREATE INDEX "categories_slug_idx" ON "categories"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
-- CreateIndex
CREATE UNIQUE INDEX "tags_slug_key" ON "tags"("slug");
-- CreateIndex
CREATE INDEX "tags_slug_idx" ON "tags"("slug");
-- CreateIndex
CREATE INDEX "article_tags_articleId_idx" ON "article_tags"("articleId");
-- CreateIndex
CREATE INDEX "article_tags_tagId_idx" ON "article_tags"("tagId");
-- CreateIndex
CREATE UNIQUE INDEX "article_tags_articleId_tagId_key" ON "article_tags"("articleId", "tagId");
-- CreateIndex
CREATE INDEX "comments_articleId_idx" ON "comments"("articleId");
-- CreateIndex
CREATE INDEX "comments_userId_idx" ON "comments"("userId");
-- CreateIndex
CREATE INDEX "comments_parentId_idx" ON "comments"("parentId");
-- CreateIndex
CREATE INDEX "comments_status_idx" ON "comments"("status");
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "user_roles" ADD CONSTRAINT "user_roles_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_roleId_fkey" FOREIGN KEY ("roleId") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "role_permissions" ADD CONSTRAINT "role_permissions_permissionId_fkey" FOREIGN KEY ("permissionId") REFERENCES "permissions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "files" ADD CONSTRAINT "files_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "oauth_accounts" ADD CONSTRAINT "oauth_accounts_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "realname_auth" ADD CONSTRAINT "realname_auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_orders" ADD CONSTRAINT "payment_orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_orders" ADD CONSTRAINT "payment_orders_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "payment_channels"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_refunds" ADD CONSTRAINT "payment_refunds_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "payment_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_refunds" ADD CONSTRAINT "payment_refunds_channelId_fkey" FOREIGN KEY ("channelId") REFERENCES "payment_channels"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_refunds" ADD CONSTRAINT "payment_refunds_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_logs" ADD CONSTRAINT "payment_logs_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "payment_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_logs" ADD CONSTRAINT "payment_logs_refundId_fkey" FOREIGN KEY ("refundId") REFERENCES "payment_refunds"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "orders" ADD CONSTRAINT "orders_paymentOrderId_fkey" FOREIGN KEY ("paymentOrderId") REFERENCES "payment_orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "order_items" ADD CONSTRAINT "order_items_orderId_fkey" FOREIGN KEY ("orderId") REFERENCES "orders"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "notification_templates"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_template_locales" ADD CONSTRAINT "notification_template_locales_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "notification_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_batches" ADD CONSTRAINT "notification_batches_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_batch_items" ADD CONSTRAINT "notification_batch_items_batchId_fkey" FOREIGN KEY ("batchId") REFERENCES "notification_batches"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "notification_batch_items" ADD CONSTRAINT "notification_batch_items_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "articles" ADD CONSTRAINT "articles_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "articles" ADD CONSTRAINT "articles_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_versions" ADD CONSTRAINT "article_versions_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_versions" ADD CONSTRAINT "article_versions_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_tags" ADD CONSTRAINT "article_tags_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "article_tags" ADD CONSTRAINT "article_tags_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tags"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_articleId_fkey" FOREIGN KEY ("articleId") REFERENCES "articles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "comments"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -46,6 +46,11 @@ model User {
loginAttempts Int @default(0)
lockedUntil DateTime?
@@index([email])
@@index([phone])
@@index([username])
@@index([isActive])
@@index([createdAt])
@@map("users")
}
@ -155,6 +160,10 @@ model Session {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([token])
@@index([isActive])
@@index([expiresAt])
@@map("sessions")
}
@ -205,6 +214,10 @@ model LoginHistory {
status String
createdAt DateTime @default(now())
@@index([userId])
@@index([ipAddress])
@@index([status])
@@index([createdAt])
@@map("login_history")
}

View File

@ -0,0 +1,23 @@
import { SetMetadata, createParamDecorator, ExecutionContext } from '@nestjs/common';
export const CACHE_KEY = 'cache_key';
export const CACHE_TTL = 'cache_ttl';
export const Cacheable = (key: string, ttl?: number) => {
return (target: any, propertyKey: string, descriptor: PropertyDescriptor) => {
SetMetadata(CACHE_KEY, key)(target, propertyKey, descriptor);
if (ttl !== undefined) {
SetMetadata(CACHE_TTL, ttl)(target, propertyKey, descriptor);
}
};
};
export const CacheEvict = (key: string) => {
return SetMetadata(CACHE_KEY, key);
};
export const CacheKey = createParamDecorator(
(data: string, ctx: ExecutionContext) => {
return data;
},
);

View File

@ -37,5 +37,9 @@ export class GlobalExceptionFilter implements ExceptionFilter {
message,
data: null,
});
if (!(exception instanceof HttpException)) {
console.error('[Unhandled Exception]', exception);
}
}
}

View File

@ -0,0 +1,28 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { CacheService } from '../../modules/cache/cache.service';
@Injectable()
export class ThrottleGuard implements CanActivate {
constructor(
private reflector: Reflector,
private cacheService: CacheService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const ip = request.ip || request.connection.remoteAddress;
const key = `throttle:${ip}:${request.path}`;
const current = await this.cacheService.get<number>(key) || 0;
const limit = 100;
const ttl = 60;
if (current >= limit) {
return false;
}
await this.cacheService.set(key, current + 1, ttl);
return true;
}
}

View File

@ -0,0 +1,42 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Inject,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, of, tap } from 'rxjs';
import { CACHE_KEY, CACHE_TTL } from '../decorators/cache.decorator';
import { CacheService } from '../../modules/cache/cache.service';
@Injectable()
export class CacheInterceptor implements NestInterceptor {
constructor(
private reflector: Reflector,
private cacheService: CacheService,
) {}
async intercept(
context: ExecutionContext,
next: CallHandler,
): Promise<Observable<any>> {
const cacheKey = this.reflector.get<string>(CACHE_KEY, context.getHandler());
const cacheTtl = this.reflector.get<number>(CACHE_TTL, context.getHandler());
if (!cacheKey) {
return next.handle();
}
const cached = await this.cacheService.get(cacheKey);
if (cached !== undefined) {
return of(cached);
}
return next.handle().pipe(
tap(async (data) => {
await this.cacheService.set(cacheKey, data, cacheTtl);
}),
);
}
}

View File

@ -0,0 +1,44 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';
@Injectable()
export class XssMiddleware implements NestMiddleware {
use(req: Request, res: Response, next: NextFunction) {
if (req.body) {
req.body = this.sanitizeObject(req.body);
}
if (req.query) {
req.query = this.sanitizeObject(req.query);
}
if (req.params) {
req.params = this.sanitizeObject(req.params);
}
next();
}
private sanitizeObject(obj: any): any {
if (typeof obj !== 'object' || obj === null) {
return this.sanitizeString(obj);
}
const sanitized: any = Array.isArray(obj) ? [] : {};
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
sanitized[key] = this.sanitizeObject(obj[key]);
}
}
return sanitized;
}
private sanitizeString(value: any): any {
if (typeof value !== 'string') {
return value;
}
return value
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+="[^"]*"/gi, '')
.replace(/on\w+='[^']*'/gi, '')
.replace(/on\w+=[^>\s]+/gi, '');
}
}

View File

@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
import { ResponseInterceptor } from './common/interceptors/response.interceptor';
@ -9,10 +10,28 @@ import { HttpMetricsInterceptor } from './modules/monitoring/http-metrics.interc
import { startTracing } from './tracing';
async function bootstrap() {
startTracing();
if (process.env.OTEL_ENABLED === 'true') {
startTracing();
}
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// Security headers
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
xssFilter: true,
frameguard: { action: 'deny' },
hsts: { maxAge: 31536000, includeSubDomains: true },
noSniff: true,
}));
app.setGlobalPrefix('api/v1');
app.useGlobalFilters(new GlobalExceptionFilter());
@ -26,7 +45,14 @@ async function bootstrap() {
transform: true,
}),
);
app.enableCors();
app.enableCors({
origin: process.env.CORS_ORIGIN || '*',
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
});
app.useStaticAssets(join(__dirname, '..', 'uploads'), {
prefix: '/uploads/',
});
@ -35,6 +61,6 @@ async function bootstrap() {
await app.listen(port);
console.log(`Application is running on: http://localhost:${port}`);
console.log(`Metrics available at: http://localhost:${port}/metrics`);
console.log(`Tracing enabled: ${process.env.OTEL_ENABLED !== 'false'}`);
console.log(`Tracing enabled: ${process.env.OTEL_ENABLED === 'true'}`);
}
void bootstrap();

View File

@ -14,7 +14,7 @@ describe('AlipayAuthService', () => {
create: jest.fn(),
update: jest.fn(),
},
oauthAccount: {
oAuthAccount: {
findUnique: jest.fn(),
create: jest.fn(),
},
@ -68,17 +68,20 @@ describe('AlipayAuthService', () => {
email: null,
phone: null,
username: 'alipay_user',
password: null,
isActive: true,
roles: [],
roles: [{ role: { name: 'user' } }],
};
jest
.spyOn(service, 'getAlipayUserInfo')
.mockResolvedValue(mockAlipayUser);
mockPrisma.oauthAccount.findUnique.mockResolvedValue({
.mockReturnValue(Promise.resolve(mockAlipayUser));
mockPrisma.oAuthAccount.findUnique.mockResolvedValue({
userId: 'user-123',
});
mockPrisma.user.findUnique.mockResolvedValue(mockExistingUser);
mockPrisma.user.update.mockResolvedValue({ ...mockExistingUser, lastLoginAt: new Date() });
mockPrisma.loginHistory.create.mockResolvedValue({});
mockSessionService.createSession.mockResolvedValue({
token: 'access-token',
refreshToken: 'refresh-token',
@ -101,17 +104,23 @@ describe('AlipayAuthService', () => {
avatar: 'https://example.com/alipay-avatar.jpg',
};
jest
.spyOn(service, 'getAlipayUserInfo')
.mockResolvedValue(mockAlipayUser);
mockPrisma.oauthAccount.findUnique.mockResolvedValue(null);
mockPrisma.user.create.mockResolvedValue({
const mockNewUser = {
id: 'user-456',
username: 'alipay_user_456',
password: null,
isActive: true,
roles: [],
});
mockPrisma.oauthAccount.create.mockResolvedValue({});
roles: [{ role: { name: 'user' } }],
};
jest
.spyOn(service, 'getAlipayUserInfo')
.mockReturnValue(Promise.resolve(mockAlipayUser));
mockPrisma.oAuthAccount.findUnique.mockResolvedValue(null);
mockPrisma.role.findUnique.mockResolvedValue({ id: 'role-user-id' });
mockPrisma.user.create.mockResolvedValue(mockNewUser);
mockPrisma.oAuthAccount.create.mockResolvedValue({});
mockPrisma.user.update.mockResolvedValue({ ...mockNewUser, lastLoginAt: new Date() });
mockPrisma.loginHistory.create.mockResolvedValue({});
mockSessionService.createSession.mockResolvedValue({
token: 'access-token',
refreshToken: 'refresh-token',
@ -120,7 +129,7 @@ describe('AlipayAuthService', () => {
const result = await service.loginWithAlipay(mockCode, {});
expect(mockPrisma.user.create).toHaveBeenCalled();
expect(mockPrisma.oauthAccount.create).toHaveBeenCalled();
expect(mockPrisma.oAuthAccount.create).toHaveBeenCalled();
expect(result).toHaveProperty('accessToken');
});
});

View File

@ -19,7 +19,7 @@ export class AlipayAuthService {
) {
const alipayUser = await this.getAlipayUserInfo(code);
const existingAccount = await this.prisma.oauthAccount.findUnique({
const existingAccount = await this.prisma.oAuthAccount.findUnique({
where: {
provider_providerId: {
provider: 'alipay',
@ -61,7 +61,7 @@ export class AlipayAuthService {
where: { name: 'user' },
select: { id: true },
})
)?.id,
)!.id,
},
},
},
@ -76,7 +76,7 @@ export class AlipayAuthService {
},
});
await this.prisma.oauthAccount.create({
await this.prisma.oAuthAccount.create({
data: {
userId: user.id,
provider: 'alipay',

View File

@ -40,7 +40,7 @@ export class AuthController {
@Post('login')
async login(@Body() loginDto: LoginDto, @Req() req: any) {
const identifier = loginDto.email || loginDto.phone;
const identifier = loginDto.email || loginDto.phone || '';
const user = await this.authService.validateUser(
identifier,
loginDto.password,

View File

@ -14,7 +14,7 @@ export class AuthService {
private jwtService: JwtService,
) {}
async validateUser(identifier: string, password: string) {
async validateUser(identifier: string, inputPassword: string) {
let user;
if (this.isPhone(identifier)) {
@ -37,7 +37,7 @@ export class AuthService {
throw new UnauthorizedException('Account is disabled');
}
const passwordValid = await this.comparePassword(password, user.password);
const passwordValid = await this.comparePassword(inputPassword, user.password);
if (!passwordValid) {
throw new UnauthorizedException('Invalid credentials');
}

Some files were not shown because too many files have changed in this diff Show More