From f8f6f17bd44f90a261dcb10d8570f1f3adaffdd7 Mon Sep 17 00:00:00 2001 From: richarjiang Date: Sun, 5 Apr 2026 20:19:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 24 +++ app/(dashboard)/wx-users/page.tsx | 195 ++++++++++++++++++ app/api/wx-users/[id]/route.ts | 55 +++++ app/api/wx-users/route.ts | 68 ++++++ components/layout/sidebar.tsx | 3 +- components/wx-users/wx-user-detail-dialog.tsx | 109 ++++++++++ prisma/schema.prisma | 29 +++ types/index.ts | 26 +++ 8 files changed, 508 insertions(+), 1 deletion(-) create mode 100644 .env create mode 100644 app/(dashboard)/wx-users/page.tsx create mode 100644 app/api/wx-users/[id]/route.ts create mode 100644 app/api/wx-users/route.ts create mode 100644 components/wx-users/wx-user-detail-dialog.tsx diff --git a/.env b/.env new file mode 100644 index 0000000..b580388 --- /dev/null +++ b/.env @@ -0,0 +1,24 @@ +# Database (MySQL) - Production +DATABASE_URL="mysql://root:MemeMind@2026@127.0.0.1:13306/mememind" + +# Better Auth +BETTER_AUTH_SECRET="WU+pYbaBiDkBEzQBrdhsWAuZGmEX6mSFi9Lyw0C3BaI=" +BETTER_AUTH_URL="https://ilookai.cn" + +# Public URLs (for client-side) +NEXT_PUBLIC_APP_URL="https://ilookai.cn" +NEXT_PUBLIC_BASE_PATH="/studio" + +# Admin Account +ADMIN_EMAIL="richardwei1995@gmail.com" +ADMIN_PASSWORD="richard_123456" + +# Tencent COS +COS_SECRET_ID=AKIDTs7B2f5NVSFqIYaP1QbZQE0bAuc9h4EB +COS_SECRET_KEY=pRs6yiHcNyVs21pRq11nOlupY4OVPOH1 +COS_BUCKET=lookai-1308511832 +COS_REGION=ap-guangzhou +COS_APPID=1308511832 + +# Server Port +PORT=3001 diff --git a/app/(dashboard)/wx-users/page.tsx b/app/(dashboard)/wx-users/page.tsx new file mode 100644 index 0000000..c8138f2 --- /dev/null +++ b/app/(dashboard)/wx-users/page.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Header } from '@/components/layout/header' +import { WxUserDetailDialog } from '@/components/wx-users/wx-user-detail-dialog' +import { Spinner } from '@/components/ui/spinner' +import { WxUser, WxUserWithProgress } from '@/types' +import { apiFetch } from '@/lib/api' +import { Search } from 'lucide-react' + +interface UsersResponse { + users: WxUser[] + meta: { total: number; page: number; limit: number; totalPages: number } +} + +export default function WxUsersPage() { + const [search, setSearch] = useState('') + const [selectedUser, setSelectedUser] = useState(null) + const [isDialogOpen, setIsDialogOpen] = useState(false) + + const { data, isLoading, error } = useQuery({ + queryKey: ['wx-users', search], + queryFn: async () => { + const res = await apiFetch(`/api/wx-users?search=${encodeURIComponent(search)}`) + if (!res.ok) throw new Error('Failed to fetch wx users') + return res.json() + }, + }) + + const { data: userDetails } = useQuery({ + queryKey: ['wx-users', selectedUser?.id], + queryFn: async () => { + const res = await apiFetch(`/api/wx-users/${selectedUser?.id}`) + if (!res.ok) throw new Error('Failed to fetch wx user details') + return res.json() + }, + enabled: !!selectedUser && isDialogOpen, + }) + + const handleUserClick = (user: WxUser) => { + setSelectedUser(user) + setIsDialogOpen(true) + } + + const handleDialogOpenChange = (open: boolean) => { + setIsDialogOpen(open) + if (!open) { + setSelectedUser(null) + } + } + + 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 ( +
+
+
+
+
+
+

小程序账户

+

+ 共 {data?.meta?.total || 0} 个账户 +

+
+
+ +
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+ +
+ + + + + + + + + + + {data?.users?.map((user) => ( + handleUserClick(user)} + > + + + + + + ))} + {data?.users?.length === 0 && ( + + + + )} + +
+ 用户 + + OpenID + + 积分 + + 注册时间 +
+
+ {user.avatarUrl ? ( + {user.nickname + ) : ( +
+ {user.nickname?.[0]?.toUpperCase() || 'U'} +
+ )} + + {user.nickname || '匿名用户'} + +
+
+ + {user.openid} + + + + {user.points} + + + {formatDate(user.createdAt)} +
+ 暂无账户数据 +
+
+
+
+ + +
+ ) +} diff --git a/app/api/wx-users/[id]/route.ts b/app/api/wx-users/[id]/route.ts new file mode 100644 index 0000000..ddfd923 --- /dev/null +++ b/app/api/wx-users/[id]/route.ts @@ -0,0 +1,55 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { auth } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +// GET /api/wx-users/[id] - Get single wx user with level progress +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth.api.getSession({ + headers: request.headers, + }) + + if (!session) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const { id } = await params + + const user = await prisma.wxUser.findUnique({ + where: { id }, + include: { + levelProgress: { + orderBy: { completedAt: 'desc' }, + include: { + level: { + select: { + id: true, + answer: true, + }, + }, + }, + }, + }, + }) + + if (!user) { + return NextResponse.json( + { error: 'User not found' }, + { status: 404 } + ) + } + + return NextResponse.json(user) + } catch (error) { + console.error('Error fetching wx user:', error) + return NextResponse.json( + { error: 'Failed to fetch wx user' }, + { status: 500 } + ) + } +} diff --git a/app/api/wx-users/route.ts b/app/api/wx-users/route.ts new file mode 100644 index 0000000..e033714 --- /dev/null +++ b/app/api/wx-users/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server' +import { prisma } from '@/lib/prisma' +import { auth } from '@/lib/auth' + +export const dynamic = 'force-dynamic' + +// GET /api/wx-users - Get all wx users with optional search and pagination +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 { searchParams } = new URL(request.url) + const search = searchParams.get('search') || '' + const page = parseInt(searchParams.get('page') || '1', 10) + const limit = parseInt(searchParams.get('limit') || '20', 10) + const skip = (page - 1) * limit + + const where = search + ? { + OR: [ + { nickname: { contains: search } }, + { openid: { contains: search } }, + ], + } + : {} + + const [users, total] = await Promise.all([ + prisma.wxUser.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + openid: true, + nickname: true, + avatarUrl: true, + points: true, + createdAt: true, + updatedAt: true, + }, + }), + prisma.wxUser.count({ where }), + ]) + + return NextResponse.json({ + users, + meta: { + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }, + }) + } catch (error) { + console.error('Error fetching wx users:', error) + return NextResponse.json( + { error: 'Failed to fetch wx users' }, + { status: 500 } + ) + } +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index b2078c0..b21cd38 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, Users } from 'lucide-react' +import { Layers, Home, Settings, LogOut, Users, UserCircle } from 'lucide-react' import { signOut, useSession } from '@/lib/auth-client' import { useRouter } from 'next/navigation' import { Button } from '@/components/ui/button' @@ -12,6 +12,7 @@ const navigation = [ { name: '首页', href: '/levels', icon: Home }, { name: '关卡配置', href: '/levels', icon: Layers }, { name: '用户管理', href: '/users', icon: Users }, + { name: '小程序账户', href: '/wx-users', icon: UserCircle }, ] export function Sidebar() { diff --git a/components/wx-users/wx-user-detail-dialog.tsx b/components/wx-users/wx-user-detail-dialog.tsx new file mode 100644 index 0000000..7ef675c --- /dev/null +++ b/components/wx-users/wx-user-detail-dialog.tsx @@ -0,0 +1,109 @@ +'use client' + +import { WxUserWithProgress } from '@/types' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog' + +interface WxUserDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + user: WxUserWithProgress | null | undefined +} + +export function WxUserDetailDialog({ + open, + onOpenChange, + user, +}: WxUserDetailDialogProps) { + if (!user) return null + + return ( + + + + 用户详情 + + 完整信息及关卡进度 + + + +
+
+
+ {user.avatarUrl ? ( + {user.nickname + ) : ( +
+ {user.nickname?.[0]?.toUpperCase() || 'U'} +
+ )} +
+
+

+ {user.nickname || '匿名用户'} +

+

+ 注册时间:{new Date(user.createdAt).toLocaleDateString('zh-CN')} +

+
+
+ +
+
+

积分

+

{user.points}

+
+
+

已通关关卡

+

{user.levelProgress.length}

+
+
+ +
+

OpenID

+ + {user.openid} + +
+ + {user.levelProgress.length > 0 && ( +
+

关卡进度

+
+ {user.levelProgress.map((progress) => ( +
+
+ + {progress.levelId} + + {progress.level && ( + + {progress.level.answer} + + )} +
+ + {new Date(progress.completedAt).toLocaleDateString('zh-CN')} + +
+ ))} +
+
+ )} +
+
+
+ ) +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 292dde0..817bf1c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,6 +22,8 @@ model Level { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + userProgress WxUserLevelProgress[] + @@map("levels") } @@ -85,3 +87,30 @@ model Verification { @@map("verifications") } + +model WxUser { + id String @id @default(uuid()) + openid String @unique + sessionKey String? @map("session_key") + nickname String? + avatarUrl String? @map("avatar_url") + points Int @default(10) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + levelProgress WxUserLevelProgress[] + + @@map("wx_users") +} + +model WxUserLevelProgress { + id String @id @default(uuid()) + userId String @map("user_id") + levelId String @map("level_id") + completedAt DateTime @default(now()) @map("completed_at") + + user WxUser @relation(fields: [userId], references: [id], onDelete: Cascade) + level Level @relation(fields: [levelId], references: [id]) + + @@map("wx_user_level_progress") +} diff --git a/types/index.ts b/types/index.ts index 48eaed6..b2e1d6e 100644 --- a/types/index.ts +++ b/types/index.ts @@ -37,3 +37,29 @@ export interface UserFormData { password: string name?: string } + +export interface WxUser { + id: string + openid: string + sessionKey: string | null + nickname: string | null + avatarUrl: string | null + points: number + createdAt: Date + updatedAt: Date +} + +export interface WxUserLevelProgress { + id: string + userId: string + levelId: string + completedAt: Date + level?: { + id: string + answer: string + } +} + +export interface WxUserWithProgress extends WxUser { + levelProgress: WxUserLevelProgress[] +}