feat: 实现 Phase 2 — 用户详情页、反馈管理、订阅管理、CSV 导出

Phase 2a: 用户详情页(资料卡片、游戏统计、答题历史、章节进度)
Phase 2b: 反馈管理页面(类型/状态筛选、详情弹窗、状态变更)
Phase 2c: 订阅等级管理(等级变更对话框、权益对比)
Phase 2d: CSV 导出(用户/反馈/题目列表,Excel 兼容 BOM)
This commit is contained in:
Wang Zhuoxuan 2026-04-08 12:29:06 +08:00
parent 7efcf97ef4
commit fbc8bbb04d
21 changed files with 1396 additions and 6 deletions

View File

@ -8,6 +8,8 @@ import EditQuestionPage from "@/routes/questions/$id.edit"
import CategoriesPage from "@/routes/categories"
import SkillTreePage from "@/routes/skill-tree"
import UsersPage from "@/routes/users"
import UserDetailPage from "@/routes/users/$id"
import FeedbackPage from "@/routes/feedback"
import SettingsPage from "@/routes/settings"
const router = createBrowserRouter([
@ -30,7 +32,14 @@ const router = createBrowserRouter([
},
{ path: "categories", Component: CategoriesPage },
{ path: "skill-tree", Component: SkillTreePage },
{ path: "users", Component: UsersPage },
{ path: "feedback", Component: FeedbackPage },
{
path: "users",
children: [
{ index: true, Component: UsersPage },
{ path: ":id", Component: UserDetailPage },
],
},
{ path: "settings", Component: SettingsPage },
],
},

View File

@ -0,0 +1,115 @@
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
FEEDBACK_STATUS_LABELS,
FEEDBACK_TYPE_LABELS,
FEEDBACK_RATING_LABELS,
} from "@/lib/constants"
import type { Feedback, FeedbackStatus } from "@/types/feedback"
const STATUS_VARIANTS: Record<FeedbackStatus, "secondary" | "default" | "destructive"> = {
pending: "destructive",
read: "secondary",
resolved: "default",
}
interface FeedbackDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
feedback: Feedback | null
onStatusChange: (id: string, status: FeedbackStatus) => Promise<void>
}
export function FeedbackDetailDialog({
open,
onOpenChange,
feedback,
onStatusChange,
}: FeedbackDetailDialogProps) {
if (!feedback) return null
const isPending = feedback.status === "pending"
const isResolved = feedback.status === "resolved"
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 基本信息 */}
<div className="flex items-center gap-2">
<Badge variant="outline">{FEEDBACK_TYPE_LABELS[feedback.type]}</Badge>
<Badge variant={STATUS_VARIANTS[feedback.status]}>
{FEEDBACK_STATUS_LABELS[feedback.status]}
</Badge>
</div>
{/* 用户 */}
<div className="text-sm">
<span className="text-muted-foreground"></span>
{feedback.userNickname || "匿名"}
{feedback.contact && (
<span className="ml-2 text-muted-foreground">({feedback.contact})</span>
)}
</div>
{/* 内容 */}
{feedback.type === "quiz_rating" ? (
<div className="space-y-2">
<div className="text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium">
{FEEDBACK_RATING_LABELS[feedback.rating ?? ""] ?? feedback.rating}
</span>
</div>
{feedback.questionStem && (
<div className="rounded-md bg-muted p-3">
<p className="text-sm">{feedback.questionStem}</p>
</div>
)}
</div>
) : (
<div className="rounded-md bg-muted p-3">
<p className="whitespace-pre-wrap text-sm">{feedback.content ?? "(无内容)"}</p>
</div>
)}
{/* 时间 */}
<div className="text-xs text-muted-foreground">
{new Date(feedback.createdAt).toLocaleString("zh-CN")}
</div>
{/* 操作按钮 */}
{!isResolved && (
<div className="flex justify-end gap-2">
{isPending && (
<Button
size="sm"
variant="outline"
onClick={() => onStatusChange(feedback.id, "read")}
>
</Button>
)}
<Button
size="sm"
onClick={() => onStatusChange(feedback.id, "resolved")}
>
</Button>
</div>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,116 @@
import type { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import {
FEEDBACK_STATUS_LABELS,
FEEDBACK_TYPE_LABELS,
FEEDBACK_RATING_LABELS,
} from "@/lib/constants"
import type { Feedback, FeedbackStatus } from "@/types/feedback"
const STATUS_VARIANTS: Record<FeedbackStatus, "secondary" | "default" | "destructive"> = {
pending: "destructive",
read: "secondary",
resolved: "default",
}
interface ColumnContext {
onMarkRead: (fb: Feedback) => void
onMarkResolved: (fb: Feedback) => void
onViewDetail: (fb: Feedback) => void
}
export function getColumns(ctx: ColumnContext): ColumnDef<Feedback>[] {
return [
{
accessorKey: "type",
header: "类型",
cell: ({ row }) => {
const type = row.getValue("type") as string
return (
<Badge variant="outline">{FEEDBACK_TYPE_LABELS[type as keyof typeof FEEDBACK_TYPE_LABELS] ?? type}</Badge>
)
},
},
{
id: "content",
header: "内容",
cell: ({ row }) => {
const fb = row.original
if (fb.type === "quiz_rating") {
return (
<div className="max-w-xs">
<span className="text-sm">{FEEDBACK_RATING_LABELS[fb.rating ?? ""] ?? fb.rating}</span>
{fb.questionStem && (
<p className="line-clamp-1 text-xs text-muted-foreground">{fb.questionStem}</p>
)}
</div>
)
}
return (
<span className="line-clamp-2 max-w-xs text-sm">
{fb.content ?? "—"}
</span>
)
},
},
{
accessorKey: "userNickname",
header: "用户",
cell: ({ row }) => (
<span className="text-muted-foreground">
{(row.getValue("userNickname") as string) || "匿名"}
</span>
),
},
{
accessorKey: "status",
header: "状态",
cell: ({ row }) => {
const status = row.getValue("status") as FeedbackStatus
return <Badge variant={STATUS_VARIANTS[status]}>{FEEDBACK_STATUS_LABELS[status]}</Badge>
},
},
{
accessorKey: "createdAt",
header: "提交时间",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{new Date(row.getValue("createdAt") as string).toLocaleString("zh-CN")}
</span>
),
},
{
id: "actions",
header: "",
cell: ({ row }) => {
const fb = row.original
return (
<div className="flex items-center gap-1">
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => ctx.onViewDetail(fb)}
>
</button>
{fb.status === "pending" && (
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => ctx.onMarkRead(fb)}
>
</button>
)}
{fb.status !== "resolved" && (
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => ctx.onMarkResolved(fb)}
>
</button>
)}
</div>
)
},
},
]
}

View File

@ -5,6 +5,7 @@ import {
FolderOpen,
TreePine,
Users,
MessageSquare,
Settings,
LogOut,
} from "lucide-react"
@ -17,6 +18,7 @@ const navItems = [
{ to: "/categories", label: "分类管理", icon: FolderOpen },
{ to: "/skill-tree", label: "技能树", icon: TreePine },
{ to: "/users", label: "用户管理", icon: Users },
{ to: "/feedback", label: "用户反馈", icon: MessageSquare },
{ to: "/settings", label: "系统设置", icon: Settings },
]

View File

@ -0,0 +1,89 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }

View File

@ -0,0 +1,159 @@
import { useCallback, useEffect, useState } from "react"
import {
useReactTable,
getCoreRowModel,
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { fetchUserProgress } from "@/lib/api/user-api"
import type { UserProgress } from "@/types/user-detail"
const PAGE_SIZE = 20
function getColumns(): ColumnDef<UserProgress>[] {
return [
{
accessorKey: "questionStem",
header: "题目",
cell: ({ row }) => {
const stem = row.getValue("questionStem") as string
return (
<span className="line-clamp-2 max-w-xs">
{stem.length > 60 ? stem.slice(0, 60) + "..." : stem}
</span>
)
},
},
{
accessorKey: "correct",
header: "结果",
cell: ({ row }) => {
const correct = row.getValue("correct") as boolean
return (
<Badge variant={correct ? "default" : "destructive"}>
{correct ? "正确" : "错误"}
</Badge>
)
},
},
{
accessorKey: "timeMs",
header: "用时",
cell: ({ row }) => {
const ms = row.getValue("timeMs") as number
return <span className="text-muted-foreground">{(ms / 1000).toFixed(1)}s</span>
},
},
{
accessorKey: "answeredAt",
header: "答题时间",
cell: ({ row }) => (
<span className="text-muted-foreground text-xs">
{new Date(row.getValue("answeredAt") as string).toLocaleString("zh-CN")}
</span>
),
},
]
}
interface AnswerHistoryTableProps {
userId: string
}
export function AnswerHistoryTable({ userId }: AnswerHistoryTableProps) {
const [data, setData] = useState<UserProgress[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const loadData = useCallback(async () => {
setLoading(true)
try {
const res = await fetchUserProgress(userId, { page, limit: PAGE_SIZE })
setData(res.data)
setTotal(res.pagination.total)
} catch {
setData([])
} finally {
setLoading(false)
}
}, [userId, page])
useEffect(() => {
loadData()
}, [loadData])
const columns = getColumns()
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
return (
<div className="space-y-4">
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-20 text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-20 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span> {page}/{totalPages} </span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
</Button>
</div>
</div>
)}
</div>
)
}

View File

@ -0,0 +1,54 @@
import { Badge } from "@/components/ui/badge"
import type { ChapterProgress, ChapterStatus } from "@/types/user-detail"
const STATUS_LABELS: Record<ChapterStatus, string> = {
locked: "未解锁",
unlocked: "已解锁",
passed: "已通过",
perfect: "完美",
}
const STATUS_VARIANTS: Record<ChapterStatus, "secondary" | "outline" | "default" | "destructive"> = {
locked: "secondary",
unlocked: "outline",
passed: "default",
perfect: "destructive",
}
interface ChapterProgressListProps {
chapters: ChapterProgress[]
}
export function ChapterProgressList({ chapters }: ChapterProgressListProps) {
if (chapters.length === 0) {
return <div className="py-8 text-center text-muted-foreground"></div>
}
return (
<div className="space-y-2">
{chapters.map((ch) => (
<div
key={ch.chapterId}
className="flex items-center justify-between rounded-lg border px-4 py-3"
>
<div className="flex items-center gap-3">
<Badge variant={STATUS_VARIANTS[ch.status]}>{STATUS_LABELS[ch.status]}</Badge>
<span className="font-medium">{ch.chapterTitle}</span>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>
{ch.bestCorrectCount}/{ch.totalQuestions}
</span>
<span> {ch.attempts} </span>
{ch.completedAt && (
<span className="text-xs">
{new Date(ch.completedAt).toLocaleDateString("zh-CN")}
</span>
)}
</div>
</div>
))}
</div>
)
}

View File

@ -0,0 +1,57 @@
import { Zap, Flame, Heart, Target, Clock, Trophy } from "lucide-react"
import { StatsCard } from "@/components/charts/StatsCard"
import type { UserDetail } from "@/types/user-detail"
interface GameStatsGridProps {
user: UserDetail
}
export function GameStatsGrid({ user }: GameStatsGridProps) {
const stats = [
{
title: "总 XP",
value: user.xpTotal.toLocaleString(),
description: `今日 ${user.dailyXpEarned}/${user.dailyXpGoal}`,
icon: Zap,
},
{
title: "连续天数",
value: `${user.streakDays}`,
icon: Flame,
},
{
title: "红心",
value: user.heartsRemaining,
icon: Heart,
},
{
title: "答题总数",
value: user.totalAnswered.toLocaleString(),
icon: Target,
},
{
title: "正确率",
value: `${(user.correctRate * 100).toFixed(1)}%`,
icon: Trophy,
},
{
title: "平均用时",
value: `${(user.avgTimeMs / 1000).toFixed(1)}s`,
icon: Clock,
},
]
return (
<div className="grid grid-cols-2 gap-4 md:grid-cols-3">
{stats.map((stat) => (
<StatsCard
key={stat.title}
title={stat.title}
value={stat.value}
description={stat.description}
icon={stat.icon}
/>
))}
</div>
)
}

View File

@ -0,0 +1,112 @@
import { useState } from "react"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { TIER_LABELS, TIER_DESCRIPTIONS } from "@/lib/constants"
import type { User, UserTier } from "@/types/user"
const TIER_VARIANTS: Record<UserTier, "secondary" | "default" | "destructive"> = {
free: "secondary",
pro: "default",
proplus: "destructive",
}
const ALL_TIERS: UserTier[] = ["free", "pro", "proplus"]
interface TierChangeDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
user: User
onConfirm: (tier: UserTier) => Promise<void>
}
export function TierChangeDialog({
open,
onOpenChange,
user,
onConfirm,
}: TierChangeDialogProps) {
const [selected, setSelected] = useState<UserTier>(user.tier)
const [submitting, setSubmitting] = useState(false)
const changed = selected !== user.tier
const handleConfirm = async () => {
if (!changed) return
setSubmitting(true)
try {
await onConfirm(selected)
onOpenChange(false)
} finally {
setSubmitting(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 当前等级 */}
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Badge variant={TIER_VARIANTS[user.tier]}>{TIER_LABELS[user.tier]}</Badge>
</div>
{/* 新等级选择 */}
<div className="space-y-2">
<span className="text-sm text-muted-foreground"></span>
<Select value={selected} onValueChange={(v) => setSelected(v as UserTier)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{ALL_TIERS.map((tier) => (
<SelectItem key={tier} value={tier}>
{TIER_LABELS[tier]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 权益变更提示 */}
{changed && (
<div className="rounded-md border bg-muted/50 p-3 space-y-1.5">
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground">
{TIER_LABELS[user.tier]} {TIER_LABELS[selected]}
</p>
<p className="text-xs text-muted-foreground">{TIER_DESCRIPTIONS[selected]}</p>
</div>
)}
{/* 操作 */}
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={submitting}>
</Button>
<Button onClick={handleConfirm} disabled={!changed || submitting}>
{submitting ? "提交中..." : "确认更改"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}

View File

@ -0,0 +1,58 @@
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import type { UserDetail } from "@/types/user-detail"
import type { UserTier } from "@/types/user"
const TIER_LABELS: Record<UserTier, string> = {
free: "免费",
pro: "Pro",
proplus: "Pro+",
}
const TIER_VARIANTS: Record<UserTier, "secondary" | "default" | "destructive"> = {
free: "secondary",
pro: "default",
proplus: "destructive",
}
const AUTH_TYPE_LABELS: Record<string, string> = {
huawei: "华为",
guest: "游客",
phone: "手机",
apple: "Apple",
google: "Google",
}
interface UserProfileCardProps {
user: UserDetail
}
export function UserProfileCard({ user }: UserProfileCardProps) {
return (
<Card>
<CardContent className="flex items-center gap-6 pt-6">
{/* 头像 */}
<div className="flex size-16 items-center justify-center rounded-full bg-muted text-2xl font-bold text-muted-foreground">
{(user.nickname ?? "?").slice(0, 1).toUpperCase()}
</div>
{/* 信息 */}
<div className="flex flex-1 flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{user.nickname || "未设置昵称"}
</span>
<Badge variant={TIER_VARIANTS[user.tier]}>{TIER_LABELS[user.tier]}</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{AUTH_TYPE_LABELS[user.authType] ?? user.authType}</span>
<span> {new Date(user.createdAt).toLocaleDateString("zh-CN")}</span>
{user.streakLastDate && (
<span> {new Date(user.streakLastDate).toLocaleDateString("zh-CN")}</span>
)}
</div>
</div>
</CardContent>
</Card>
)
}

View File

@ -1,4 +1,5 @@
import type { ColumnDef } from "@tanstack/react-table"
import { Link } from "react-router"
import { Badge } from "@/components/ui/badge"
import type { User, UserTier } from "@/types/user"
@ -29,10 +30,11 @@ export function getColumns(): ColumnDef<User>[] {
header: "昵称",
cell: ({ row }) => {
const nickname = row.getValue("nickname") as string | null
const userId = row.original.id
return (
<span className="font-medium">
<Link to={`/users/${userId}`} className="font-medium hover:underline">
{nickname || <span className="text-muted-foreground"></span>}
</span>
</Link>
)
},
},

View File

@ -0,0 +1,33 @@
import { apiClient } from "@/lib/api-client"
import type { ApiResponse, PaginatedResponse } from "@/types/api"
import type { Feedback, FeedbackStatus, FeedbackType } from "@/types/feedback"
export interface FetchFeedbackParams {
page?: number
limit?: number
type?: FeedbackType
status?: FeedbackStatus
}
export async function fetchFeedback(
params: FetchFeedbackParams = {}
): Promise<PaginatedResponse<Feedback>> {
const searchParams = new URLSearchParams()
if (params.page) searchParams.set("page", String(params.page))
if (params.limit) searchParams.set("limit", String(params.limit))
if (params.type) searchParams.set("type", params.type)
if (params.status) searchParams.set("status", params.status)
return apiClient
.get("feedback", { searchParams })
.json<PaginatedResponse<Feedback>>()
}
export async function updateFeedbackStatus(
id: string,
status: FeedbackStatus
): Promise<ApiResponse<Feedback>> {
return apiClient
.patch(`feedback/${id}`, { json: { status } })
.json<ApiResponse<Feedback>>()
}

View File

@ -1,6 +1,7 @@
import { apiClient } from "@/lib/api-client"
import type { PaginatedResponse, ApiResponse } from "@/types/api"
import type { User, UserTier } from "@/types/user"
import type { UserDetail, UserProgress, ChapterProgress } from "@/types/user-detail"
export interface FetchUsersParams {
page?: number
@ -26,3 +27,30 @@ export async function fetchUsers(
export async function fetchUser(id: string): Promise<ApiResponse<User>> {
return apiClient.get(`users/${id}`).json<ApiResponse<User>>()
}
export async function fetchUserDetail(id: string): Promise<ApiResponse<UserDetail>> {
return apiClient.get(`users/${id}`).json<ApiResponse<UserDetail>>()
}
export async function updateUserTier(id: string, tier: UserTier): Promise<ApiResponse<User>> {
return apiClient.patch(`users/${id}`, { json: { tier } }).json<ApiResponse<User>>()
}
export async function fetchUserProgress(
id: string,
params: { page?: number; limit?: number } = {}
): Promise<PaginatedResponse<UserProgress>> {
const searchParams = new URLSearchParams()
if (params.page) searchParams.set("page", String(params.page))
if (params.limit) searchParams.set("limit", String(params.limit))
return apiClient
.get(`users/${id}/progress`, { searchParams })
.json<PaginatedResponse<UserProgress>>()
}
export async function fetchUserChapterProgress(
id: string
): Promise<ApiResponse<ChapterProgress[]>> {
return apiClient.get(`users/${id}/chapters`).json<ApiResponse<ChapterProgress[]>>()
}

View File

@ -1,5 +1,7 @@
import type { CategoryStatus } from "@/types/category"
import type { FeedbackStatus, FeedbackType } from "@/types/feedback"
import type { QuestionStatus } from "@/types/question"
import type { UserTier } from "@/types/user"
export const QUESTION_STATUSES = {
draft: "草稿",
@ -31,3 +33,31 @@ export const CATEGORY_STATUS_LABELS: Record<CategoryStatus, string> = {
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000"
export const AUTH_STORAGE_KEY = "duoqi_admin_jwt"
export const FEEDBACK_STATUS_LABELS: Record<FeedbackStatus, string> = {
pending: "待处理",
read: "已读",
resolved: "已处理",
}
export const FEEDBACK_TYPE_LABELS: Record<FeedbackType, string> = {
quiz_rating: "题目评价",
text_feedback: "意见反馈",
}
export const FEEDBACK_RATING_LABELS: Record<string, string> = {
good: "👍 有趣",
bad: "👎 一般",
}
export const TIER_LABELS: Record<UserTier, string> = {
free: "免费",
pro: "Pro",
proplus: "Pro+",
}
export const TIER_DESCRIPTIONS: Record<UserTier, string> = {
free: "完整答题内容 + 5 颗红心/天 + 可选广告",
pro: "无广告 + 无限红心 + 深度知识卡 + 连胜自动修复",
proplus: "Pro 全部权益 + 专属题包 + 吉祥物皮肤 + AI 知识问答",
}

35
src/lib/csv-export.ts Normal file
View File

@ -0,0 +1,35 @@
/**
* CSV
*
* @param filename "users.csv"
* @param columns key label
* @param data
*/
export function exportToCsv<T extends Record<string, unknown>>(
filename: string,
columns: ReadonlyArray<{ key: keyof T & string; label: string }>,
data: ReadonlyArray<T>
): void {
const escape = (value: unknown): string => {
const str = value === null || value === undefined ? "" : String(value)
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
const header = columns.map((col) => escape(col.label)).join(",")
const rows = data.map((row) =>
columns.map((col) => escape(row[col.key])).join(",")
)
const csv = [header, ...rows].join("\n")
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" })
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
}

View File

@ -0,0 +1,235 @@
import { useCallback, useEffect, useState } from "react"
import {
useReactTable,
getCoreRowModel,
flexRender,
} from "@tanstack/react-table"
import { Download } from "lucide-react"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Button } from "@/components/ui/button"
import { FeedbackDetailDialog } from "@/components/feedback/FeedbackDetailDialog"
import { getColumns } from "@/components/feedback/columns"
import { fetchFeedback, updateFeedbackStatus } from "@/lib/api/feedback-api"
import { exportToCsv } from "@/lib/csv-export"
import { FEEDBACK_STATUS_LABELS, FEEDBACK_TYPE_LABELS } from "@/lib/constants"
import type { Feedback, FeedbackStatus, FeedbackType } from "@/types/feedback"
const PAGE_SIZE = 20
export default function FeedbackPage() {
const [data, setData] = useState<Feedback[]>([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [typeFilter, setTypeFilter] = useState<FeedbackType | "all">("all")
const [statusFilter, setStatusFilter] = useState<FeedbackStatus | "all">("all")
// 详情弹窗
const [detailFeedback, setDetailFeedback] = useState<Feedback | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [exporting, setExporting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const loadData = useCallback(async () => {
setLoading(true)
try {
const res = await fetchFeedback({
page,
limit: PAGE_SIZE,
type: typeFilter !== "all" ? typeFilter : undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
})
setData(res.data)
setTotal(res.pagination.total)
} catch {
setData([])
} finally {
setLoading(false)
}
}, [page, typeFilter, statusFilter])
useEffect(() => {
loadData()
}, [loadData])
const handleStatusChange = async (id: string, status: FeedbackStatus) => {
await updateFeedbackStatus(id, status)
setDetailOpen(false)
loadData()
}
const handleMarkRead = (fb: Feedback) => handleStatusChange(fb.id, "read")
const handleMarkResolved = (fb: Feedback) => handleStatusChange(fb.id, "resolved")
const handleViewDetail = (fb: Feedback) => {
setDetailFeedback(fb)
setDetailOpen(true)
}
const handleExport = async () => {
setExporting(true)
try {
const res = await fetchFeedback({
limit: 10000,
type: typeFilter !== "all" ? typeFilter : undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
})
exportToCsv("feedback.csv", [
{ key: "type", label: "类型" },
{ key: "userNickname", label: "用户" },
{ key: "content", label: "内容" },
{ key: "rating", label: "评价" },
{ key: "status", label: "状态" },
{ key: "createdAt", label: "提交时间" },
], res.data as unknown as Record<string, unknown>[])
} finally {
setExporting(false)
}
}
const columns = getColumns({
onMarkRead: handleMarkRead,
onMarkResolved: handleMarkResolved,
onViewDetail: handleViewDetail,
})
const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() })
return (
<div className="space-y-6">
{/* 头部 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground"> {total} </span>
<Button variant="outline" size="sm" disabled={exporting} onClick={handleExport}>
<Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"}
</Button>
</div>
</div>
{/* 筛选 */}
<div className="flex items-center gap-3">
<Select
value={typeFilter}
onValueChange={(val) => {
setTypeFilter(val as FeedbackType | "all")
setPage(1)
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(FEEDBACK_TYPE_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={statusFilter}
onValueChange={(val) => {
setStatusFilter(val as FeedbackStatus | "all")
setPage(1)
}}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{Object.entries(FEEDBACK_STATUS_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 表格 */}
<div className="rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id}>
{hg.headers.map((h) => (
<TableHead key={h.id}>
{h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-20 text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : data.length === 0 ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-20 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span> {page}/{totalPages} </span>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage((p) => p + 1)}>
</Button>
</div>
</div>
)}
{/* 详情弹窗 */}
<FeedbackDetailDialog
open={detailOpen}
onOpenChange={setDetailOpen}
feedback={detailFeedback}
onStatusChange={handleStatusChange}
/>
</div>
)
}

View File

@ -6,7 +6,7 @@ import {
flexRender,
type ColumnDef,
} from "@tanstack/react-table"
import { Plus, Search, Trash2, EyeOff, Send } from "lucide-react"
import { Plus, Search, Trash2, EyeOff, Send, Download } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@ -40,6 +40,7 @@ import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDial
import { Checkbox } from "@/components/ui/checkbox"
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
import { fetchCategories } from "@/lib/api/category-api"
import { exportToCsv } from "@/lib/csv-export"
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
import type { Question, QuestionStatus, Difficulty } from "@/types/question"
import type { Category } from "@/types/category"
@ -73,6 +74,7 @@ export default function QuestionsPage() {
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
const [batchSubmitting, setBatchSubmitting] = useState(false)
const [exporting, setExporting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
@ -192,12 +194,42 @@ export default function QuestionsPage() {
const selectedCount = table.getFilteredSelectedRowModel().rows.length
async function handleExport() {
setExporting(true)
try {
const res = await fetchQuestions({
limit: 10000,
search: search || undefined,
status: statusFilter !== "all" ? statusFilter : undefined,
categoryId: categoryFilter !== "all" ? categoryFilter : undefined,
difficulty: difficultyFilter !== "all"
? (Number(difficultyFilter) as Difficulty)
: undefined,
})
exportToCsv("questions.csv", [
{ key: "stem", label: "题干" },
{ key: "categoryId", label: "分类" },
{ key: "difficulty", label: "难度" },
{ key: "status", label: "状态" },
{ key: "answerCount", label: "答题次数" },
{ key: "correctRate", label: "正确率" },
{ key: "updatedAt", label: "更新时间" },
], res.data as unknown as Record<string, unknown>[])
} finally {
setExporting(false)
}
}
return (
<div className="space-y-6">
{/* 页面头部 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex gap-2">
<Button variant="outline" size="sm" disabled={exporting} onClick={handleExport}>
<Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"}
</Button>
<Button variant="outline" onClick={() => setImportOpen(true)}>
</Button>

148
src/routes/users/$id.tsx Normal file
View File

@ -0,0 +1,148 @@
import { useEffect, useState } from "react"
import { useNavigate, useParams } from "react-router"
import { ArrowLeft } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { AnswerHistoryTable } from "@/components/user/AnswerHistoryTable"
import { ChapterProgressList } from "@/components/user/ChapterProgressList"
import { GameStatsGrid } from "@/components/user/GameStatsGrid"
import { TierChangeDialog } from "@/components/user/TierChangeDialog"
import { UserProfileCard } from "@/components/user/UserProfileCard"
import { fetchUserChapterProgress, fetchUserDetail, updateUserTier } from "@/lib/api/user-api"
import { TIER_LABELS, TIER_DESCRIPTIONS } from "@/lib/constants"
import type { ChapterProgress, UserDetail } from "@/types/user-detail"
import type { UserTier } from "@/types/user"
const TIER_VARIANTS: Record<UserTier, "secondary" | "default" | "destructive"> = {
free: "secondary",
pro: "default",
proplus: "destructive",
}
export default function UserDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const [user, setUser] = useState<UserDetail | null>(null)
const [chapters, setChapters] = useState<ChapterProgress[]>([])
const [loading, setLoading] = useState(true)
const [tierDialogOpen, setTierDialogOpen] = useState(false)
useEffect(() => {
if (!id) return
Promise.all([
fetchUserDetail(id).then((res) => res.data),
fetchUserChapterProgress(id)
.then((res) => res.data)
.catch(() => []),
])
.then(([userDetail, chapterData]) => {
setUser(userDetail)
setChapters(chapterData)
})
.catch(() => navigate("/users"))
.finally(() => setLoading(false))
}, [id, navigate])
if (loading) {
return <div className="text-muted-foreground py-12 text-center">...</div>
}
if (!user) {
return <div className="text-muted-foreground py-12 text-center"></div>
}
const handleTierChange = async (tier: UserTier) => {
if (!id) return
const res = await updateUserTier(id, tier)
setUser((prev) => (prev ? { ...prev, tier: res.data.tier } : prev))
}
return (
<div className="space-y-6">
{/* 头部 */}
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate("/users")}>
<ArrowLeft className="size-4" />
</Button>
<h1 className="text-2xl font-bold"></h1>
</div>
{/* 资料 + 统计 */}
<UserProfileCard user={user} />
<GameStatsGrid user={user} />
{/* Tab 面板 */}
<Tabs defaultValue="history">
<TabsList>
<TabsTrigger value="history"></TabsTrigger>
<TabsTrigger value="chapters"></TabsTrigger>
<TabsTrigger value="subscription"></TabsTrigger>
</TabsList>
<TabsContent value="history" className="mt-4">
<AnswerHistoryTable userId={user.id} />
</TabsContent>
<TabsContent value="chapters" className="mt-4">
<ChapterProgressList chapters={chapters} />
</TabsContent>
<TabsContent value="subscription" className="mt-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"></CardTitle>
<Button size="sm" onClick={() => setTierDialogOpen(true)}>
</Button>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground"></span>
<Badge variant={TIER_VARIANTS[user.tier]} className="text-base">
{TIER_LABELS[user.tier]}
</Badge>
</div>
<p className="text-sm text-muted-foreground">{TIER_DESCRIPTIONS[user.tier]}</p>
{/* 权益参考 */}
<div className="mt-4 rounded-md border">
<div className="border-b px-4 py-2">
<span className="text-sm font-medium"></span>
</div>
<div className="divide-y text-sm">
{(["free", "pro", "proplus"] as const).map((tier) => (
<div
key={tier}
className={`flex items-center justify-between px-4 py-2 ${
tier === user.tier ? "bg-muted/50" : ""
}`}
>
<div className="flex items-center gap-2">
<Badge variant={TIER_VARIANTS[tier]}>{TIER_LABELS[tier]}</Badge>
{tier === user.tier && (
<span className="text-xs text-muted-foreground"></span>
)}
</div>
<span className="text-xs text-muted-foreground">{TIER_DESCRIPTIONS[tier]}</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 订阅变更弹窗 */}
<TierChangeDialog
open={tierDialogOpen}
onOpenChange={setTierDialogOpen}
user={user}
onConfirm={handleTierChange}
/>
</div>
)
}

View File

@ -4,7 +4,7 @@ import {
getCoreRowModel,
flexRender,
} from "@tanstack/react-table"
import { Search } from "lucide-react"
import { Download, Search } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
@ -24,6 +24,7 @@ import {
} from "@/components/ui/table"
import { getColumns } from "@/components/user/columns"
import { fetchUsers } from "@/lib/api/user-api"
import { exportToCsv } from "@/lib/csv-export"
import type { User, UserTier } from "@/types/user"
const PAGE_SIZE = 20
@ -66,6 +67,26 @@ export default function UsersPage() {
loadUsers()
}, [loadUsers])
const [exporting, setExporting] = useState(false)
const handleExport = async () => {
setExporting(true)
try {
const res = await fetchUsers({ limit: 10000, search: search || undefined, tier: tierFilter !== "all" ? tierFilter : undefined })
exportToCsv("users.csv", [
{ key: "nickname", label: "昵称" },
{ key: "authType", label: "登录方式" },
{ key: "tier", label: "订阅" },
{ key: "xpTotal", label: "总 XP" },
{ key: "streakDays", label: "连续天数" },
{ key: "heartsRemaining", label: "红心" },
{ key: "createdAt", label: "注册时间" },
], res.data as unknown as Record<string, unknown>[])
} finally {
setExporting(false)
}
}
const columns = getColumns()
const table = useReactTable({
@ -79,7 +100,13 @@ export default function UsersPage() {
{/* 页面头部 */}
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"></h1>
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground"> {total} </span>
<Button variant="outline" size="sm" disabled={exporting} onClick={handleExport}>
<Download className="mr-1 size-4" />
{exporting ? "导出中..." : "导出 CSV"}
</Button>
</div>
</div>
{/* 筛选栏 */}

17
src/types/feedback.ts Normal file
View File

@ -0,0 +1,17 @@
export type FeedbackType = "quiz_rating" | "text_feedback"
export type FeedbackStatus = "pending" | "read" | "resolved"
export interface Feedback {
id: string
type: FeedbackType
userId: string
userNickname?: string
questionId?: string
questionStem?: string
rating?: "good" | "bad"
content?: string
contact?: string
status: FeedbackStatus
createdAt: string
updatedAt: string
}

32
src/types/user-detail.ts Normal file
View File

@ -0,0 +1,32 @@
import type { User } from "./user"
/** 用户详情,包含统计摘要 */
export interface UserDetail extends User {
totalAnswered: number
correctRate: number
avgTimeMs: number
}
/** 单条答题记录 */
export interface UserProgress {
id: string
questionId: string
questionStem: string
correct: boolean
timeMs: number
answeredAt: string
}
/** 章节完成状态 */
export type ChapterStatus = "locked" | "unlocked" | "passed" | "perfect"
/** 单章节进度 */
export interface ChapterProgress {
chapterId: string
chapterTitle: string
status: ChapterStatus
bestCorrectCount: number
totalQuestions: number
attempts: number
completedAt?: string
}