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 && (
+
+ )}
+