diff --git a/messages/en.json b/messages/en.json index e7d4148..49cc745 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3,6 +3,7 @@ "home": "Home", "studio": "Studio", "profile": "Profile", + "admin": "Admin", "signIn": "Sign In", "signUp": "Sign Up", "signOut": "Sign Out" @@ -215,6 +216,30 @@ "perMonth": "per month", "startProTrial": "Start Pro Trial" }, + "admin": { + "dashboard": "Admin Dashboard", + "totalUsers": "Total Users", + "totalPrompts": "Total Prompts", + "sharedPrompts": "Shared Prompts", + "publishedPrompts": "Published Prompts", + "quickActions": "Quick Actions", + "systemStatus": "System Status", + "reviewPrompts": "Review Prompts", + "reviewPromptsDesc": "Approve or reject shared prompts", + "databaseStatus": "Database", + "authStatus": "Authentication", + "healthy": "Healthy", + "pending": "pending", + "noPromptsPending": "No prompts pending review", + "allPromptsReviewed": "All shared prompts have been reviewed.", + "underReview": "Under Review", + "published": "Published", + "promptContent": "Prompt Content", + "approve": "Approve", + "reject": "Reject", + "allPrompts": "All Prompts", + "allPromptsDesc": "Review all shared prompts" + }, "errors": { "generic": "Something went wrong. Please try again.", "network": "Network error. Please check your connection.", diff --git a/messages/zh.json b/messages/zh.json index f7e0a25..4c19c07 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -3,6 +3,7 @@ "home": "首页", "studio": "工作室", "profile": "个人资料", + "admin": "管理员后台", "signIn": "登录", "signUp": "注册", "signOut": "退出登录" @@ -215,6 +216,30 @@ "perMonth": "每月", "startProTrial": "开始专业试用" }, + "admin": { + "dashboard": "管理员后台", + "totalUsers": "用户总数", + "totalPrompts": "提示词总数", + "sharedPrompts": "用户共享提示词", + "publishedPrompts": "广场提示词", + "quickActions": "快捷操作", + "systemStatus": "系统状态", + "reviewPrompts": "审核提示词", + "reviewPromptsDesc": "审核或拒绝用户共享的提示词", + "databaseStatus": "数据库", + "authStatus": "身份验证", + "healthy": "正常", + "pending": "待审核", + "noPromptsPending": "没有待审核的提示词", + "allPromptsReviewed": "所有共享的提示词都已审核完成。", + "underReview": "审核中", + "published": "已发布", + "promptContent": "提示词内容", + "approve": "通过", + "reject": "拒绝", + "allPrompts": "所有提示词", + "allPromptsDesc": "审核所有共享提示词" + }, "errors": { "generic": "出现错误,请重试。", "network": "网络错误,请检查您的网络连接。", diff --git a/package-lock.json b/package-lock.json index 0d963a7..570a4ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.53.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.532.0", "next": "15.4.4", "next-intl": "^4.3.4", @@ -2692,6 +2693,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", diff --git a/package.json b/package.json index 23b3753..7470ed9 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "@supabase/ssr": "^0.6.1", "@supabase/supabase-js": "^2.53.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.532.0", "next": "15.4.4", "next-intl": "^4.3.4", @@ -45,4 +46,4 @@ "tailwindcss": "^4", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bbccf34..1b7068f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { avatar String? bio String? language String @default("en") + isAdmin Boolean @default(false) // 管理员标记 versionLimit Int @default(3) // 版本数量限制,可在用户配置中设置 subscribePlan String @default("free") // 订阅计划: "free", "pro" maxVersionLimit Int @default(3) // 基于订阅的最大版本限制 diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 0000000..204f60c --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,51 @@ +'use client' + +import { useUser } from '@/hooks/useUser' +import { useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { useTranslations } from 'next-intl' + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const { userData, loading } = useUser() + const router = useRouter() + const t = useTranslations('admin') + + useEffect(() => { + if (!loading && (!userData || !userData.isAdmin)) { + router.push('/') + } + }, [userData, loading, router]) + + if (loading) { + return ( +
+
+
+
+
+ ) + } + + if (!userData || !userData.isAdmin) { + return null + } + + return ( +
+
+
+

+ {t('dashboard')} +

+
+
+
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..ec83532 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,157 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Card } from '@/components/ui/card' +import { Users, FileText, Share, CheckCircle } from 'lucide-react' +import Link from 'next/link' + +interface AdminStats { + totalUsers: number + totalPrompts: number + sharedPrompts: number + publishedPrompts: number +} + +export default function AdminDashboard() { + const t = useTranslations('admin') + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + const fetchStats = async () => { + try { + const response = await fetch('/api/admin/stats') + if (response.ok) { + const data = await response.json() + setStats(data) + } + } catch (error) { + console.error('Failed to fetch admin stats:', error) + } finally { + setLoading(false) + } + } + + fetchStats() + }, []) + + if (loading) { + return ( +
+ {[...Array(4)].map((_, i) => ( + +
+
+
+
+
+ ))} +
+ ) + } + + const statCards = [ + { + title: t('totalUsers'), + value: stats?.totalUsers || 0, + icon: Users, + color: 'text-blue-600', + }, + { + title: t('totalPrompts'), + value: stats?.totalPrompts || 0, + icon: FileText, + color: 'text-green-600', + }, + { + title: t('sharedPrompts'), + value: stats?.sharedPrompts || 0, + icon: Share, + color: 'text-yellow-600', + }, + { + title: t('publishedPrompts'), + value: stats?.publishedPrompts || 0, + icon: CheckCircle, + color: 'text-purple-600', + }, + ] + + return ( +
+ {/* Stats Grid */} +
+ {statCards.map((stat, index) => { + const Icon = stat.icon + return ( + +
+
+

+ {stat.title} +

+

+ {stat.value.toLocaleString()} +

+
+ +
+
+ ) + })} +
+ + {/* Quick Actions */} +
+ +

+ {t('quickActions')} +

+
+ +
+ +
+
+ {t('allPrompts')} +
+
+ {t('allPromptsDesc')} +
+
+
+ +
+
+ + +

+ {t('systemStatus')} +

+
+
+ + {t('databaseStatus')} + + + {t('healthy')} + +
+
+ + {t('authStatus')} + + + {t('healthy')} + +
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/admin/review/page.tsx b/src/app/admin/review/page.tsx new file mode 100644 index 0000000..cc3096b --- /dev/null +++ b/src/app/admin/review/page.tsx @@ -0,0 +1,209 @@ +'use client' + +import { useEffect, useState } from 'react' +import { useTranslations } from 'next-intl' +import { Card } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { CheckCircle, XCircle, Eye, Calendar, User as UserIcon } from 'lucide-react' +import { formatDistanceToNow } from 'date-fns' + +interface ReviewPrompt { + id: string + name: string + description: string | null + content: string + visibility: string | null + createdAt: string + updatedAt: string + user: { + username: string | null + email: string + } +} + +export default function AdminReviewPage() { + const t = useTranslations('admin') + const [prompts, setPrompts] = useState([]) + const [loading, setLoading] = useState(true) + + const fetchPendingPrompts = async () => { + try { + const response = await fetch('/api/admin/prompts/pending') + if (response.ok) { + const data = await response.json() + setPrompts(data.prompts) + } + } catch (error) { + console.error('Failed to fetch pending prompts:', error) + } finally { + setLoading(false) + } + } + + useEffect(() => { + fetchPendingPrompts() + }, []) + + const handleApprove = async (promptId: string) => { + try { + const response = await fetch(`/api/admin/prompts/${promptId}/approve`, { + method: 'POST' + }) + if (response.ok) { + setPrompts(prompts.filter(p => p.id !== promptId)) + } + } catch (error) { + console.error('Failed to approve prompt:', error) + } + } + + const handleReject = async (promptId: string) => { + try { + const response = await fetch(`/api/admin/prompts/${promptId}/reject`, { + method: 'POST' + }) + if (response.ok) { + setPrompts(prompts.filter(p => p.id !== promptId)) + } + } catch (error) { + console.error('Failed to reject prompt:', error) + } + } + + if (loading) { + return ( +
+
+

+ {t('reviewPrompts') || 'Review Prompts'} +

+
+
+ {[...Array(3)].map((_, i) => ( + +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ) + } + + return ( +
+
+

+ {t('allPrompts')} +

+
+ + {prompts.filter(p => !p.visibility || p.visibility === 'under_review').length} {t('pending')} + + + {prompts.filter(p => p.visibility === 'published').length} {t('published')} + +
+
+ + {prompts.length === 0 ? ( + + +

+ {t('noPromptsPending')} +

+

+ {t('allPromptsReviewed')} +

+
+ ) : ( +
+ {prompts.map((prompt) => ( + +
+ {/* Header */} +
+
+

+ {prompt.name} +

+ {prompt.description && ( +

+ {prompt.description} +

+ )} +
+ + {prompt.visibility === 'published' ? t('published') : t('underReview')} + +
+ + {/* Metadata */} +
+
+ + {prompt.user.username || prompt.user.email} +
+
+ + + {formatDistanceToNow(new Date(prompt.createdAt), { addSuffix: true })} + +
+
+ + {/* Content Preview */} +
+
+ + + {t('promptContent')} + +
+
+ {prompt.content.length > 500 + ? `${prompt.content.slice(0, 500)}...` + : prompt.content + } +
+
+ + {/* Actions */} +
+ {prompt.visibility !== 'published' && ( + + )} + +
+
+
+ ))} +
+ )} +
+ ) +} \ No newline at end of file diff --git a/src/app/api/admin/prompts/[id]/approve/route.ts b/src/app/api/admin/prompts/[id]/approve/route.ts new file mode 100644 index 0000000..fb86092 --- /dev/null +++ b/src/app/api/admin/prompts/[id]/approve/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { createServerSupabaseClient } from '@/lib/supabase-server' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + try { + const supabase = await createServerSupabaseClient() + const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser() + + if (authError || !supabaseUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if user is admin + const user = await prisma.user.findUnique({ + where: { id: supabaseUser.id }, + select: { isAdmin: true } + }) + + if (!user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Update prompt visibility to published + const updatedPrompt = await prisma.prompt.update({ + where: { id }, + data: { + visibility: 'published', + updatedAt: new Date() + } + }) + + return NextResponse.json({ + message: 'Prompt approved successfully', + prompt: updatedPrompt + }) + + } catch (error) { + console.error('Error approving prompt:', error) + return NextResponse.json( + { error: 'Failed to approve prompt' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/admin/prompts/[id]/reject/route.ts b/src/app/api/admin/prompts/[id]/reject/route.ts new file mode 100644 index 0000000..ee4c7c6 --- /dev/null +++ b/src/app/api/admin/prompts/[id]/reject/route.ts @@ -0,0 +1,50 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { createServerSupabaseClient } from '@/lib/supabase-server' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const { id } = await params + try { + const supabase = await createServerSupabaseClient() + const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser() + + if (authError || !supabaseUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if user is admin + const user = await prisma.user.findUnique({ + where: { id: supabaseUser.id }, + select: { isAdmin: true } + }) + + if (!user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Update prompt permissions back to private (rejected) + const updatedPrompt = await prisma.prompt.update({ + where: { id }, + data: { + permissions: 'private', + visibility: null, + updatedAt: new Date() + } + }) + + return NextResponse.json({ + message: 'Prompt rejected successfully', + prompt: updatedPrompt + }) + + } catch (error) { + console.error('Error rejecting prompt:', error) + return NextResponse.json( + { error: 'Failed to reject prompt' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/admin/prompts/pending/route.ts b/src/app/api/admin/prompts/pending/route.ts new file mode 100644 index 0000000..3dd8cce --- /dev/null +++ b/src/app/api/admin/prompts/pending/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { createServerSupabaseClient } from '@/lib/supabase-server' + +export async function GET() { + try { + const supabase = await createServerSupabaseClient() + const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser() + + if (authError || !supabaseUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if user is admin + const user = await prisma.user.findUnique({ + where: { id: supabaseUser.id }, + select: { isAdmin: true } + }) + + if (!user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Get prompts that are public (including pending and published ones for review) + const prompts = await prisma.prompt.findMany({ + where: { + permissions: 'public', + OR: [ + { visibility: null }, + { visibility: 'under_review' }, + { visibility: 'published' } + ] + }, + include: { + user: { + select: { + username: true, + email: true + } + } + }, + orderBy: { + createdAt: 'desc' + } + }) + + return NextResponse.json({ prompts }) + + } catch (error) { + console.error('Error fetching pending prompts:', error) + return NextResponse.json( + { error: 'Failed to fetch prompts' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/app/api/admin/stats/route.ts b/src/app/api/admin/stats/route.ts new file mode 100644 index 0000000..01e084e --- /dev/null +++ b/src/app/api/admin/stats/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { createServerSupabaseClient } from '@/lib/supabase-server' + +export async function GET() { + try { + const supabase = await createServerSupabaseClient() + const { data: { user: supabaseUser }, error: authError } = await supabase.auth.getUser() + + if (authError || !supabaseUser) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Check if user is admin + const user = await prisma.user.findUnique({ + where: { id: supabaseUser.id }, + select: { isAdmin: true } + }) + + if (!user?.isAdmin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + // Get statistics + const [totalUsers, totalPrompts, sharedPrompts, publishedPrompts] = await Promise.all([ + prisma.user.count(), + prisma.prompt.count(), + prisma.prompt.count({ + where: { permissions: 'public' } + }), + prisma.prompt.count({ + where: { + permissions: 'public', + visibility: 'published' + } + }) + ]) + + return NextResponse.json({ + totalUsers, + totalPrompts, + sharedPrompts, + publishedPrompts + }) + + } catch (error) { + console.error('Error fetching admin stats:', error) + return NextResponse.json( + { error: 'Failed to fetch stats' }, + { status: 500 } + ) + } +} \ No newline at end of file diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..774529f --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,24 @@ +import { cn } from '@/lib/utils' + +interface BadgeProps { + children: React.ReactNode + variant?: 'default' | 'secondary' | 'destructive' | 'outline' + className?: string +} + +export function Badge({ children, variant = 'default', className }: BadgeProps) { + const baseStyles = 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2' + + const variants = { + default: 'bg-primary text-primary-foreground hover:bg-primary/80', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/80', + outline: 'text-foreground border border-input bg-background hover:bg-accent hover:text-accent-foreground' + } + + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..575db8b --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,17 @@ +import { cn } from '@/lib/utils' + +interface CardProps { + children: React.ReactNode + className?: string +} + +export function Card({ children, className }: CardProps) { + return ( +
+ {children} +
+ ) +} \ No newline at end of file diff --git a/src/components/ui/user-avatar-dropdown.tsx b/src/components/ui/user-avatar-dropdown.tsx index 01c4f0f..092e450 100644 --- a/src/components/ui/user-avatar-dropdown.tsx +++ b/src/components/ui/user-avatar-dropdown.tsx @@ -5,8 +5,10 @@ import { useTranslations } from 'next-intl' import { User as SupabaseUser } from '@supabase/supabase-js' import { Button } from '@/components/ui/button' import { Avatar } from '@/components/ui/avatar' -import { ChevronDown, User, LogOut } from 'lucide-react' +import { ChevronDown, User, LogOut, Settings } from 'lucide-react' import { cn } from '@/lib/utils' +import { useUser } from '@/hooks/useUser' +import { useRouter } from 'next/navigation' interface UserAvatarDropdownProps { user: SupabaseUser @@ -23,6 +25,8 @@ export function MobileUserMenu({ className }: UserAvatarDropdownProps) { const t = useTranslations('navigation') + const { isAdmin } = useUser() + const router = useRouter() const userName = user.user_metadata?.full_name || user.user_metadata?.name || @@ -67,6 +71,20 @@ export function MobileUserMenu({ {t('profile')} + {isAdmin && ( + + )} + + {isAdmin && ( + + )} +