feat(error-book): update components and analytics data-access

- Update all error-book components (filters, cards, charts, dialogs)

- Update data-access-analytics.ts

- Update student-error-book-list-client.tsx
This commit is contained in:
SpecialX
2026-07-04 10:22:36 +08:00
parent 6ea8ba763b
commit eee0145274
18 changed files with 39 additions and 25 deletions

View File

@@ -64,7 +64,7 @@ export function StudentErrorBookListClient({
studentId, studentId,
}: StudentErrorBookListClientProps): JSX.Element { }: StudentErrorBookListClientProps): JSX.Element {
const router = useRouter() const router = useRouter()
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const tPractice = useTranslations("practice") const tPractice = useTranslations("practice")
const [, startTransition] = useTransition() const [, startTransition] = useTransition()

View File

@@ -43,7 +43,7 @@ export function AddErrorBookDialog({
questionOptions: questionOptionsProp, questionOptions: questionOptionsProp,
onLoadQuestions, onLoadQuestions,
}: AddErrorBookDialogProps) { }: AddErrorBookDialogProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [questionId, setQuestionId] = useState("") const [questionId, setQuestionId] = useState("")

View File

@@ -29,7 +29,7 @@ export function AnalyticsStatsCards({
knowledgePointCount, knowledgePointCount,
className, className,
}: AnalyticsStatsCardsProps) { }: AnalyticsStatsCardsProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1) const avg = (totalStudents > 0 ? totalErrorItems / totalStudents : 0).toFixed(1)
const cards = [ const cards = [

View File

@@ -47,7 +47,7 @@ function isChapterChartPayload(v: unknown): v is ChapterChartPayload {
* 每个柱子可展开显示该章节下错得最多的知识点 * 每个柱子可展开显示该章节下错得最多的知识点
*/ */
export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) { export function ChapterWeaknessChart({ data, className }: ChapterWeaknessChartProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
if (data.length === 0) return null if (data.length === 0) return null

View File

@@ -55,7 +55,7 @@ const CHART_COLORS = [
* 颜色按班级区分tooltip 显示学生数/人均/掌握率 * 颜色按班级区分tooltip 显示学生数/人均/掌握率
*/ */
export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) { export function ClassErrorBarChart({ data, className }: ClassErrorBarChartProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
if (data.length === 0) return null if (data.length === 0) return null

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -18,6 +18,10 @@ interface ClassFilterProps {
/** /**
* 班级筛选器(教师视图) * 班级筛选器(教师视图)
* 显示教师所教的所有班级,点击切换查看单个班级 * 显示教师所教的所有班级,点击切换查看单个班级
*
* v3: 移除 useSearchParams(),改用 window.location.search 在点击时读取当前 URL 参数。
* useSearchParams() 在 SSR 期间会导致 Suspense 显示 fallbackCSR 期间显示真实内容,
* 组件树结构不同导致 useId() 生成的 id 不匹配,触发 hydration mismatch。
*/ */
export function ClassFilter({ export function ClassFilter({
classes, classes,
@@ -25,11 +29,13 @@ export function ClassFilter({
paramName = "classId", paramName = "classId",
}: ClassFilterProps) { }: ClassFilterProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const t = useTranslations("errorBook")
const t = useTranslations("error-book")
const handleSelect = (classId: string) => { const handleSelect = (classId: string) => {
const params = new URLSearchParams(searchParams.toString()) // 在 client 端点击时读取当前 URL 参数,避免 useSearchParams() 的 SSR/CSR 不一致
const params = new URLSearchParams(
typeof window !== "undefined" ? window.location.search : ""
)
if (classId === "all") { if (classId === "all") {
params.delete(paramName) params.delete(paramName)
} else { } else {

View File

@@ -60,7 +60,7 @@ export function ErrorBookDetailDialog({
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [note, setNote] = useState(item.note ?? "") const [note, setNote] = useState(item.note ?? "")
const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? []) const [errorTags, setErrorTags] = useState<string[]>(item.errorTags ?? [])
const t = useTranslations("error-book") const t = useTranslations("errorBook")
function handleSaveNote() { function handleSaveNote() {
startTransition(async () => { startTransition(async () => {

View File

@@ -13,7 +13,7 @@ import {
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar" import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
export function ErrorBookFilters() { export function ErrorBookFilters() {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const [search, setSearch] = useQueryState("q", parseAsString.withDefault("")) const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all")) const [status, setStatus] = useQueryState("status", parseAsString.withDefault("all"))
const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all")) const [sourceType, setSourceType] = useQueryState("source", parseAsString.withDefault("all"))

View File

@@ -24,7 +24,7 @@ interface ErrorBookItemCardProps {
} }
export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) { export function ErrorBookItemCard({ item, children }: ErrorBookItemCardProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const preview = item.question const preview = item.question
? extractQuestionPreview(item.question.content, t("itemCard.questionContent")) ? extractQuestionPreview(item.question.content, t("itemCard.questionContent"))
: t("itemCard.questionDeleted") : t("itemCard.questionDeleted")

View File

@@ -23,7 +23,7 @@ export function ErrorBookList({
aiAnalysisSlot, aiAnalysisSlot,
onStartVariantPractice, onStartVariantPractice,
}: ErrorBookListProps): JSX.Element { }: ErrorBookListProps): JSX.Element {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
if (items.length === 0) { if (items.length === 0) {
return ( return (

View File

@@ -12,7 +12,7 @@ interface ErrorBookStatsCardsProps {
} }
export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) { export function ErrorBookStatsCards({ stats, isLoading }: ErrorBookStatsCardsProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const masteredPercent = stats.totalCount > 0 const masteredPercent = stats.totalCount > 0
? Math.round(stats.masteredRate * 100) ? Math.round(stats.masteredRate * 100)
: 0 : 0

View File

@@ -34,7 +34,7 @@ export function GroupedStudentErrorTable({
studentNames, studentNames,
basePath, basePath,
}: GroupedStudentErrorTableProps) { }: GroupedStudentErrorTableProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set()) const [expandedClasses, setExpandedClasses] = useState<Set<string>>(new Set())
// 按班级分组 // 按班级分组

View File

@@ -50,7 +50,7 @@ export function KnowledgePointWeaknessChart({
data, data,
className, className,
}: KnowledgePointWeaknessChartProps) { }: KnowledgePointWeaknessChartProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
if (data.length === 0) return null if (data.length === 0) return null

View File

@@ -23,7 +23,7 @@ type ReviewOption = {
} }
export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) { export function ReviewButtons({ itemId, onReviewed }: ReviewButtonsProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null) const [selected, setSelected] = useState<ErrorBookReviewResultValue | null>(null)

View File

@@ -54,7 +54,7 @@ export function SubjectDistributionChart({
data, data,
className, className,
}: SubjectDistributionChartProps) { }: SubjectDistributionChartProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
if (data.length === 0) return null if (data.length === 0) return null

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useRouter, useSearchParams } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -18,6 +18,10 @@ interface SubjectTabsProps {
/** /**
* 学科切换 Tab教师/管理员视图) * 学科切换 Tab教师/管理员视图)
* 显示每个学科的错题数概览,点击切换 * 显示每个学科的错题数概览,点击切换
*
* v3: 移除 useSearchParams(),改用 window.location.search 在点击时读取当前 URL 参数。
* useSearchParams() 在 SSR 期间会导致 Suspense 显示 fallbackCSR 期间显示真实内容,
* 组件树结构不同导致 useId() 生成的 id 不匹配,触发 hydration mismatch。
*/ */
export function SubjectTabs({ export function SubjectTabs({
subjects, subjects,
@@ -25,11 +29,13 @@ export function SubjectTabs({
paramName = "subject", paramName = "subject",
}: SubjectTabsProps) { }: SubjectTabsProps) {
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const t = useTranslations("errorBook")
const t = useTranslations("error-book")
const handleSelect = (subjectId: string | null) => { const handleSelect = (subjectId: string | null) => {
const params = new URLSearchParams(searchParams.toString()) // 在 client 端点击时读取当前 URL 参数,避免 useSearchParams() 的 SSR/CSR 不一致
const params = new URLSearchParams(
typeof window !== "undefined" ? window.location.search : ""
)
if (subjectId === null) { if (subjectId === null) {
params.delete(paramName) params.delete(paramName)
} else { } else {

View File

@@ -34,7 +34,7 @@ function isKnownQuestionType(v: string): v is KnownQuestionType {
} }
export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) { export function TopWrongQuestions({ questions }: TopWrongQuestionsProps) {
const t = useTranslations("error-book") const t = useTranslations("errorBook")
if (questions.length === 0) { if (questions.length === 0) {
return ( return (

View File

@@ -109,11 +109,13 @@ export async function getTopWrongQuestionsByStudentIds(
const whereClause = buildStudentErrorWhereClause(studentIds, subjectId) const whereClause = buildStudentErrorWhereClause(studentIds, subjectId)
// 使用子查询聚合,避免 GROUP BY JSON 列的问题 // 使用子查询聚合,避免 GROUP BY JSON 列的问题
// 注意:子查询中的 raw SQL 字段count()、sql``)必须声明 alias
// 否则外层引用时会报 "doesn't have an alias declared" 错误
const aggregatedSubquery = db const aggregatedSubquery = db
.select({ .select({
questionId: errorBookItems.questionId, questionId: errorBookItems.questionId,
errorCount: count(), errorCount: count().as("errorCount"),
masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`, masteredCount: sql<number>`sum(case when ${errorBookItems.status} = 'mastered' then 1 else 0 end)`.as("masteredCount"),
}) })
.from(errorBookItems) .from(errorBookItems)
.where(whereClause) .where(whereClause)