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 CategoriesPage from "@/routes/categories"
|
||||||
import SkillTreePage from "@/routes/skill-tree"
|
import SkillTreePage from "@/routes/skill-tree"
|
||||||
import UsersPage from "@/routes/users"
|
import UsersPage from "@/routes/users"
|
||||||
|
import UserDetailPage from "@/routes/users/$id"
|
||||||
|
import FeedbackPage from "@/routes/feedback"
|
||||||
import SettingsPage from "@/routes/settings"
|
import SettingsPage from "@/routes/settings"
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -30,7 +32,14 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
{ path: "categories", Component: CategoriesPage },
|
{ path: "categories", Component: CategoriesPage },
|
||||||
{ path: "skill-tree", Component: SkillTreePage },
|
{ 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 },
|
{ 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,
|
FolderOpen,
|
||||||
TreePine,
|
TreePine,
|
||||||
Users,
|
Users,
|
||||||
|
MessageSquare,
|
||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
@ -17,6 +18,7 @@ const navItems = [
|
|||||||
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
{ to: "/categories", label: "分类管理", icon: FolderOpen },
|
||||||
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
{ to: "/skill-tree", label: "技能树", icon: TreePine },
|
||||||
{ to: "/users", label: "用户管理", icon: Users },
|
{ to: "/users", label: "用户管理", icon: Users },
|
||||||
|
{ to: "/feedback", label: "用户反馈", icon: MessageSquare },
|
||||||
{ to: "/settings", label: "系统设置", icon: Settings },
|
{ 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 type { ColumnDef } from "@tanstack/react-table"
|
||||||
|
import { Link } from "react-router"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import type { User, UserTier } from "@/types/user"
|
import type { User, UserTier } from "@/types/user"
|
||||||
|
|
||||||
@ -29,10 +30,11 @@ export function getColumns(): ColumnDef<User>[] {
|
|||||||
header: "昵称",
|
header: "昵称",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const nickname = row.getValue("nickname") as string | null
|
const nickname = row.getValue("nickname") as string | null
|
||||||
|
const userId = row.original.id
|
||||||
return (
|
return (
|
||||||
<span className="font-medium">
|
<Link to={`/users/${userId}`} className="font-medium hover:underline">
|
||||||
{nickname || <span className="text-muted-foreground">未设置</span>}
|
{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 { apiClient } from "@/lib/api-client"
|
||||||
import type { PaginatedResponse, ApiResponse } from "@/types/api"
|
import type { PaginatedResponse, ApiResponse } from "@/types/api"
|
||||||
import type { User, UserTier } from "@/types/user"
|
import type { User, UserTier } from "@/types/user"
|
||||||
|
import type { UserDetail, UserProgress, ChapterProgress } from "@/types/user-detail"
|
||||||
|
|
||||||
export interface FetchUsersParams {
|
export interface FetchUsersParams {
|
||||||
page?: number
|
page?: number
|
||||||
@ -26,3 +27,30 @@ export async function fetchUsers(
|
|||||||
export async function fetchUser(id: string): Promise<ApiResponse<User>> {
|
export async function fetchUser(id: string): Promise<ApiResponse<User>> {
|
||||||
return apiClient.get(`users/${id}`).json<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 { CategoryStatus } from "@/types/category"
|
||||||
|
import type { FeedbackStatus, FeedbackType } from "@/types/feedback"
|
||||||
import type { QuestionStatus } from "@/types/question"
|
import type { QuestionStatus } from "@/types/question"
|
||||||
|
import type { UserTier } from "@/types/user"
|
||||||
|
|
||||||
export const QUESTION_STATUSES = {
|
export const QUESTION_STATUSES = {
|
||||||
draft: "草稿",
|
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 API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:3000"
|
||||||
|
|
||||||
export const AUTH_STORAGE_KEY = "duoqi_admin_jwt"
|
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,
|
flexRender,
|
||||||
type ColumnDef,
|
type ColumnDef,
|
||||||
} from "@tanstack/react-table"
|
} 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 { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@ -40,6 +40,7 @@ import { ImportQuestionsDialog } from "@/components/question/ImportQuestionsDial
|
|||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
|
import { fetchQuestions, deleteQuestion, updateQuestionStatus, batchOperateQuestions } from "@/lib/api/question-api"
|
||||||
import { fetchCategories } from "@/lib/api/category-api"
|
import { fetchCategories } from "@/lib/api/category-api"
|
||||||
|
import { exportToCsv } from "@/lib/csv-export"
|
||||||
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
import { DIFFICULTY_LABELS, QUESTION_STATUSES } from "@/lib/constants"
|
||||||
import type { Question, QuestionStatus, Difficulty } from "@/types/question"
|
import type { Question, QuestionStatus, Difficulty } from "@/types/question"
|
||||||
import type { Category } from "@/types/category"
|
import type { Category } from "@/types/category"
|
||||||
@ -73,6 +74,7 @@ export default function QuestionsPage() {
|
|||||||
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
|
const [batchConfirmOpen, setBatchConfirmOpen] = useState(false)
|
||||||
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
|
const [batchAction, setBatchAction] = useState<"publish" | "archive" | "delete">("delete")
|
||||||
const [batchSubmitting, setBatchSubmitting] = useState(false)
|
const [batchSubmitting, setBatchSubmitting] = useState(false)
|
||||||
|
const [exporting, setExporting] = useState(false)
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||||
|
|
||||||
@ -192,12 +194,42 @@ export default function QuestionsPage() {
|
|||||||
|
|
||||||
const selectedCount = table.getFilteredSelectedRowModel().rows.length
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">题库管理</h1>
|
<h1 className="text-2xl font-bold">题库管理</h1>
|
||||||
<div className="flex gap-2">
|
<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 variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
批量导入
|
批量导入
|
||||||
</Button>
|
</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,
|
getCoreRowModel,
|
||||||
flexRender,
|
flexRender,
|
||||||
} from "@tanstack/react-table"
|
} from "@tanstack/react-table"
|
||||||
import { Search } from "lucide-react"
|
import { Download, Search } from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import {
|
import {
|
||||||
@ -24,6 +24,7 @@ import {
|
|||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import { getColumns } from "@/components/user/columns"
|
import { getColumns } from "@/components/user/columns"
|
||||||
import { fetchUsers } from "@/lib/api/user-api"
|
import { fetchUsers } from "@/lib/api/user-api"
|
||||||
|
import { exportToCsv } from "@/lib/csv-export"
|
||||||
import type { User, UserTier } from "@/types/user"
|
import type { User, UserTier } from "@/types/user"
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
@ -66,6 +67,26 @@ export default function UsersPage() {
|
|||||||
loadUsers()
|
loadUsers()
|
||||||
}, [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 columns = getColumns()
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@ -79,7 +100,13 @@ export default function UsersPage() {
|
|||||||
{/* 页面头部 */}
|
{/* 页面头部 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">用户管理</h1>
|
<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>
|
</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