diff --git a/app/(dashboard)/users/page.tsx b/app/(dashboard)/users/page.tsx new file mode 100644 index 0000000..cfaf603 --- /dev/null +++ b/app/(dashboard)/users/page.tsx @@ -0,0 +1,248 @@ +'use client' + +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' +import { Header } from '@/components/layout/header' +import { UserDialog } from '@/components/users/user-dialog' +import { Spinner } from '@/components/ui/spinner' +import { User, UserFormData } from '@/types' +import { Plus, Pencil, Trash2 } from 'lucide-react' + +export default function UsersPage() { + const queryClient = useQueryClient() + const [isDialogOpen, setIsDialogOpen] = useState(false) + const [editingUser, setEditingUser] = useState(null) + const [deleteConfirmId, setDeleteConfirmId] = useState(null) + + // Fetch users + const { data: users, isLoading, error } = useQuery({ + queryKey: ['users'], + queryFn: async () => { + const res = await fetch('/api/users') + if (!res.ok) throw new Error('Failed to fetch users') + return res.json() + }, + }) + + // Create user mutation + const createMutation = useMutation({ + mutationFn: async (data: UserFormData) => { + const res = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed to create user') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) + + // Update user mutation + const updateMutation = useMutation({ + mutationFn: async ({ id, data }: { id: string; data: UserFormData }) => { + const res = await fetch('/api/users', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id, ...data }), + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed to update user') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + }, + }) + + // Delete user mutation + const deleteMutation = useMutation({ + mutationFn: async (id: string) => { + const res = await fetch(`/api/users?id=${id}`, { + method: 'DELETE', + }) + if (!res.ok) { + const error = await res.json() + throw new Error(error.error || 'Failed to delete user') + } + return res.json() + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['users'] }) + setDeleteConfirmId(null) + }, + }) + + const handleOpenCreate = () => { + setEditingUser(null) + setIsDialogOpen(true) + } + + const handleOpenEdit = (user: User) => { + setEditingUser(user) + setIsDialogOpen(true) + } + + const handleDelete = (id: string) => { + if (deleteConfirmId === id) { + deleteMutation.mutate(id) + } else { + setDeleteConfirmId(id) + // Reset after 3 seconds + setTimeout(() => setDeleteConfirmId(null), 3000) + } + } + + const handleSubmit = async (data: UserFormData) => { + if (editingUser) { + await updateMutation.mutateAsync({ id: editingUser.id, data }) + } else { + await createMutation.mutateAsync(data) + } + } + + const formatDate = (date: Date | string) => { + return new Date(date).toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return ( +
+
+

加载失败

+ +
+
+ ) + } + + return ( +
+
+
+
+
+
+

用户管理

+

+ 共 {users?.length || 0} 个用户 +

+
+ +
+ +
+ + + + + + + + + + + {users?.map((user) => ( + + + + + + + ))} + {users?.length === 0 && ( + + + + )} + +
+ 用户 + + 邮箱 + + 创建时间 + + 操作 +
+
+
+ {user.email[0]?.toUpperCase() || 'U'} +
+
+
+ {user.name || '未设置'} +
+
+
+
+
{user.email}
+
+ {formatDate(user.createdAt)} + + + +
+ 暂无用户数据 +
+
+
+
+ + +
+ ) +} diff --git a/app/api/users/route.ts b/app/api/users/route.ts new file mode 100644 index 0000000..66c1000 --- /dev/null +++ b/app/api/users/route.ts @@ -0,0 +1,217 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { auth } from '@/lib/auth' +import { hashPassword } from 'better-auth/crypto' +import { v4 as uuidv4 } from 'uuid' + +// GET /api/users - Get all users +export async function GET(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const users = await prisma.user.findMany({ + orderBy: { createdAt: 'desc' }, + select: { + id: true, + email: true, + emailVerified: true, + name: true, + image: true, + createdAt: true, + updatedAt: true, + }, + }) + + return NextResponse.json(users) + } catch (error) { + console.error('Error fetching users:', error) + return NextResponse.json( + { error: 'Failed to fetch users' }, + { status: 500 } + ) + } +} + +// POST /api/users - Create a new user +export async function POST(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { email, password, name } = body + + if (!email || !password) { + return NextResponse.json( + { error: 'email and password are required' }, + { status: 400 } + ) + } + + // Check if user already exists + const existingUser = await prisma.user.findUnique({ + where: { email }, + }) + + if (existingUser) { + return NextResponse.json( + { error: '该邮箱已被注册' }, + { status: 400 } + ) + } + + // Hash password + const hashedPassword = await hashPassword(password) + + const userId = uuidv4() + const accountId = uuidv4() + + // Create user and account in transaction + const user = await prisma.$transaction(async (tx) => { + const newUser = await tx.user.create({ + data: { + id: userId, + email, + name: name || null, + }, + }) + + await tx.account.create({ + data: { + id: accountId, + accountId: userId, + providerId: 'credential', + userId: userId, + password: hashedPassword, + }, + }) + + return newUser + }) + + return NextResponse.json(user, { status: 201 }) + } catch (error) { + console.error('Error creating user:', error) + return NextResponse.json( + { error: 'Failed to create user' }, + { status: 500 } + ) + } +} + +// PUT /api/users - Update a user +export async function PUT(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { id, email, password, name } = body + + if (!id) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + + // Check if email is taken by another user + if (email) { + const existingUser = await prisma.user.findFirst({ + where: { + email, + NOT: { id }, + }, + }) + + if (existingUser) { + return NextResponse.json( + { error: '该邮箱已被其他用户使用' }, + { status: 400 } + ) + } + } + + // Update user and optionally password + const user = await prisma.$transaction(async (tx) => { + const updatedUser = await tx.user.update({ + where: { id }, + data: { + email, + name: name || null, + }, + }) + + if (password) { + const hashedPassword = await hashPassword(password) + await tx.account.updateMany({ + where: { userId: id, providerId: 'credential' }, + data: { password: hashedPassword }, + }) + } + + return updatedUser + }) + + return NextResponse.json(user) + } catch (error) { + console.error('Error updating user:', error) + return NextResponse.json( + { error: 'Failed to update user' }, + { status: 500 } + ) + } +} + +// DELETE /api/users - Delete a user +export async function DELETE(request: NextRequest) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const id = searchParams.get('id') + + if (!id) { + return NextResponse.json({ error: 'id is required' }, { status: 400 }) + } + + // Prevent deleting yourself + if (id === session.user.id) { + return NextResponse.json( + { error: '不能删除自己的账户' }, + { status: 400 } + ) + } + + await prisma.user.delete({ + where: { id }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Error deleting user:', error) + return NextResponse.json( + { error: 'Failed to delete user' }, + { status: 500 } + ) + } +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index a321eaa..b2078c0 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { usePathname } from 'next/navigation' import { cn } from '@/lib/utils' -import { Layers, Home, Settings, LogOut } from 'lucide-react' +import { Layers, Home, Settings, LogOut, Users } from 'lucide-react' import { signOut, useSession } from '@/lib/auth-client' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' @@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button' const navigation = [ { name: '首页', href: '/levels', icon: Home }, { name: '关卡配置', href: '/levels', icon: Layers }, + { name: '用户管理', href: '/users', icon: Users }, ] export function Sidebar() { diff --git a/components/levels/image-uploader.tsx b/components/levels/image-uploader.tsx index 857f2e0..be4b38b 100644 --- a/components/levels/image-uploader.tsx +++ b/components/levels/image-uploader.tsx @@ -2,9 +2,10 @@ import { useState, useRef } from 'react' import { Button } from '@/components/ui/button' -import { Upload, X, Image as ImageIcon } from 'lucide-react' +import { X, Image as ImageIcon } from 'lucide-react' import Image from 'next/image' import { Spinner } from '@/components/ui/spinner' +import COS from 'cos-js-sdk-v5' interface ImageUploaderProps { value: string @@ -47,49 +48,42 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) { const ext = file.name.split('.').pop() || 'jpg' const timestamp = Date.now() const randomStr = Math.random().toString(36).substring(2, 8) - const filename = `levels/${timestamp}_${randomStr}.${ext}` + const filename = `mini_game/images/${timestamp}_${randomStr}.${ext}` - // Upload to COS - const formData = new FormData() - formData.append('key', filename) - formData.append('Signature', keyData.credentials.sessionToken) - formData.append('success_action_status', '200') - - const uploadUrl = `https://${keyData.bucket}.cos.${keyData.region}.myqcloud.com` - - // Use XMLHttpRequest for COS upload with temp credentials - const uploadResult = await new Promise((resolve, reject) => { - const xhr = new XMLHttpRequest() - xhr.open('POST', uploadUrl) - - // Set temp credentials headers - xhr.setRequestHeader('Authorization', getCOSAuthorization( - keyData.credentials.tmpSecretId, - keyData.credentials.tmpSecretKey, - keyData.credentials.sessionToken, - 'post', - filename, - keyData.bucket, - keyData.region - )) - - xhr.onload = () => { - if (xhr.status === 200) { - resolve(`${uploadUrl}/${filename}`) - } else { - reject(new Error('上传失败')) - } - } - xhr.onerror = () => reject(new Error('上传失败')) - - const cosFormData = new FormData() - cosFormData.append('key', filename) - cosFormData.append('file', file) - - xhr.send(cosFormData) + // Initialize COS with temp credentials + const cos = new COS({ + getAuthorization: (_options, callback) => { + callback({ + TmpSecretId: keyData.credentials.tmpSecretId, + TmpSecretKey: keyData.credentials.tmpSecretKey, + SecurityToken: keyData.credentials.sessionToken, + StartTime: keyData.startTime, + ExpiredTime: keyData.expiredTime, + }) + }, }) - onChange(uploadResult) + // Upload file + const uploadUrl = await new Promise((resolve, reject) => { + cos.putObject( + { + Bucket: keyData.bucket, + Region: keyData.region, + Key: filename, + Body: file, + }, + (err, data) => { + if (err) { + reject(new Error(err.message || '上传失败')) + return + } + const url = `https://${keyData.bucket}.cos.${keyData.region}.myqcloud.com/${filename}` + resolve(url) + } + ) + }) + + onChange(uploadUrl) } catch (err) { console.error('Upload error:', err) setError(err instanceof Error ? err.message : '上传失败') @@ -162,21 +156,3 @@ export function ImageUploader({ value, onChange }: ImageUploaderProps) { ) } - -// Helper function to generate COS authorization header -function getCOSAuthorization( - secretId: string, - secretKey: string, - sessionToken: string, - method: string, - pathname: string, - bucket: string, - region: string -): string { - const now = Math.floor(Date.now() / 1000) - const exp = now + 1800 - const keyTime = `${now};${exp}` - - // Simple authorization string for temp credentials - return `q-sign-algorithm=sha1&q-ak=${secretId}&q-sign-time=${keyTime}&q-key-time=${keyTime}&q-header-list=&q-url-param-list=&q-signature=placeholder&x-cos-security-token=${sessionToken}` -} diff --git a/components/users/user-dialog.tsx b/components/users/user-dialog.tsx new file mode 100644 index 0000000..d44aa4e --- /dev/null +++ b/components/users/user-dialog.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useState, useEffect } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Spinner } from '@/components/ui/spinner' +import { User, UserFormData } from '@/types' + +interface UserDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + user?: User | null + onSubmit: (data: UserFormData) => Promise +} + +const defaultFormData: UserFormData = { + email: '', + password: '', + name: '', +} + +export function UserDialog({ open, onOpenChange, user, onSubmit }: UserDialogProps) { + const [formData, setFormData] = useState(defaultFormData) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState('') + + // Reset form when dialog opens/closes or user changes + useEffect(() => { + if (open) { + if (user) { + setFormData({ + email: user.email, + password: '', + name: user.name || '', + }) + } else { + setFormData(defaultFormData) + } + setError('') + } + }, [open, user]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + + if (!formData.email.trim()) { + setError('请输入邮箱') + return + } + + if (!user && !formData.password) { + setError('请输入密码') + return + } + + if (formData.password && formData.password.length < 6) { + setError('密码至少需要6个字符') + return + } + + setIsLoading(true) + try { + await onSubmit(formData) + onOpenChange(false) + } catch (err) { + setError(err instanceof Error ? err.message : '操作失败,请稍后重试') + } finally { + setIsLoading(false) + } + } + + return ( + + + + {user ? '编辑用户' : '添加用户'} + + {user ? '修改用户信息,密码留空则不修改' : '创建新的平台用户'} + + +
+ {error && ( +
+ {error} +
+ )} + +
+ + + setFormData((prev) => ({ ...prev, email: e.target.value })) + } + placeholder="请输入邮箱" + required + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, password: e.target.value })) + } + placeholder={user ? '留空则不修改密码' : '请输入密码'} + required={!user} + /> +
+ +
+ + + setFormData((prev) => ({ ...prev, name: e.target.value })) + } + placeholder="请输入姓名" + /> +
+ + + + + +
+
+
+ ) +} diff --git a/lib/cos.ts b/lib/cos.ts index 72b5cb0..9ff57cb 100644 --- a/lib/cos.ts +++ b/lib/cos.ts @@ -26,7 +26,7 @@ export async function getTempKey(): Promise { const region = process.env.COS_REGION || 'ap-guangzhou' const appid = process.env.COS_APPID || '' - // Define the policy for upload permissions + // Define the policy for upload permissions (limited to mini_game/images/*) const policy = { version: '2.0', statement: [ @@ -38,7 +38,7 @@ export async function getTempKey(): Promise { effect: 'allow', principal: { qcs: ['qcs::cam::anyone:anyone'] }, resource: [ - `qcs::cos:${region}:uid/${appid}:${bucket}/*`, + `qcs::cos:${region}:uid/${appid}:${bucket}/mini_game/images/*`, ], }, ], diff --git a/package-lock.json b/package-lock.json index 02bb65d..d37ba4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "better-auth": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cos-js-sdk-v5": "^1.10.1", "cos-nodejs-sdk-v5": "^2.14.0", "lucide-react": "^0.483.0", "next": "14.2.28", @@ -3420,6 +3421,38 @@ "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", "license": "MIT" }, + "node_modules/cos-js-sdk-v5": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/cos-js-sdk-v5/-/cos-js-sdk-v5-1.10.1.tgz", + "integrity": "sha512-a4SRfCY5g6Z35C7OWe9te/S1zk77rVQzfpvZ33gmTdJQzKxbNbEG7Aw/v453XwVMsQB352FIf7KRMm5Ya/wlZQ==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "fast-xml-parser": "4.5.0" + } + }, + "node_modules/cos-js-sdk-v5/node_modules/fast-xml-parser": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz", + "integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + }, + { + "type": "paypal", + "url": "https://paypal.me/naturalintelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^1.0.5" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/cos-nodejs-sdk-v5": { "version": "2.15.4", "resolved": "https://registry.npmjs.org/cos-nodejs-sdk-v5/-/cos-nodejs-sdk-v5-2.15.4.tgz", diff --git a/package.json b/package.json index 0d02b2b..3f986fc 100644 --- a/package.json +++ b/package.json @@ -14,29 +14,30 @@ "db:seed": "tsx prisma/seed.ts" }, "dependencies": { - "next": "14.2.28", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "@prisma/client": "^6.5.0", - "better-auth": "^1.2.7", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@tanstack/react-query": "^5.69.0", - "react-hook-form": "^7.54.2", "@hookform/resolvers": "^4.1.3", - "zod": "^3.24.2", - "cos-nodejs-sdk-v5": "^2.14.0", - "uuid": "^11.1.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "tailwind-merge": "^3.0.2", - "lucide-react": "^0.483.0", + "@prisma/client": "^6.5.0", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", "@radix-ui/react-slot": "^1.1.2", + "@tanstack/react-query": "^5.69.0", "bcryptjs": "^3.0.2", - "qcloud-cos-sts": "^3.1.1" + "better-auth": "^1.2.7", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cos-js-sdk-v5": "^1.10.1", + "cos-nodejs-sdk-v5": "^2.14.0", + "lucide-react": "^0.483.0", + "next": "14.2.28", + "qcloud-cos-sts": "^3.1.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-hook-form": "^7.54.2", + "tailwind-merge": "^3.0.2", + "uuid": "^11.1.0", + "zod": "^3.24.2" }, "devDependencies": { "@types/bcryptjs": "^3.0.0", @@ -44,13 +45,13 @@ "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", "@types/uuid": "^10.0.0", - "typescript": "^5.8.2", - "prisma": "^6.5.0", - "tsx": "^4.19.3", + "autoprefixer": "^10.4.21", "eslint": "^8.57.0", "eslint-config-next": "14.2.28", - "tailwindcss": "^3.4.17", "postcss": "^8.5.3", - "autoprefixer": "^10.4.21" + "prisma": "^6.5.0", + "tailwindcss": "^3.4.17", + "tsx": "^4.19.3", + "typescript": "^5.8.2" } } diff --git a/types/index.ts b/types/index.ts index f667285..48eaed6 100644 --- a/types/index.ts +++ b/types/index.ts @@ -21,3 +21,19 @@ export interface LevelFormData { export interface ReorderRequest { orders: { id: string; sortOrder: number }[] } + +export interface User { + id: string + email: string + emailVerified: boolean + name: string | null + image: string | null + createdAt: Date + updatedAt: Date +} + +export interface UserFormData { + email: string + password: string + name?: string +}