feat: 实现 Phase 2 — 用户详情页、反馈管理、订阅管理、CSV 导出
Phase 2a: 用户详情页(资料卡片、游戏统计、答题历史、章节进度) Phase 2b: 反馈管理页面(类型/状态筛选、详情弹窗、状态变更) Phase 2c: 订阅等级管理(等级变更对话框、权益对比) Phase 2d: CSV 导出(用户/反馈/题目列表,Excel 兼容 BOM)
This commit is contained in:
parent
7efcf97ef4
commit
fbc8bbb04d
11
src/App.tsx
11
src/App.tsx
@ -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 },
|
||||
],
|
||||
},
|
||||
|
||||
115
src/components/feedback/FeedbackDetailDialog.tsx
Normal file
115
src/components/feedback/FeedbackDetailDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
116
src/components/feedback/columns.tsx
Normal file
116
src/components/feedback/columns.tsx
Normal 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>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
@ -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 },
|
||||
]
|
||||
|
||||
|
||||
89
src/components/ui/tabs.tsx
Normal file
89
src/components/ui/tabs.tsx
Normal 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 }
|
||||
159
src/components/user/AnswerHistoryTable.tsx
Normal file
159
src/components/user/AnswerHistoryTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
54
src/components/user/ChapterProgressList.tsx
Normal file
54
src/components/user/ChapterProgressList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
src/components/user/GameStatsGrid.tsx
Normal file
57
src/components/user/GameStatsGrid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
src/components/user/TierChangeDialog.tsx
Normal file
112
src/components/user/TierChangeDialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
58
src/components/user/UserProfileCard.tsx
Normal file
58
src/components/user/UserProfileCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
33
src/lib/api/feedback-api.ts
Normal file
33
src/lib/api/feedback-api.ts
Normal 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>>()
|
||||
}
|
||||
@ -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[]>>()
|
||||
}
|
||||
|
||||
@ -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
35
src/lib/csv-export.ts
Normal 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)
|
||||
}
|
||||
235
src/routes/feedback/index.tsx
Normal file
235
src/routes/feedback/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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
148
src/routes/users/$id.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
<span className="text-sm text-muted-foreground">共 {total} 名用户</span>
|
||||
<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
17
src/types/feedback.ts
Normal 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
32
src/types/user-detail.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user