add admin

This commit is contained in:
songtianlun 2025-08-03 11:04:56 +08:00
parent 2e44c5865d
commit b052bbedf5
5 changed files with 217 additions and 149 deletions

View File

@ -218,6 +218,7 @@
}, },
"admin": { "admin": {
"dashboard": "Admin Dashboard", "dashboard": "Admin Dashboard",
"dashboardDesc": "Manage users, prompts, and system settings",
"totalUsers": "Total Users", "totalUsers": "Total Users",
"totalPrompts": "Total Prompts", "totalPrompts": "Total Prompts",
"sharedPrompts": "Shared Prompts", "sharedPrompts": "Shared Prompts",
@ -238,7 +239,10 @@
"approve": "Approve", "approve": "Approve",
"reject": "Reject", "reject": "Reject",
"allPrompts": "All Prompts", "allPrompts": "All Prompts",
"allPromptsDesc": "Review all shared prompts" "allPromptsDesc": "Review all shared prompts",
"loadingAdmin": "Loading admin panel...",
"loadingDashboard": "Loading dashboard statistics...",
"loadingPrompts": "Loading prompts for review..."
}, },
"errors": { "errors": {
"generic": "Something went wrong. Please try again.", "generic": "Something went wrong. Please try again.",

View File

@ -218,6 +218,7 @@
}, },
"admin": { "admin": {
"dashboard": "管理员后台", "dashboard": "管理员后台",
"dashboardDesc": "管理用户、提示词和系统设置",
"totalUsers": "用户总数", "totalUsers": "用户总数",
"totalPrompts": "提示词总数", "totalPrompts": "提示词总数",
"sharedPrompts": "用户共享提示词", "sharedPrompts": "用户共享提示词",
@ -238,7 +239,10 @@
"approve": "通过", "approve": "通过",
"reject": "拒绝", "reject": "拒绝",
"allPrompts": "所有提示词", "allPrompts": "所有提示词",
"allPromptsDesc": "审核所有共享提示词" "allPromptsDesc": "审核所有共享提示词",
"loadingAdmin": "加载管理员后台中...",
"loadingDashboard": "加载统计数据中...",
"loadingPrompts": "加载审核提示词中..."
}, },
"errors": { "errors": {
"generic": "出现错误,请重试。", "generic": "出现错误,请重试。",

View File

@ -4,6 +4,7 @@ import { useUser } from '@/hooks/useUser'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useEffect } from 'react' import { useEffect } from 'react'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { Header } from '@/components/layout/Header'
export default function AdminLayout({ export default function AdminLayout({
children, children,
@ -22,9 +23,15 @@ export default function AdminLayout({
if (loading) { if (loading) {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen">
<div className="flex items-center justify-center h-screen"> <Header />
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> <div className="max-w-7xl mx-auto px-4 py-8">
<div className="flex items-center justify-center min-h-96">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">{t('loadingAdmin')}</p>
</div>
</div>
</div> </div>
</div> </div>
) )
@ -35,17 +42,9 @@ export default function AdminLayout({
} }
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen">
<div className="border-b border-border bg-card"> <Header />
<div className="container mx-auto px-4 py-4"> {children}
<h1 className="text-2xl font-bold text-foreground">
{t('dashboard')}
</h1>
</div>
</div>
<div className="container mx-auto px-4 py-8">
{children}
</div>
</div> </div>
) )
} }

View File

@ -38,15 +38,22 @@ export default function AdminDashboard() {
if (loading) { if (loading) {
return ( return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="max-w-7xl mx-auto px-4 py-8">
{[...Array(4)].map((_, i) => ( {/* Header */}
<Card key={i} className="p-6"> <div className="mb-8">
<div className="animate-pulse"> <h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">{t('dashboard')}</h1>
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div> <p className="text-muted-foreground">
<div className="h-8 bg-muted rounded w-1/2"></div> {t('dashboardDesc') || 'Manage users, prompts, and system settings'}
</div> </p>
</Card> </div>
))}
{/* Loading State */}
<div className="flex items-center justify-center min-h-96">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<p className="text-sm text-muted-foreground">{t('loadingDashboard')}</p>
</div>
</div>
</div> </div>
) )
} }
@ -79,78 +86,112 @@ export default function AdminDashboard() {
] ]
return ( return (
<div className="space-y-8"> <div className="max-w-7xl mx-auto px-4 py-8">
{/* Stats Grid */} {/* Header */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="mb-8">
{statCards.map((stat, index) => { <h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">{t('dashboard')}</h1>
const Icon = stat.icon <p className="text-muted-foreground">
return ( {t('dashboardDesc') || 'Manage users, prompts, and system settings'}
<Card key={index} className="p-6 hover:shadow-md transition-shadow"> </p>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-muted-foreground">
{stat.title}
</p>
<p className="text-3xl font-bold text-foreground">
{stat.value.toLocaleString()}
</p>
</div>
<Icon className={`h-8 w-8 ${stat.color}`} />
</div>
</Card>
)
})}
</div> </div>
{/* Quick Actions */} <div className="space-y-6 lg:space-y-8">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> {/* Stats Grid */}
<Card className="p-6"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 lg:gap-6">
<h3 className="text-lg font-semibold text-foreground mb-4"> {statCards.map((stat, index) => {
{t('quickActions')} const Icon = stat.icon
</h3> return (
<div className="space-y-3"> <Card key={index} className="p-4 lg:p-6 hover:shadow-md transition-all duration-200 border-border hover:border-primary/20">
<Link <div className="flex items-center justify-between">
href="/admin/review" <div className="min-w-0 flex-1">
className="block p-3 rounded-lg border border-border hover:bg-accent hover:text-accent-foreground transition-colors" <p className="text-xs sm:text-sm font-medium text-muted-foreground mb-1 truncate">
> {stat.title}
<div className="flex items-center gap-3"> </p>
<CheckCircle className="h-5 w-5 text-primary" /> <p className="text-xl sm:text-2xl lg:text-3xl font-bold text-foreground">
<div> {stat.value.toLocaleString()}
<div className="font-medium"> </p>
{t('allPrompts')}
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="flex-shrink-0 ml-3">
{t('allPromptsDesc')} <div className="p-2 lg:p-3 rounded-full bg-muted/50">
<Icon className={`h-5 w-5 lg:h-6 lg:w-6 ${stat.color}`} />
</div>
</div> </div>
</div> </div>
</div> </Card>
</Link> )
</div> })}
</Card> </div>
<Card className="p-6"> {/* Quick Actions and System Status */}
<h3 className="text-lg font-semibold text-foreground mb-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{t('systemStatus')} {/* Quick Actions */}
</h3> <Card className="p-4 lg:p-6">
<div className="space-y-3"> <div className="flex items-center gap-3 mb-4 lg:mb-6">
<div className="flex items-center justify-between"> <div className="p-2 rounded-lg bg-primary/10">
<span className="text-sm text-muted-foreground"> <CheckCircle className="h-5 w-5 text-primary" />
{t('databaseStatus')} </div>
</span> <h3 className="text-lg font-semibold text-foreground">
<span className="text-sm font-medium text-green-600"> {t('quickActions')}
{t('healthy')} </h3>
</span>
</div> </div>
<div className="flex items-center justify-between"> <div className="space-y-3">
<span className="text-sm text-muted-foreground"> <Link
{t('authStatus')} href="/admin/review"
</span> className="group block p-3 lg:p-4 rounded-lg border border-border hover:border-primary/30 hover:bg-accent/50 transition-all duration-200"
<span className="text-sm font-medium text-green-600"> >
{t('healthy')} <div className="flex items-center gap-3">
</span> <div className="p-2 rounded-md bg-primary/5 group-hover:bg-primary/10 transition-colors">
<FileText className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-foreground group-hover:text-primary transition-colors">
{t('allPrompts')}
</div>
<div className="text-sm text-muted-foreground mt-0.5">
{t('allPromptsDesc')}
</div>
</div>
</div>
</Link>
</div> </div>
</div> </Card>
</Card>
{/* System Status */}
<Card className="p-4 lg:p-6">
<div className="flex items-center gap-3 mb-4 lg:mb-6">
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
<CheckCircle className="h-5 w-5 text-green-600" />
</div>
<h3 className="text-lg font-semibold text-foreground">
{t('systemStatus')}
</h3>
</div>
<div className="space-y-3">
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-sm font-medium text-foreground">
{t('databaseStatus')}
</span>
</div>
<span className="text-sm font-medium text-green-600">
{t('healthy')}
</span>
</div>
<div className="flex items-center justify-between p-3 rounded-lg bg-muted/30">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500"></div>
<span className="text-sm font-medium text-foreground">
{t('authStatus')}
</span>
</div>
<span className="text-sm font-medium text-green-600">
{t('healthy')}
</span>
</div>
</div>
</Card>
</div>
</div> </div>
</div> </div>
) )

View File

@ -73,104 +73,123 @@ export default function AdminReviewPage() {
if (loading) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="max-w-7xl mx-auto px-4 py-8">
<div className="flex items-center justify-between"> {/* Header */}
<h1 className="text-2xl font-bold text-foreground"> <div className="mb-8">
{t('reviewPrompts') || 'Review Prompts'} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
</h1> <div>
<h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
{t('allPrompts')}
</h1>
<p className="text-muted-foreground">
{t('reviewPromptsDesc')}
</p>
</div>
</div>
</div> </div>
<div className="space-y-4">
{[...Array(3)].map((_, i) => ( {/* Loading State */}
<Card key={i} className="p-6"> <div className="flex items-center justify-center min-h-96">
<div className="animate-pulse space-y-4"> <div className="flex flex-col items-center gap-3">
<div className="h-4 bg-muted rounded w-3/4"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<div className="h-20 bg-muted rounded"></div> <p className="text-sm text-muted-foreground">{t('loadingPrompts')}</p>
<div className="flex gap-2"> </div>
<div className="h-9 bg-muted rounded w-20"></div>
<div className="h-9 bg-muted rounded w-20"></div>
</div>
</div>
</Card>
))}
</div> </div>
</div> </div>
) )
} }
return ( return (
<div className="space-y-6"> <div className="max-w-7xl mx-auto px-4 py-8">
<div className="flex items-center justify-between"> {/* Header */}
<h1 className="text-2xl font-bold text-foreground"> <div className="mb-8">
{t('allPrompts')} <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-4">
</h1> <div>
<div className="flex items-center gap-2"> <h1 className="text-2xl sm:text-3xl font-bold text-foreground mb-2">
<Badge variant="secondary"> {t('allPrompts')}
{prompts.filter(p => !p.visibility || p.visibility === 'under_review').length} {t('pending')} </h1>
</Badge> <p className="text-muted-foreground">
<Badge variant="outline"> {t('reviewPromptsDesc')}
{prompts.filter(p => p.visibility === 'published').length} {t('published')} </p>
</Badge> </div>
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs sm:text-sm">
{prompts.filter(p => !p.visibility || p.visibility === 'under_review').length} {t('pending')}
</Badge>
<Badge variant="outline" className="text-xs sm:text-sm">
{prompts.filter(p => p.visibility === 'published').length} {t('published')}
</Badge>
</div>
</div> </div>
</div> </div>
{/* Content */}
{prompts.length === 0 ? ( {prompts.length === 0 ? (
<Card className="p-8 text-center"> <Card className="p-8 lg:p-12 text-center">
<CheckCircle className="h-12 w-12 text-green-500 mx-auto mb-4" /> <div className="max-w-md mx-auto">
<h3 className="text-lg font-semibold text-foreground mb-2"> <CheckCircle className="h-12 w-12 lg:h-16 lg:w-16 text-green-500 mx-auto mb-4" />
{t('noPromptsPending')} <h3 className="text-lg lg:text-xl font-semibold text-foreground mb-2">
</h3> {t('noPromptsPending')}
<p className="text-muted-foreground"> </h3>
{t('allPromptsReviewed')} <p className="text-muted-foreground">
</p> {t('allPromptsReviewed')}
</p>
</div>
</Card> </Card>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4 lg:space-y-6">
{prompts.map((prompt) => ( {prompts.map((prompt) => (
<Card key={prompt.id} className="p-6"> <Card key={prompt.id} className="p-4 lg:p-6 border-border hover:border-primary/20 transition-all duration-200">
<div className="space-y-4"> <div className="space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between"> <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3">
<div className="space-y-1"> <div className="space-y-1 min-w-0 flex-1">
<h3 className="text-lg font-semibold text-foreground"> <h3 className="text-lg lg:text-xl font-semibold text-foreground break-words">
{prompt.name} {prompt.name}
</h3> </h3>
{prompt.description && ( {prompt.description && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground line-clamp-2 break-words">
{prompt.description} {prompt.description}
</p> </p>
)} )}
</div> </div>
<Badge <div className="flex-shrink-0">
variant={prompt.visibility === 'published' ? 'default' : 'outline'} <Badge
className={prompt.visibility === 'published' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' : ''} variant={prompt.visibility === 'published' ? 'default' : 'outline'}
> className={`text-xs sm:text-sm ${
{prompt.visibility === 'published' ? t('published') : t('underReview')} prompt.visibility === 'published'
</Badge> ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: ''
}`}
>
{prompt.visibility === 'published' ? t('published') : t('underReview')}
</Badge>
</div>
</div> </div>
{/* Metadata */} {/* Metadata */}
<div className="flex items-center gap-4 text-sm text-muted-foreground"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<UserIcon className="h-4 w-4" /> <UserIcon className="h-4 w-4 flex-shrink-0" />
<span>{prompt.user.username || prompt.user.email}</span> <span className="truncate">{prompt.user.username || prompt.user.email}</span>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4 flex-shrink-0" />
<span> <span className="whitespace-nowrap">
{formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })} {formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })}
</span> </span>
</div> </div>
</div> </div>
{/* Content Preview */} {/* Content Preview */}
<div className="border border-border rounded-lg p-4 bg-muted/50"> <div className="border border-border rounded-lg p-3 lg:p-4 bg-muted/30">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<Eye className="h-4 w-4 text-muted-foreground" /> <Eye className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{t('promptContent')} {t('promptContent')}
</span> </span>
</div> </div>
<div className="text-sm text-foreground max-h-32 overflow-y-auto"> <div className="text-sm text-foreground max-h-32 lg:max-h-40 overflow-y-auto break-words">
{prompt.content.length > 500 {prompt.content.length > 500
? `${prompt.content.slice(0, 500)}...` ? `${prompt.content.slice(0, 500)}...`
: prompt.content : prompt.content
@ -179,11 +198,11 @@ export default function AdminReviewPage() {
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex items-center gap-3 pt-2"> <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 pt-2 border-t border-border/50">
{prompt.visibility !== 'published' && ( {prompt.visibility !== 'published' && (
<Button <Button
onClick={() => handleApprove(prompt.id)} onClick={() => handleApprove(prompt.id)}
className="bg-green-600 hover:bg-green-700 text-white" className="bg-green-600 hover:bg-green-700 text-white w-full sm:w-auto"
size="sm" size="sm"
> >
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
@ -194,6 +213,7 @@ export default function AdminReviewPage() {
onClick={() => handleReject(prompt.id)} onClick={() => handleReject(prompt.id)}
variant="destructive" variant="destructive"
size="sm" size="sm"
className="w-full sm:w-auto"
> >
<XCircle className="h-4 w-4 mr-2" /> <XCircle className="h-4 w-4 mr-2" />
{t('reject')} {t('reject')}