From fbc8bbb04dcba441c336c7fdb12d83b0ead381d6 Mon Sep 17 00:00:00 2001 From: Wang Zhuoxuan Date: Wed, 8 Apr 2026 12:29:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Phase=202=20?= =?UTF-8?q?=E2=80=94=20=E7=94=A8=E6=88=B7=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E3=80=81=E5=8F=8D=E9=A6=88=E7=AE=A1=E7=90=86=E3=80=81=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E7=AE=A1=E7=90=86=E3=80=81CSV=20=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a: 用户详情页(资料卡片、游戏统计、答题历史、章节进度) Phase 2b: 反馈管理页面(类型/状态筛选、详情弹窗、状态变更) Phase 2c: 订阅等级管理(等级变更对话框、权益对比) Phase 2d: CSV 导出(用户/反馈/题目列表,Excel 兼容 BOM) --- src/App.tsx | 11 +- .../feedback/FeedbackDetailDialog.tsx | 115 +++++++++ src/components/feedback/columns.tsx | 116 +++++++++ src/components/layout/Sidebar.tsx | 2 + src/components/ui/tabs.tsx | 89 +++++++ src/components/user/AnswerHistoryTable.tsx | 159 ++++++++++++ src/components/user/ChapterProgressList.tsx | 54 ++++ src/components/user/GameStatsGrid.tsx | 57 +++++ src/components/user/TierChangeDialog.tsx | 112 +++++++++ src/components/user/UserProfileCard.tsx | 58 +++++ src/components/user/columns.tsx | 6 +- src/lib/api/feedback-api.ts | 33 +++ src/lib/api/user-api.ts | 28 +++ src/lib/constants.ts | 30 +++ src/lib/csv-export.ts | 35 +++ src/routes/feedback/index.tsx | 235 ++++++++++++++++++ src/routes/questions/index.tsx | 34 ++- src/routes/users/$id.tsx | 148 +++++++++++ src/routes/users/index.tsx | 31 ++- src/types/feedback.ts | 17 ++ src/types/user-detail.ts | 32 +++ 21 files changed, 1396 insertions(+), 6 deletions(-) create mode 100644 src/components/feedback/FeedbackDetailDialog.tsx create mode 100644 src/components/feedback/columns.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/components/user/AnswerHistoryTable.tsx create mode 100644 src/components/user/ChapterProgressList.tsx create mode 100644 src/components/user/GameStatsGrid.tsx create mode 100644 src/components/user/TierChangeDialog.tsx create mode 100644 src/components/user/UserProfileCard.tsx create mode 100644 src/lib/api/feedback-api.ts create mode 100644 src/lib/csv-export.ts create mode 100644 src/routes/feedback/index.tsx create mode 100644 src/routes/users/$id.tsx create mode 100644 src/types/feedback.ts create mode 100644 src/types/user-detail.ts diff --git a/src/App.tsx b/src/App.tsx index d7b7811..ecc911c 100644 --- a/src/App.tsx +++ b/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 }, ], }, diff --git a/src/components/feedback/FeedbackDetailDialog.tsx b/src/components/feedback/FeedbackDetailDialog.tsx new file mode 100644 index 0000000..0119437 --- /dev/null +++ b/src/components/feedback/FeedbackDetailDialog.tsx @@ -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 = { + pending: "destructive", + read: "secondary", + resolved: "default", +} + +interface FeedbackDetailDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + feedback: Feedback | null + onStatusChange: (id: string, status: FeedbackStatus) => Promise +} + +export function FeedbackDetailDialog({ + open, + onOpenChange, + feedback, + onStatusChange, +}: FeedbackDetailDialogProps) { + if (!feedback) return null + + const isPending = feedback.status === "pending" + const isResolved = feedback.status === "resolved" + + return ( + + + + 反馈详情 + + +
+ {/* 基本信息 */} +
+ {FEEDBACK_TYPE_LABELS[feedback.type]} + + {FEEDBACK_STATUS_LABELS[feedback.status]} + +
+ + {/* 用户 */} +
+ 用户: + {feedback.userNickname || "匿名"} + {feedback.contact && ( + ({feedback.contact}) + )} +
+ + {/* 内容 */} + {feedback.type === "quiz_rating" ? ( +
+
+ 评价: + + {FEEDBACK_RATING_LABELS[feedback.rating ?? ""] ?? feedback.rating} + +
+ {feedback.questionStem && ( +
+

{feedback.questionStem}

+
+ )} +
+ ) : ( +
+

{feedback.content ?? "(无内容)"}

+
+ )} + + {/* 时间 */} +
+ 提交于 {new Date(feedback.createdAt).toLocaleString("zh-CN")} +
+ + {/* 操作按钮 */} + {!isResolved && ( +
+ {isPending && ( + + )} + +
+ )} +
+
+
+ ) +} diff --git a/src/components/feedback/columns.tsx b/src/components/feedback/columns.tsx new file mode 100644 index 0000000..823b56e --- /dev/null +++ b/src/components/feedback/columns.tsx @@ -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 = { + 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[] { + return [ + { + accessorKey: "type", + header: "类型", + cell: ({ row }) => { + const type = row.getValue("type") as string + return ( + {FEEDBACK_TYPE_LABELS[type as keyof typeof FEEDBACK_TYPE_LABELS] ?? type} + ) + }, + }, + { + id: "content", + header: "内容", + cell: ({ row }) => { + const fb = row.original + if (fb.type === "quiz_rating") { + return ( +
+ {FEEDBACK_RATING_LABELS[fb.rating ?? ""] ?? fb.rating} + {fb.questionStem && ( +

{fb.questionStem}

+ )} +
+ ) + } + return ( + + {fb.content ?? "—"} + + ) + }, + }, + { + accessorKey: "userNickname", + header: "用户", + cell: ({ row }) => ( + + {(row.getValue("userNickname") as string) || "匿名"} + + ), + }, + { + accessorKey: "status", + header: "状态", + cell: ({ row }) => { + const status = row.getValue("status") as FeedbackStatus + return {FEEDBACK_STATUS_LABELS[status]} + }, + }, + { + accessorKey: "createdAt", + header: "提交时间", + cell: ({ row }) => ( + + {new Date(row.getValue("createdAt") as string).toLocaleString("zh-CN")} + + ), + }, + { + id: "actions", + header: "", + cell: ({ row }) => { + const fb = row.original + return ( +
+ + {fb.status === "pending" && ( + + )} + {fb.status !== "resolved" && ( + + )} +
+ ) + }, + }, + ] +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 3b2475c..91f73fb 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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 }, ] diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..7f2c4f2 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -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) { + return ( + + ) +} + +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 & + VariantProps) { + return ( + + ) +} + +function TabsTrigger({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function TabsContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants } diff --git a/src/components/user/AnswerHistoryTable.tsx b/src/components/user/AnswerHistoryTable.tsx new file mode 100644 index 0000000..600f019 --- /dev/null +++ b/src/components/user/AnswerHistoryTable.tsx @@ -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[] { + return [ + { + accessorKey: "questionStem", + header: "题目", + cell: ({ row }) => { + const stem = row.getValue("questionStem") as string + return ( + + {stem.length > 60 ? stem.slice(0, 60) + "..." : stem} + + ) + }, + }, + { + accessorKey: "correct", + header: "结果", + cell: ({ row }) => { + const correct = row.getValue("correct") as boolean + return ( + + {correct ? "正确" : "错误"} + + ) + }, + }, + { + accessorKey: "timeMs", + header: "用时", + cell: ({ row }) => { + const ms = row.getValue("timeMs") as number + return {(ms / 1000).toFixed(1)}s + }, + }, + { + accessorKey: "answeredAt", + header: "答题时间", + cell: ({ row }) => ( + + {new Date(row.getValue("answeredAt") as string).toLocaleString("zh-CN")} + + ), + }, + ] +} + +interface AnswerHistoryTableProps { + userId: string +} + +export function AnswerHistoryTable({ userId }: AnswerHistoryTableProps) { + const [data, setData] = useState([]) + 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 ( +
+
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + {h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())} + + ))} + + ))} + + + {loading ? ( + + + 加载中... + + + ) : data.length === 0 ? ( + + + 暂无答题记录 + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ + {totalPages > 1 && ( +
+ 第 {page}/{totalPages} 页 +
+ + +
+
+ )} +
+ ) +} diff --git a/src/components/user/ChapterProgressList.tsx b/src/components/user/ChapterProgressList.tsx new file mode 100644 index 0000000..d91bfd6 --- /dev/null +++ b/src/components/user/ChapterProgressList.tsx @@ -0,0 +1,54 @@ +import { Badge } from "@/components/ui/badge" +import type { ChapterProgress, ChapterStatus } from "@/types/user-detail" + +const STATUS_LABELS: Record = { + locked: "未解锁", + unlocked: "已解锁", + passed: "已通过", + perfect: "完美", +} + +const STATUS_VARIANTS: Record = { + locked: "secondary", + unlocked: "outline", + passed: "default", + perfect: "destructive", +} + +interface ChapterProgressListProps { + chapters: ChapterProgress[] +} + +export function ChapterProgressList({ chapters }: ChapterProgressListProps) { + if (chapters.length === 0) { + return
暂无章节进度数据
+ } + + return ( +
+ {chapters.map((ch) => ( +
+
+ {STATUS_LABELS[ch.status]} + {ch.chapterTitle} +
+ +
+ + {ch.bestCorrectCount}/{ch.totalQuestions} 题 + + 尝试 {ch.attempts} 次 + {ch.completedAt && ( + + {new Date(ch.completedAt).toLocaleDateString("zh-CN")} + + )} +
+
+ ))} +
+ ) +} diff --git a/src/components/user/GameStatsGrid.tsx b/src/components/user/GameStatsGrid.tsx new file mode 100644 index 0000000..e08b770 --- /dev/null +++ b/src/components/user/GameStatsGrid.tsx @@ -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 ( +
+ {stats.map((stat) => ( + + ))} +
+ ) +} diff --git a/src/components/user/TierChangeDialog.tsx b/src/components/user/TierChangeDialog.tsx new file mode 100644 index 0000000..3c180ed --- /dev/null +++ b/src/components/user/TierChangeDialog.tsx @@ -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 = { + 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 +} + +export function TierChangeDialog({ + open, + onOpenChange, + user, + onConfirm, +}: TierChangeDialogProps) { + const [selected, setSelected] = useState(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 ( + + + + 更改订阅等级 + + +
+ {/* 当前等级 */} +
+ 当前: + {TIER_LABELS[user.tier]} +
+ + {/* 新等级选择 */} +
+ 更改为: + +
+ + {/* 权益变更提示 */} + {changed && ( +
+

权益变更:

+

+ {TIER_LABELS[user.tier]} → {TIER_LABELS[selected]} +

+

{TIER_DESCRIPTIONS[selected]}

+
+ )} + + {/* 操作 */} +
+ + +
+
+
+
+ ) +} diff --git a/src/components/user/UserProfileCard.tsx b/src/components/user/UserProfileCard.tsx new file mode 100644 index 0000000..a5c481b --- /dev/null +++ b/src/components/user/UserProfileCard.tsx @@ -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 = { + free: "免费", + pro: "Pro", + proplus: "Pro+", +} + +const TIER_VARIANTS: Record = { + free: "secondary", + pro: "default", + proplus: "destructive", +} + +const AUTH_TYPE_LABELS: Record = { + huawei: "华为", + guest: "游客", + phone: "手机", + apple: "Apple", + google: "Google", +} + +interface UserProfileCardProps { + user: UserDetail +} + +export function UserProfileCard({ user }: UserProfileCardProps) { + return ( + + + {/* 头像 */} +
+ {(user.nickname ?? "?").slice(0, 1).toUpperCase()} +
+ + {/* 信息 */} +
+
+ + {user.nickname || "未设置昵称"} + + {TIER_LABELS[user.tier]} +
+
+ {AUTH_TYPE_LABELS[user.authType] ?? user.authType} + 注册于 {new Date(user.createdAt).toLocaleDateString("zh-CN")} + {user.streakLastDate && ( + 最近活跃 {new Date(user.streakLastDate).toLocaleDateString("zh-CN")} + )} +
+
+
+
+ ) +} diff --git a/src/components/user/columns.tsx b/src/components/user/columns.tsx index 63189cc..c14d490 100644 --- a/src/components/user/columns.tsx +++ b/src/components/user/columns.tsx @@ -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[] { header: "昵称", cell: ({ row }) => { const nickname = row.getValue("nickname") as string | null + const userId = row.original.id return ( - + {nickname || 未设置} - + ) }, }, diff --git a/src/lib/api/feedback-api.ts b/src/lib/api/feedback-api.ts new file mode 100644 index 0000000..d0e95ac --- /dev/null +++ b/src/lib/api/feedback-api.ts @@ -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> { + 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>() +} + +export async function updateFeedbackStatus( + id: string, + status: FeedbackStatus +): Promise> { + return apiClient + .patch(`feedback/${id}`, { json: { status } }) + .json>() +} diff --git a/src/lib/api/user-api.ts b/src/lib/api/user-api.ts index 5373656..d1f45b2 100644 --- a/src/lib/api/user-api.ts +++ b/src/lib/api/user-api.ts @@ -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> { return apiClient.get(`users/${id}`).json>() } + +export async function fetchUserDetail(id: string): Promise> { + return apiClient.get(`users/${id}`).json>() +} + +export async function updateUserTier(id: string, tier: UserTier): Promise> { + return apiClient.patch(`users/${id}`, { json: { tier } }).json>() +} + +export async function fetchUserProgress( + id: string, + params: { page?: number; limit?: number } = {} +): Promise> { + 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>() +} + +export async function fetchUserChapterProgress( + id: string +): Promise> { + return apiClient.get(`users/${id}/chapters`).json>() +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 71de342..ca537dc 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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 = { 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 = { + pending: "待处理", + read: "已读", + resolved: "已处理", +} + +export const FEEDBACK_TYPE_LABELS: Record = { + quiz_rating: "题目评价", + text_feedback: "意见反馈", +} + +export const FEEDBACK_RATING_LABELS: Record = { + good: "👍 有趣", + bad: "👎 一般", +} + +export const TIER_LABELS: Record = { + free: "免费", + pro: "Pro", + proplus: "Pro+", +} + +export const TIER_DESCRIPTIONS: Record = { + free: "完整答题内容 + 5 颗红心/天 + 可选广告", + pro: "无广告 + 无限红心 + 深度知识卡 + 连胜自动修复", + proplus: "Pro 全部权益 + 专属题包 + 吉祥物皮肤 + AI 知识问答", +} diff --git a/src/lib/csv-export.ts b/src/lib/csv-export.ts new file mode 100644 index 0000000..38a6a1f --- /dev/null +++ b/src/lib/csv-export.ts @@ -0,0 +1,35 @@ +/** + * 将数据导出为 CSV 文件并触发浏览器下载。 + * + * @param filename 下载文件名(如 "users.csv") + * @param columns 列定义,每项包含 key(数据字段名)和 label(表头) + * @param data 要导出的数据行 + */ +export function exportToCsv>( + filename: string, + columns: ReadonlyArray<{ key: keyof T & string; label: string }>, + data: ReadonlyArray +): 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) +} diff --git a/src/routes/feedback/index.tsx b/src/routes/feedback/index.tsx new file mode 100644 index 0000000..8065876 --- /dev/null +++ b/src/routes/feedback/index.tsx @@ -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([]) + const [loading, setLoading] = useState(true) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [typeFilter, setTypeFilter] = useState("all") + const [statusFilter, setStatusFilter] = useState("all") + + // 详情弹窗 + const [detailFeedback, setDetailFeedback] = useState(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[]) + } finally { + setExporting(false) + } + } + + const columns = getColumns({ + onMarkRead: handleMarkRead, + onMarkResolved: handleMarkResolved, + onViewDetail: handleViewDetail, + }) + + const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel() }) + + return ( +
+ {/* 头部 */} +
+

用户反馈

+
+ 共 {total} 条 + +
+
+ + {/* 筛选 */} +
+ + + +
+ + {/* 表格 */} +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((h) => ( + + {h.isPlaceholder ? null : flexRender(h.column.columnDef.header, h.getContext())} + + ))} + + ))} + + + {loading ? ( + + + 加载中... + + + ) : data.length === 0 ? ( + + + 暂无反馈数据 + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + )} + +
+
+ + {/* 分页 */} + {totalPages > 1 && ( +
+ 第 {page}/{totalPages} 页 +
+ + +
+
+ )} + + {/* 详情弹窗 */} + +
+ ) +} diff --git a/src/routes/questions/index.tsx b/src/routes/questions/index.tsx index 17109e8..b2b1585 100644 --- a/src/routes/questions/index.tsx +++ b/src/routes/questions/index.tsx @@ -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[]) + } finally { + setExporting(false) + } + } + return (
{/* 页面头部 */}

题库管理

+ diff --git a/src/routes/users/$id.tsx b/src/routes/users/$id.tsx new file mode 100644 index 0000000..a5bd7a9 --- /dev/null +++ b/src/routes/users/$id.tsx @@ -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 = { + free: "secondary", + pro: "default", + proplus: "destructive", +} + +export default function UserDetailPage() { + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const [user, setUser] = useState(null) + const [chapters, setChapters] = useState([]) + 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
加载中...
+ } + + if (!user) { + return
用户不存在
+ } + + const handleTierChange = async (tier: UserTier) => { + if (!id) return + const res = await updateUserTier(id, tier) + setUser((prev) => (prev ? { ...prev, tier: res.data.tier } : prev)) + } + + return ( +
+ {/* 头部 */} +
+ +

用户详情

+
+ + {/* 资料 + 统计 */} + + + + {/* Tab 面板 */} + + + 答题历史 + 章节进度 + 订阅管理 + + + + + + + + + + + + + + 订阅状态 + + + +
+ 当前等级: + + {TIER_LABELS[user.tier]} + +
+

{TIER_DESCRIPTIONS[user.tier]}

+ + {/* 权益参考 */} +
+
+ 各等级权益对比 +
+
+ {(["free", "pro", "proplus"] as const).map((tier) => ( +
+
+ {TIER_LABELS[tier]} + {tier === user.tier && ( + (当前) + )} +
+ {TIER_DESCRIPTIONS[tier]} +
+ ))} +
+
+
+
+
+
+ + {/* 订阅变更弹窗 */} + +
+ ) +} diff --git a/src/routes/users/index.tsx b/src/routes/users/index.tsx index 9a8b398..8308f33 100644 --- a/src/routes/users/index.tsx +++ b/src/routes/users/index.tsx @@ -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[]) + } finally { + setExporting(false) + } + } + const columns = getColumns() const table = useReactTable({ @@ -79,7 +100,13 @@ export default function UsersPage() { {/* 页面头部 */}

用户管理

- 共 {total} 名用户 +
+ 共 {total} 名用户 + +
{/* 筛选栏 */} diff --git a/src/types/feedback.ts b/src/types/feedback.ts new file mode 100644 index 0000000..36ae17e --- /dev/null +++ b/src/types/feedback.ts @@ -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 +} diff --git a/src/types/user-detail.ts b/src/types/user-detail.ts new file mode 100644 index 0000000..0fc7d5e --- /dev/null +++ b/src/types/user-detail.ts @@ -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 +}