feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates

parent:

- Add parent-student-attendance-detail component

auth:

- Add actions, data-access, schema, services, types

onboarding:

- Add parent-children-form and hooks directory

files:

- Add actions, schema, hooks directory

notifications:

- Add schema and schema test

adaptive-practice:

- Add answer-input, answer-result, practice-result-view, practice-starter-with-nav

- Add question-card, question-content, lib and services directories

ai:

- Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
SpecialX
2026-07-03 10:26:12 +08:00
parent f3c223d914
commit e9a5264fe7
84 changed files with 6060 additions and 2530 deletions

View File

@@ -3,9 +3,14 @@
import { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions } from "@/shared/types/permissions"
import { Permissions, type AuthContext } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils"
import {
getStudentActiveClassId,
getStudentActiveGradeId,
verifyTeacherOwnsClass,
} from "@/modules/classes/data-access"
import {
CreatePracticeSessionSchema,
@@ -22,7 +27,98 @@ import {
getPracticeSessions,
getPracticeStats,
} from "./data-access"
import type { PracticeSessionDetail, PracticeSessionSummary, PracticeStats, PracticeSourceMeta } from "./types"
import { parseSourceMeta } from "./lib/source-meta"
import { practiceErrors } from "./lib/errors"
import type {
PracticeSessionDetail,
PracticeSessionSummary,
PracticeStats,
} from "./types"
// ---------------------------------------------------------------------------
// 权限辅助dataScope 下 studentId 归属校验
// ---------------------------------------------------------------------------
/**
* 根据 dataScope 解析并校验目标学生 ID。
*
* 行级权限校验P0 安全修复):
* - `all`admin 可访问任意 studentId不传则用 ctx.userId
* - `owned`:仅可访问 ctx.userId学生本人
* - `class_members`:仅可访问 ctx.userId学生本人
* - `children`:仅可访问 childrenIds 中的学生(家长)
* - `class_taught`:校验学生属于教师所教班级(教师)
* - `grade_managed`:校验学生属于年级主任所辖年级(年级主任)
*
* @throws {PermissionDeniedError} studentId 不在 dataScope 范围内
* @returns 校验通过的目标 studentId
*/
async function resolveTargetStudentId(
ctx: AuthContext,
studentId?: string,
): Promise<string> {
// 未传 studentId根据 scope 选取默认值
if (!studentId || studentId === ctx.userId) {
if (ctx.dataScope.type === "children") {
// 家长默认查第一个孩子
return ctx.dataScope.childrenIds[0] ?? ctx.userId
}
return ctx.userId
}
// 传入了 studentId按 scope 校验归属
switch (ctx.dataScope.type) {
case "all":
// admin 无限制
return studentId
case "owned":
// 学生仅能访问自己
if (studentId !== ctx.userId) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
return studentId
case "class_members":
// 学生仅能访问自己
if (studentId !== ctx.userId) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
return studentId
case "children":
// 家长仅能访问自己的子女
if (!ctx.dataScope.childrenIds.includes(studentId)) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
return studentId
case "class_taught": {
// 教师:校验学生属于其所教班级
const classId = await getStudentActiveClassId(studentId)
if (!classId) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
if (!owns) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
return studentId
}
case "grade_managed": {
// 年级主任:校验学生属于其所辖年级
const gradeId = await getStudentActiveGradeId(studentId)
if (!gradeId || !ctx.dataScope.gradeIds.includes(gradeId)) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
return studentId
}
default:
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
}
// ---------------------------------------------------------------------------
// 查询 Actions
@@ -33,25 +129,12 @@ export async function getPracticeSessionsAction(
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>> {
try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
let targetStudentId = ctx.userId
if (studentId && studentId !== ctx.userId) {
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
targetStudentId = studentId
} else if (ctx.dataScope.type === "children") {
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
}
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
const result = await getPracticeSessions(targetStudentId)
return { success: true, data: result }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "获取练习列表失败"
return { success: false, message }
return handleActionError(e)
}
}
@@ -61,28 +144,16 @@ export async function getPracticeSessionDetailAction(
): Promise<ActionState<PracticeSessionDetail>> {
try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
let targetStudentId = ctx.userId
if (studentId && studentId !== ctx.userId) {
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
targetStudentId = studentId
} else if (ctx.dataScope.type === "children") {
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
}
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
const data = await getPracticeSessionById(sessionId, targetStudentId)
if (!data) {
return { success: false, message: "练习会话不存在或无权访问" }
// 通过 errorCode 返回,前端 t(`errors.session_not_found`) 查 i18n
throw practiceErrors.sessionNotFound()
}
return { success: true, data }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "获取练习详情失败"
return { success: false, message }
return handleActionError(e)
}
}
@@ -91,25 +162,12 @@ export async function getPracticeStatsAction(
): Promise<ActionState<PracticeStats>> {
try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
let targetStudentId = ctx.userId
if (studentId && studentId !== ctx.userId) {
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
}
targetStudentId = studentId
} else if (ctx.dataScope.type === "children") {
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
}
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
const data = await getPracticeStats(targetStudentId)
return { success: true, data }
} catch (e) {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "获取练习统计失败"
return { success: false, message }
return handleActionError(e)
}
}
@@ -126,7 +184,8 @@ export async function createPracticeSessionAction(
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "提交格式错误,需要 JSON 字段" }
// errorCode: invalid_input → 前端 t(`errors.invalid_input`)
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
}
const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString))
@@ -134,31 +193,38 @@ export async function createPracticeSessionAction(
return {
success: false,
message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors,
}
}
// 从 JSON 解析的 sourceMeta 需要经过 unknown 中间转换
// 因为 Zod 的 z.record(z.string(), z.unknown()) 返回 Record<string, unknown>
// 而实际运行时结构由前端按练习类型构建,此处做类型收窄
const sourceMeta = parsed.data.sourceMeta as unknown as PracticeSourceMeta
// 严格类型守卫:替代 `as unknown as PracticeSourceMeta` 断言
const sourceMeta = parseSourceMeta(parsed.data.practiceType, parsed.data.sourceMeta)
if (!sourceMeta) {
return {
success: false,
message: "来源元数据结构与练习类型不匹配",
errorCode: "invalid_source_meta",
}
}
const result = await createPracticeSession(ctx.userId, {
// 写入操作仅允许学生本人或家长代子女发起
// 家长 dataScope.type === "children" 时,使用第一个子女 ID
const targetStudentId = ctx.dataScope.type === "children"
? ctx.dataScope.childrenIds[0] ?? ctx.userId
: ctx.userId
const result = await createPracticeSession(targetStudentId, {
practiceType: parsed.data.practiceType,
subjectId: parsed.data.subjectId,
sourceMeta,
questionCount: parsed.data.questionCount,
})
if (result.selectedCount === 0) {
return { success: false, message: "未找到符合条件的题目,请尝试其他筛选条件" }
}
revalidatePath("/student/practice")
return {
success: true,
message: `已创建练习会话,共 ${result.selectedCount} 道题目`,
data: result,
}
} catch (e) {
@@ -175,7 +241,7 @@ export async function submitPracticeAnswerAction(
const jsonString = formData.get("json")
if (typeof jsonString !== "string") {
return { success: false, message: "提交格式错误" }
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
}
const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString))
@@ -183,15 +249,21 @@ export async function submitPracticeAnswerAction(
return {
success: false,
message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors,
}
}
const { sessionId, answerId, answer, skip } = parsed.data
// 学生本人或家长代子女提交
const targetStudentId = ctx.dataScope.type === "children"
? ctx.dataScope.childrenIds[0] ?? ctx.userId
: ctx.userId
const result = await submitPracticeAnswer(
sessionId,
ctx.userId,
targetStudentId,
answerId,
answer,
skip ?? false,
@@ -201,7 +273,6 @@ export async function submitPracticeAnswerAction(
return {
success: true,
message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"),
data: result,
}
} catch (e) {
@@ -223,16 +294,21 @@ export async function completePracticeSessionAction(
return {
success: false,
message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors,
}
}
await completePracticeSession(parsed.data.sessionId, ctx.userId)
const targetStudentId = ctx.dataScope.type === "children"
? ctx.dataScope.childrenIds[0] ?? ctx.userId
: ctx.userId
await completePracticeSession(parsed.data.sessionId, targetStudentId)
revalidatePath("/student/practice")
revalidatePath(`/student/practice/${parsed.data.sessionId}`)
return { success: true, message: "练习已完成" }
return { success: true }
} catch (e) {
return handleActionError(e)
}
@@ -252,15 +328,20 @@ export async function abandonPracticeSessionAction(
return {
success: false,
message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors,
}
}
await abandonPracticeSession(parsed.data.sessionId, ctx.userId)
const targetStudentId = ctx.dataScope.type === "children"
? ctx.dataScope.childrenIds[0] ?? ctx.userId
: ctx.userId
await abandonPracticeSession(parsed.data.sessionId, targetStudentId)
revalidatePath("/student/practice")
return { success: true, message: "练习已放弃" }
return { success: true }
} catch (e) {
return handleActionError(e)
}

View File

@@ -0,0 +1,145 @@
"use client"
import { useTranslations } from "next-intl"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
import { Label } from "@/shared/components/ui/label"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { extractOptions } from "../lib/grading"
import { isStringArray } from "../lib/answer-utils"
interface AnswerInputProps {
questionType: string
content: unknown
userAnswer: unknown
onAnswerChange: (answer: unknown) => void
/** 是否禁用输入 */
disabled?: boolean
}
/**
* 答题输入组件。
*
* 根据题目类型渲染不同的输入控件:
* - single_choice: 单选 RadioGroup
* - multiple_choice: 多选 Checkbox 组
* - judgment: 判断 RadioGroup正确/错误)
* - text/其他: 文本域
*
* 所有控件均带 aria-label 供屏幕阅读器识别。
*/
export function AnswerInput({
questionType,
content,
userAnswer,
onAnswerChange,
disabled = false,
}: AnswerInputProps): React.ReactNode {
const t = useTranslations("practice")
if (questionType === "single_choice") {
const options = extractOptions(content)
const selectedId = typeof userAnswer === "string" ? userAnswer : ""
return (
<RadioGroup
value={selectedId}
onValueChange={onAnswerChange}
disabled={disabled}
aria-label={t("session.submit")}
>
<div className="space-y-2">
{options.map((opt) => (
<div key={opt.id} className="flex items-center space-x-2">
<RadioGroupItem
value={opt.id}
id={opt.id}
aria-label={opt.text}
/>
<Label htmlFor={opt.id}>{opt.text}</Label>
</div>
))}
</div>
</RadioGroup>
)
}
if (questionType === "multiple_choice") {
const options = extractOptions(content)
const selectedIds = isStringArray(userAnswer) ? userAnswer : []
function toggle(id: string): void {
const newIds = selectedIds.includes(id)
? selectedIds.filter((v) => v !== id)
: [...selectedIds, id]
onAnswerChange(newIds)
}
return (
<div
className="space-y-2"
role="group"
aria-label={t("session.submit")}
>
{options.map((opt) => (
<div key={opt.id} className="flex items-center space-x-2">
<Checkbox
checked={selectedIds.includes(opt.id)}
onCheckedChange={() => toggle(opt.id)}
id={opt.id}
disabled={disabled}
aria-label={opt.text}
/>
<Label htmlFor={opt.id}>{opt.text}</Label>
</div>
))}
</div>
)
}
if (questionType === "judgment") {
const value = typeof userAnswer === "string" ? userAnswer : ""
return (
<RadioGroup
value={value}
onValueChange={onAnswerChange}
disabled={disabled}
aria-label={t("session.submit")}
>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem
value="true"
id="judgment-true"
aria-label={t("session.true")}
/>
<Label htmlFor="judgment-true">{t("session.true")}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem
value="false"
id="judgment-false"
aria-label={t("session.false")}
/>
<Label htmlFor="judgment-false">{t("session.false")}</Label>
</div>
</div>
</RadioGroup>
)
}
// text 题型
const textValue = typeof userAnswer === "string" ? userAnswer : ""
return (
<textarea
value={textValue}
onChange={(e) => onAnswerChange(e.target.value)}
placeholder={t("session.textPlaceholder")}
disabled={disabled}
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={t("session.textPlaceholder")}
/>
)
}

View File

@@ -0,0 +1,76 @@
"use client"
import { useTranslations } from "next-intl"
import { CheckCircle2, XCircle } from "lucide-react"
import { QuestionContent } from "./question-content"
import type { PracticeAnswerRecord } from "../types"
interface AnswerResultProps {
answer: PracticeAnswerRecord
result?: { isCorrect: boolean | null; score: number | null }
}
/**
* 答题结果展示组件。
*
* 展示:
* - 判分结果(正确/错误/待批阅/已跳过)
* - 学生答案(使用 QuestionContent 渲染替代 JSON.stringify
*/
export function AnswerResult({ answer, result }: AnswerResultProps): React.ReactNode {
const t = useTranslations("practice")
const isCorrect = result?.isCorrect ?? answer.isCorrect
const isSkipped = answer.status === "skipped"
return (
<div className="space-y-3">
{/* 判分结果 */}
{isSkipped ? (
<div
className="rounded-md border border-muted bg-muted/30 p-3 text-sm text-muted-foreground"
role="status"
aria-label={t("session.skipped")}
>
{t("session.skipped")}
</div>
) : isCorrect === true ? (
<div
className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/20 dark:text-emerald-400"
role="status"
aria-label={t("session.correct")}
>
<CheckCircle2 className="h-5 w-5" aria-hidden="true" />
{t("session.correct")}
</div>
) : isCorrect === false ? (
<div
className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400"
role="status"
aria-label={t("session.incorrect")}
>
<XCircle className="h-5 w-5" aria-hidden="true" />
{t("session.incorrect")}
</div>
) : (
<div
className="rounded-md border border-amber-200 bg-amber-50/50 p-3 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-400"
role="status"
aria-label={t("session.pendingReview")}
>
{t("session.pendingReview")}
</div>
)}
{/* 学生答案 */}
{answer.studentAnswer !== null && answer.studentAnswer !== undefined ? (
<div>
<h4 className="mb-1 text-sm font-medium">{t("session.yourAnswer")}</h4>
<div className="rounded-md border bg-muted/30 p-2 text-xs">
<QuestionContent content={answer.studentAnswer} />
</div>
</div>
) : null}
</div>
)
}

View File

@@ -13,6 +13,12 @@ import type { PracticeSessionSummary, PracticeStatus } from "../types"
interface PracticeHistoryProps {
sessions: PracticeSessionSummary[]
/**
* 点击某条练习记录跳转的路由前缀。
* - 学生端传 `/student/practice`,将跳转到 `/student/practice/{sessionId}`
* - 家长端不传(无会话详情页),仅渲染为只读卡片
*/
routePrefix?: string
}
const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline"> = {
@@ -24,7 +30,10 @@ const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline
/**
* 专项练习历史列表
*/
export function PracticeHistory({ sessions }: PracticeHistoryProps): React.ReactNode {
export function PracticeHistory({
sessions,
routePrefix,
}: PracticeHistoryProps): React.ReactNode {
const t = useTranslations("practice")
if (sessions.length === 0) {
@@ -48,8 +57,7 @@ export function PracticeHistory({ sessions }: PracticeHistoryProps): React.React
? (session.answeredQuestions / session.totalQuestions) * 100
: 0
return (
<Link key={session.id} href={`/student/practice/${session.id}`}>
const content = (
<Card className="transition-colors hover:bg-muted/30">
<CardContent className="p-4">
<div className="flex items-center justify-between gap-3">
@@ -87,8 +95,17 @@ export function PracticeHistory({ sessions }: PracticeHistoryProps): React.React
<Progress value={progress} className="mt-2 h-1" />
</CardContent>
</Card>
)
if (routePrefix) {
return (
<Link key={session.id} href={`${routePrefix}/${session.id}`}>
{content}
</Link>
)
}
return <div key={session.id}>{content}</div>
})}
</div>
)

View File

@@ -0,0 +1,110 @@
"use client"
import { useTranslations } from "next-intl"
import { CheckCircle2, XCircle, Trophy } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import type { PracticeSessionDetail } from "../types"
interface PracticeResultViewProps {
session: PracticeSessionDetail
}
/**
* 练习完成后的结果视图。
*
* 展示:
* - 结果摘要卡片(已答题数、正确数、正确率)
* - 逐题回顾列表
*
* 带有 ARIA role 和 aria-label 供屏幕阅读器识别。
*/
export function PracticeResultView({ session }: PracticeResultViewProps): React.ReactNode {
const t = useTranslations("practice")
const total = session.totalQuestions
const answered = session.answeredQuestions
const correct = session.correctCount
const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0
return (
<Card>
<CardHeader>
<CardTitle
className="flex items-center gap-2"
aria-label={t("result.title")}
>
<Trophy className="h-5 w-5 text-amber-500" aria-hidden="true" />
{t("result.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 结果摘要 */}
<div
className="grid grid-cols-3 gap-4"
role="region"
aria-label={t("result.title")}
>
<div className="rounded-md border p-4 text-center">
<div className="text-2xl font-bold" aria-label={`${t("result.answered")}: ${answered}`}>
{answered}
</div>
<div className="text-xs text-muted-foreground">{t("result.answered")}</div>
</div>
<div className="rounded-md border p-4 text-center">
<div
className="text-2xl font-bold text-emerald-600"
aria-label={`${t("result.correct")}: ${correct}`}
>
{correct}
</div>
<div className="text-xs text-muted-foreground">{t("result.correct")}</div>
</div>
<div className="rounded-md border p-4 text-center">
<div
className="text-2xl font-bold"
aria-label={`${t("result.accuracy")}: ${accuracy}%`}
>
{accuracy}%
</div>
<div className="text-xs text-muted-foreground">{t("result.accuracy")}</div>
</div>
</div>
{/* 逐题回顾 */}
<div className="space-y-2" role="list" aria-label={t("result.review")}>
<h4 className="text-sm font-medium">{t("result.review")}</h4>
{session.answers.map((a, idx) => (
<div
key={a.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
role="listitem"
aria-label={`${t("session.question")} ${idx + 1}`}
>
<span className="flex items-center gap-2">
{a.isCorrect === true ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
) : a.isCorrect === false ? (
<XCircle className="h-4 w-4 text-rose-500" aria-hidden="true" />
) : (
<span className="text-muted-foreground" aria-hidden="true"></span>
)}
{t("session.question")} {idx + 1}
</span>
<Badge variant="outline">
{a.status === "answered"
? (a.isCorrect === true
? t("session.correct")
: a.isCorrect === false
? t("session.incorrect")
: t("session.pendingReview"))
: t("session.skipped")}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)
}

View File

@@ -3,16 +3,12 @@
import { useState, useTransition, useMemo } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { CheckCircle2, XCircle, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
import { CheckCircle2, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Progress } from "@/shared/components/ui/progress"
import { Badge } from "@/shared/components/ui/badge"
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
import { Label } from "@/shared/components/ui/label"
import { Checkbox } from "@/shared/components/ui/checkbox"
import {
AlertDialog,
AlertDialogAction,
@@ -25,36 +21,55 @@ import {
AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog"
import { submitPracticeAnswerAction, completePracticeSessionAction, abandonPracticeSessionAction } from "../actions"
import type { PracticeSessionDetail, PracticeAnswerRecord } from "../types"
import { QuestionCard } from "./question-card"
import { PracticeResultView } from "./practice-result-view"
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
import type { PracticeSessionDetail } from "../types"
interface PracticeSessionViewProps {
session: PracticeSessionDetail
/**
* 放弃练习后的跳转回调(由页面层注入路由跳转逻辑)。
*
* 解耦设计:组件本身不硬编码路由,由调用方决定跳转目标。
* 不传则使用默认行为 `router.push("/student/practice")`(向后兼容)。
*/
onAbandoned?: () => void
}
/**
* 专项练习答题界面
* 专项练习答题界面(编排层)。
*
* 功能
* 1. 逐题作答,支持上一题/下一题导航
* 2. 自动判分(选择题/判断题
* 3. 跳过题目
* 4. 完成练习后展示结果摘要
* 职责
* 1. 管理 UI 状态(当前题目索引、答案暂存、判分结果缓存)
* 2. 通过 `usePracticeService()` 调用数据服务(不直接 import actions
* 3. 组合 QuestionCard / PracticeResultView 等子组件
*
* 子组件:
* - QuestionCard: 题目卡片(含内容渲染、答题输入、结果展示)
* - PracticeResultView: 完成后的结果视图
*
* 提交失败支持重试(通过 submitFailed + onRetry 传递给 QuestionCard
*/
export function PracticeSessionView({ session }: PracticeSessionViewProps): React.ReactNode {
export function PracticeSessionView({
session,
onAbandoned,
}: PracticeSessionViewProps): React.ReactNode {
const t = useTranslations("practice")
const router = useRouter()
const service = usePracticeService()
const analytics = usePracticeAnalytics()
const [isPending, startTransition] = useTransition()
const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<string, unknown>>({})
const [results, setResults] = useState<Record<string, { isCorrect: boolean | null; score: number | null }>>({})
const [failedAnswerIds, setFailedAnswerIds] = useState<Set<string>>(new Set())
const answersList = session.answers
const total = answersList.length
const current = answersList[currentIndex]
const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0
// 已答题数和正确数
const answeredCount = useMemo(
() => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length,
[answersList],
@@ -64,6 +79,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
[answersList],
)
function resolveErrorMessage(
errorCode: string | undefined,
message: string | undefined,
fallbackKey: string,
): string {
if (errorCode) {
return t(`errors.${errorCode}`)
}
return message ?? t(fallbackKey)
}
if (session.status !== "in_progress") {
return <PracticeResultView session={session} />
}
@@ -92,16 +118,25 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
skip,
}),
)
const res = await submitPracticeAnswerAction(undefined, formData)
const res = await service.submitAnswer(undefined, formData)
if (res.success && res.data) {
setResults((prev) => ({ ...prev, [current.id]: res.data! }))
const data = res.data
setResults((prev) => ({ ...prev, [current.id]: data }))
setFailedAnswerIds((prev) => {
const next = new Set(prev)
next.delete(current.id)
return next
})
analytics.trackAnswerSubmit(session.id, current.id, data.isCorrect)
toast.success(res.message ?? t("toasts.submitted"))
// 自动跳到下一题
if (currentIndex < total - 1) {
setCurrentIndex(currentIndex + 1)
}
} else {
toast.error(res.message ?? t("toasts.submitFailed"))
// 标记提交失败,显示重试按钮
setFailedAnswerIds((prev) => new Set(prev).add(current.id))
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed"))
}
})
}
@@ -110,12 +145,13 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
startTransition(async () => {
const formData = new FormData()
formData.append("sessionId", session.id)
const res = await completePracticeSessionAction(undefined, formData)
const res = await service.completeSession(undefined, formData)
if (res.success) {
analytics.trackSessionComplete(session.id, session.accuracy)
toast.success(res.message ?? t("toasts.completed"))
router.refresh()
} else {
toast.error(res.message ?? t("toasts.completeFailed"))
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed"))
}
})
}
@@ -124,18 +160,32 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
startTransition(async () => {
const formData = new FormData()
formData.append("sessionId", session.id)
const res = await abandonPracticeSessionAction(undefined, formData)
const res = await service.abandonSession(undefined, formData)
if (res.success) {
analytics.trackSessionAbandon(session.id)
toast.success(res.message ?? t("toasts.abandoned"))
router.push("/student/practice")
// 优先使用注入的回调,否则回退到默认路由
if (onAbandoned) {
onAbandoned()
} else {
toast.error(res.message ?? t("toasts.abandonFailed"))
router.push("/student/practice")
}
} else {
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed"))
}
})
}
function handleRetry(): void {
if (current) {
analytics.trackErrorRetry(current.id)
}
handleSubmit(answers[current?.id ?? ""] ?? null)
}
const currentResult = current ? results[current.id] : undefined
const isAnswered = current?.status === "answered" || current?.status === "skipped"
const submitFailed = current ? failedAnswerIds.has(current.id) : false
return (
<div className="space-y-6">
@@ -143,18 +193,27 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">
<CardTitle
className="text-lg"
aria-label={`${t("session.progress")}: ${currentIndex + 1} / ${total}`}
>
{t("session.progress")}: {currentIndex + 1} / {total}
</CardTitle>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
<span className="flex items-center gap-1" aria-label={`${t("result.correct")}: ${correctCount}`}>
<CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
{correctCount}
</span>
<span>{answeredCount}/{total}</span>
<span aria-label={`${t("result.answered")}: ${answeredCount}/${total}`}>
{answeredCount}/{total}
</span>
</div>
</div>
<Progress value={progress} className="mt-2" />
<Progress
value={progress}
className="mt-2"
aria-label={`${t("session.progress")}: ${Math.round(progress)}%`}
/>
</CardHeader>
</Card>
@@ -168,9 +227,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))}
onSubmit={(ans) => handleSubmit(ans)}
onSkip={() => handleSubmit(null, true)}
onRetry={handleRetry}
isAnswered={isAnswered}
result={currentResult}
isPending={isPending}
submitFailed={submitFailed}
/>
) : null}
@@ -180,16 +241,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
variant="outline"
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
disabled={currentIndex === 0}
aria-label={t("session.previous")}
>
<ChevronLeft className="h-4 w-4" />
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
{t("session.previous")}
</Button>
<div className="flex gap-2">
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm">
<Flag className="h-4 w-4" />
<Button variant="ghost" size="sm" aria-label={t("session.abandon")}>
<Flag className="h-4 w-4" aria-hidden="true" />
{t("session.abandon")}
</Button>
</AlertDialogTrigger>
@@ -200,7 +262,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleAbandon} disabled={isPending}>
<AlertDialogAction
onClick={handleAbandon}
disabled={isPending}
aria-label={t("session.confirmAbandon")}
>
{t("session.confirmAbandon")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -211,13 +277,18 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
<Button
variant="outline"
onClick={() => setCurrentIndex(currentIndex + 1)}
aria-label={t("session.next")}
>
{t("session.next")}
<ChevronRight className="h-4 w-4" />
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</Button>
) : (
<Button onClick={handleComplete} disabled={isPending || answeredCount < total}>
<Trophy className="h-4 w-4" />
<Button
onClick={handleComplete}
disabled={isPending || answeredCount < total}
aria-label={t("session.complete")}
>
<Trophy className="h-4 w-4" aria-hidden="true" />
{t("session.complete")}
</Button>
)}
@@ -226,325 +297,3 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
</div>
)
}
// ---------------------------------------------------------------------------
// 题目卡片
// ---------------------------------------------------------------------------
interface QuestionCardProps {
answer: PracticeAnswerRecord
index: number
total: number
userAnswer: unknown
onAnswerChange: (answer: unknown) => void
onSubmit: (answer: unknown) => void
onSkip: () => void
isAnswered: boolean
result?: { isCorrect: boolean | null; score: number | null }
isPending: boolean
}
function QuestionCard({
answer,
index,
total,
userAnswer,
onAnswerChange,
onSubmit,
onSkip,
isAnswered,
result,
isPending,
}: QuestionCardProps): React.ReactNode {
const t = useTranslations("practice")
const question = answer.question
const content = answer.variantContent ?? question?.content
const questionType = question?.type ?? "unknown"
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{t("session.question")} {index + 1}/{total}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="outline">{questionType}</Badge>
{question?.difficulty ? (
<Badge variant="secondary">
{t("session.difficulty")}: {question.difficulty}
</Badge>
) : null}
{answer.isVariant ? (
<Badge variant="default">{t("session.variant")}</Badge>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 题目内容 */}
<div className="rounded-md border bg-muted/30 p-4 text-sm">
<pre className="whitespace-pre-wrap break-words font-sans">
{typeof content === "string"
? content
: JSON.stringify(content, null, 2)}
</pre>
</div>
{/* 作答区域 */}
{!isAnswered ? (
<AnswerInput
questionType={questionType}
content={content}
userAnswer={userAnswer}
onAnswerChange={onAnswerChange}
/>
) : (
<AnswerResult
answer={answer}
result={result}
/>
)}
{/* 操作按钮 */}
{!isAnswered ? (
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={onSkip} disabled={isPending}>
{t("session.skip")}
</Button>
<Button onClick={() => onSubmit(userAnswer)} disabled={isPending || userAnswer === undefined}>
{isPending ? t("session.submitting") : t("session.submit")}
</Button>
</div>
) : null}
</CardContent>
</Card>
)
}
// ---------------------------------------------------------------------------
// 答题输入组件
// ---------------------------------------------------------------------------
interface AnswerInputProps {
questionType: string
content: unknown
userAnswer: unknown
onAnswerChange: (answer: unknown) => void
}
function AnswerInput({ questionType, content, userAnswer, onAnswerChange }: AnswerInputProps): React.ReactNode {
const t = useTranslations("practice")
if (questionType === "single_choice") {
const options = extractOptions(content)
const selectedId = typeof userAnswer === "string" ? userAnswer : ""
return (
<RadioGroup value={selectedId} onValueChange={onAnswerChange}>
<div className="space-y-2">
{options.map((opt) => (
<div key={opt.id} className="flex items-center space-x-2">
<RadioGroupItem value={opt.id} id={opt.id} />
<Label htmlFor={opt.id}>{opt.text}</Label>
</div>
))}
</div>
</RadioGroup>
)
}
if (questionType === "multiple_choice") {
const options = extractOptions(content)
const selectedIds = Array.isArray(userAnswer) ? userAnswer as string[] : []
function toggle(id: string): void {
const newIds = selectedIds.includes(id)
? selectedIds.filter((v) => v !== id)
: [...selectedIds, id]
onAnswerChange(newIds)
}
return (
<div className="space-y-2">
{options.map((opt) => (
<div key={opt.id} className="flex items-center space-x-2">
<Checkbox
checked={selectedIds.includes(opt.id)}
onCheckedChange={() => toggle(opt.id)}
id={opt.id}
/>
<Label htmlFor={opt.id}>{opt.text}</Label>
</div>
))}
</div>
)
}
if (questionType === "judgment") {
const value = typeof userAnswer === "string" ? userAnswer : ""
return (
<RadioGroup value={value} onValueChange={onAnswerChange}>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<RadioGroupItem value="true" id="judgment-true" />
<Label htmlFor="judgment-true">{t("session.true")}</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="false" id="judgment-false" />
<Label htmlFor="judgment-false">{t("session.false")}</Label>
</div>
</div>
</RadioGroup>
)
}
// text 题型
return (
<textarea
value={typeof userAnswer === "string" ? userAnswer : ""}
onChange={(e) => onAnswerChange(e.target.value)}
placeholder={t("session.textPlaceholder")}
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
/>
)
}
// ---------------------------------------------------------------------------
// 答题结果展示
// ---------------------------------------------------------------------------
interface AnswerResultProps {
answer: PracticeAnswerRecord
result?: { isCorrect: boolean | null; score: number | null }
}
function AnswerResult({ answer, result }: AnswerResultProps): React.ReactNode {
const t = useTranslations("practice")
const isCorrect = result?.isCorrect ?? answer.isCorrect
const isSkipped = answer.status === "skipped"
return (
<div className="space-y-3">
{/* 判分结果 */}
{isSkipped ? (
<div className="rounded-md border border-muted bg-muted/30 p-3 text-sm text-muted-foreground">
{t("session.skipped")}
</div>
) : isCorrect === true ? (
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/20 dark:text-emerald-400">
<CheckCircle2 className="h-5 w-5" />
{t("session.correct")}
</div>
) : isCorrect === false ? (
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400">
<XCircle className="h-5 w-5" />
{t("session.incorrect")}
</div>
) : (
<div className="rounded-md border border-amber-200 bg-amber-50/50 p-3 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-400">
{t("session.pendingReview")}
</div>
)}
{/* 学生答案 */}
{answer.studentAnswer !== null && answer.studentAnswer !== undefined ? (
<div>
<h4 className="mb-1 text-sm font-medium">{t("session.yourAnswer")}</h4>
<pre className="whitespace-pre-wrap break-words rounded-md border bg-muted/30 p-2 text-xs font-sans">
{typeof answer.studentAnswer === "string"
? answer.studentAnswer
: JSON.stringify(answer.studentAnswer, null, 2)}
</pre>
</div>
) : null}
</div>
)
}
// ---------------------------------------------------------------------------
// 练习结果视图
// ---------------------------------------------------------------------------
function PracticeResultView({ session }: { session: PracticeSessionDetail }): React.ReactNode {
const t = useTranslations("practice")
const total = session.totalQuestions
const answered = session.answeredQuestions
const correct = session.correctCount
const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Trophy className="h-5 w-5 text-amber-500" />
{t("result.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div className="rounded-md border p-4 text-center">
<div className="text-2xl font-bold">{answered}</div>
<div className="text-xs text-muted-foreground">{t("result.answered")}</div>
</div>
<div className="rounded-md border p-4 text-center">
<div className="text-2xl font-bold text-emerald-600">{correct}</div>
<div className="text-xs text-muted-foreground">{t("result.correct")}</div>
</div>
<div className="rounded-md border p-4 text-center">
<div className="text-2xl font-bold">{accuracy}%</div>
<div className="text-xs text-muted-foreground">{t("result.accuracy")}</div>
</div>
</div>
{/* 逐题回顾 */}
<div className="space-y-2">
<h4 className="text-sm font-medium">{t("result.review")}</h4>
{session.answers.map((a, idx) => (
<div
key={a.id}
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
>
<span className="flex items-center gap-2">
{a.isCorrect === true ? (
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
) : a.isCorrect === false ? (
<XCircle className="h-4 w-4 text-rose-500" />
) : (
<span className="text-muted-foreground"></span>
)}
{t("session.question")} {idx + 1}
</span>
<Badge variant="outline">
{a.status === "answered" ? (a.isCorrect === true ? t("session.correct") : a.isCorrect === false ? t("session.incorrect") : t("session.pendingReview")) : t("session.skipped")}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)
}
// ---------------------------------------------------------------------------
// 辅助函数
// ---------------------------------------------------------------------------
function extractOptions(content: unknown): Array<{ id: string; text: string }> {
if (typeof content !== "object" || content === null) return []
const record = content as Record<string, unknown>
const options = record.options
if (!Array.isArray(options)) return []
return options
.filter((opt): opt is Record<string, unknown> =>
typeof opt === "object" && opt !== null && typeof opt.id === "string",
)
.map((opt) => ({
id: opt.id as string,
text: typeof opt.text === "string" ? opt.text : String(opt.text ?? ""),
}))
}

View File

@@ -0,0 +1,45 @@
"use client"
import { useRouter } from "next/navigation"
import { PracticeStarter, type PracticeStarterProps } from "./practice-starter"
type PracticeStarterWithNavProps = Omit<PracticeStarterProps, "onSessionCreated"> & {
/**
* 会话创建成功后跳转的路径前缀sessionId 会被自动拼接。
*
* 例如:`/student/practice` → 跳转到 `/student/practice/{sessionId}`。
* 不同角色可传不同前缀:学生 `/student/practice`、家长 `/parent/practice`。
*/
routePrefix: string
}
/**
* PracticeStarter 的路由导航包装器。
*
* 职责:将 `onSessionCreated` 回调与 `useRouter` 路由跳转绑定。
*
* 解耦设计:
* - `PracticeStarter` 本身不感知路由结构,仅暴露 `onSessionCreated` 回调
* - 此包装器在 Client Component 中使用 `useRouter`,由页面层渲染
* - Server Component 页面可直接渲染此包装器(无需额外客户端中间层)
*
* @example
* // app/(dashboard)/student/practice/page.tsx (Server Component)
* <PracticeStarterWithNav
* knowledgePoints={knowledgePoints}
* routePrefix="/student/practice"
* />
*/
export function PracticeStarterWithNav({
routePrefix,
...rest
}: PracticeStarterWithNavProps): React.ReactNode {
const router = useRouter()
function handleSessionCreated(sessionId: string): void {
router.push(`${routePrefix}/${sessionId}`)
}
return <PracticeStarter {...rest} onSessionCreated={handleSessionCreated} />
}

View File

@@ -1,13 +1,13 @@
"use client"
import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { Label } from "@/shared/components/ui/label"
import {
Select,
@@ -17,10 +17,11 @@ import {
SelectValue,
} from "@/shared/components/ui/select"
import { createPracticeSessionAction } from "../actions"
import type { PracticeType } from "../types"
import { isPracticeType } from "../lib/type-guards"
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
import type { AiRecommendedReason, PracticeType } from "../types"
interface PracticeStarterProps {
export interface PracticeStarterProps {
/** 知识点选项列表 */
knowledgePoints: Array<{ id: string; name: string }>
/** 预设模式(从错题本发起时传入) */
@@ -31,22 +32,50 @@ interface PracticeStarterProps {
}
/** 是否禁用类型选择(预设模式时) */
lockType?: boolean
/**
* 会话创建成功后的回调(由页面层注入路由跳转逻辑)。
*
* 解耦设计:组件本身不感知路由,由调用方决定跳转目标
* (学生跳 `/student/practice/[id]`,家长可能跳 `/parent/practice/[id]`)。
* 不传则不跳转(仅 toast 提示)。
*/
onSessionCreated?: (sessionId: string) => void
/**
* AI 推荐练习的发起角色(决定 sourceMeta.reason 枚举值)。
*
* - 学生自主发起(默认):`"student_initiated"`
* - 家长建议子女:`"parent_suggested"`
* - 教师布置:`"teacher_assigned"`
*/
aiRecommendReason?: AiRecommendedReason
}
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const
/**
* 专项练习发起器
* 专项练习发起器
*
* 通过 `usePracticeService()` 获取数据服务,不直接 import actions
* 实现完全解耦:测试时可注入 mock 服务,不同角色可注入不同实现。
*
* 路由跳转通过 `onSessionCreated` 回调注入,组件本身不感知路由结构。
*
* 支持四种练习模式:
* 1. 错题变式练习:从错题本发起
* 1. 错题重做:从错题本发起(通过 presetMode
* 2. 知识点专项:选择知识点后抽题
* 3. 薄弱章节:自动识别薄弱知识点
* 4. AI 推荐:AI 根据学情推荐
* 3. 薄弱章节:自动识别薄弱知识点(不传 chapterId 时跨章节识别)
* 4. AI 推荐:根据学情推荐
*/
export function PracticeStarter({ knowledgePoints, presetMode, lockType }: PracticeStarterProps): React.ReactNode {
export function PracticeStarter({
knowledgePoints,
presetMode,
lockType,
onSessionCreated,
aiRecommendReason = "student_initiated",
}: PracticeStarterProps): React.ReactNode {
const t = useTranslations("practice")
const router = useRouter()
const service = usePracticeService()
const analytics = usePracticeAnalytics()
const [isPending, startTransition] = useTransition()
const [practiceType, setPracticeType] = useState<PracticeType>(
@@ -62,6 +91,38 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
)
}
function handleTypeChange(value: string): void {
// 严格类型守卫替代 `value as PracticeType` 断言
if (isPracticeType(value)) {
setPracticeType(value)
}
}
function handleDifficultyChange(value: string): void {
const num = Number(value)
if (Number.isInteger(num)) {
setDifficulty(num)
}
}
function handleQuestionCountChange(value: string): void {
const num = Number(value)
if (Number.isInteger(num)) {
setQuestionCount(num)
}
}
function resolveErrorMessage(
errorCode: string | undefined,
message: string | undefined,
fallbackKey: string,
): string {
if (errorCode) {
return t(`errors.${errorCode}`)
}
return message ?? t(fallbackKey)
}
function handleStart(): void {
if (presetMode) {
// 预设模式:直接使用传入的 sourceMeta
@@ -76,12 +137,13 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
questionCount,
}),
)
const res = await createPracticeSessionAction(undefined, formData)
const res = await service.createSession(undefined, formData)
if (res.success && res.data) {
analytics.trackSessionStart(presetMode.type, questionCount)
toast.success(res.message ?? t("toasts.created"))
router.push(`/student/practice/${res.data.sessionId}`)
onSessionCreated?.(res.data.sessionId)
} else {
toast.error(res.message ?? t("toasts.createFailed"))
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
}
})
return
@@ -101,18 +163,19 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
}
} else if (practiceType === "weak_chapter") {
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
// 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点
if (selectedKpIds.length === 0) {
toast.error(t("toasts.selectWeakKnowledgePoint"))
return
}
sourceMeta = {
chapterId: "",
weakKnowledgePointIds: selectedKpIds,
}
} else if (practiceType === "ai_recommended") {
sourceMeta = {
recommendedKnowledgePointIds: selectedKpIds,
reason: t("toasts.aiRecommendedReason"),
// 枚举值业务数据UI 层通过 t(`reasons.${reason}`) 查 i18n
reason: aiRecommendReason,
}
}
@@ -126,23 +189,28 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
questionCount,
}),
)
const res = await createPracticeSessionAction(undefined, formData)
const res = await service.createSession(undefined, formData)
if (res.success && res.data) {
analytics.trackSessionStart(practiceType, questionCount)
toast.success(res.message ?? t("toasts.created"))
router.push(`/student/practice/${res.data.sessionId}`)
onSessionCreated?.(res.data.sessionId)
} else {
toast.error(res.message ?? t("toasts.createFailed"))
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
}
})
}
const showKpSelector = !presetMode || practiceType === "knowledge_point" || practiceType === "weak_chapter" || practiceType === "ai_recommended"
const showKpSelector =
!presetMode ||
practiceType === "knowledge_point" ||
practiceType === "weak_chapter" ||
practiceType === "ai_recommended"
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-primary" />
<Target className="h-5 w-5 text-primary" aria-hidden="true" />
{t("starter.title")}
</CardTitle>
<CardDescription>{t("starter.description")}</CardDescription>
@@ -151,30 +219,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{/* 练习类型选择 */}
{!lockType ? (
<div className="space-y-2">
<Label>{t("starter.type")}</Label>
<Select
value={practiceType}
onValueChange={(v: string) => setPracticeType(v as PracticeType)}
>
<SelectTrigger>
<Label htmlFor="practice-type-select">{t("starter.type")}</Label>
<Select value={practiceType} onValueChange={handleTypeChange}>
<SelectTrigger id="practice-type-select" aria-label={t("starter.type")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="knowledge_point">
<span className="flex items-center gap-2">
<BookOpen className="h-4 w-4" />
<BookOpen className="h-4 w-4" aria-hidden="true" />
{t("types.knowledge_point")}
</span>
</SelectItem>
<SelectItem value="weak_chapter">
<span className="flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<AlertCircle className="h-4 w-4" aria-hidden="true" />
{t("types.weak_chapter")}
</span>
</SelectItem>
<SelectItem value="ai_recommended">
<span className="flex items-center gap-2">
<Sparkles className="h-4 w-4" />
<Sparkles className="h-4 w-4" aria-hidden="true" />
{t("types.ai_recommended")}
</span>
</SelectItem>
@@ -187,21 +252,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{showKpSelector && knowledgePoints.length > 0 ? (
<div className="space-y-2">
<Label>{t("starter.knowledgePoints")}</Label>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2">
{knowledgePoints.map((kp) => (
<div
className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2"
role="group"
aria-label={t("starter.knowledgePoints")}
>
{knowledgePoints.map((kp) => {
const checked = selectedKpIds.includes(kp.id)
return (
<label
key={kp.id}
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
checked={selectedKpIds.includes(kp.id)}
onChange={() => toggleKp(kp.id)}
className="rounded border-input"
<Checkbox
checked={checked}
onCheckedChange={() => toggleKp(kp.id)}
aria-label={kp.name}
/>
<span className="text-sm">{kp.name}</span>
</label>
))}
)
})}
</div>
</div>
) : null}
@@ -209,12 +280,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{/* 难度选择(仅知识点专项) */}
{practiceType === "knowledge_point" && !presetMode ? (
<div className="space-y-2">
<Label>{t("starter.difficulty")}</Label>
<Select
value={String(difficulty)}
onValueChange={(v: string) => setDifficulty(Number(v))}
>
<SelectTrigger>
<Label htmlFor="difficulty-select">{t("starter.difficulty")}</Label>
<Select value={String(difficulty)} onValueChange={handleDifficultyChange}>
<SelectTrigger id="difficulty-select" aria-label={t("starter.difficulty")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -231,12 +299,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{/* 题目数量 */}
<div className="space-y-2">
<Label>{t("starter.questionCount")}</Label>
<Select
value={String(questionCount)}
onValueChange={(v: string) => setQuestionCount(Number(v))}
>
<SelectTrigger>
<Label htmlFor="question-count-select">{t("starter.questionCount")}</Label>
<Select value={String(questionCount)} onValueChange={handleQuestionCountChange}>
<SelectTrigger id="question-count-select" aria-label={t("starter.questionCount")}>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -254,6 +319,7 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
onClick={handleStart}
disabled={isPending}
className="w-full"
aria-label={isPending ? t("starter.creating") : t("starter.start")}
>
{isPending ? t("starter.creating") : t("starter.start")}
</Button>

View File

@@ -0,0 +1,149 @@
"use client"
import { useTranslations } from "next-intl"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { QuestionContent } from "./question-content"
import { AnswerInput } from "./answer-input"
import { AnswerResult } from "./answer-result"
import type { PracticeAnswerRecord } from "../types"
interface QuestionCardProps {
answer: PracticeAnswerRecord
index: number
total: number
userAnswer: unknown
onAnswerChange: (answer: unknown) => void
onSubmit: (answer: unknown) => void
onSkip: () => void
onRetry?: () => void
isAnswered: boolean
result?: { isCorrect: boolean | null; score: number | null }
isPending: boolean
/** 提交是否失败(用于显示重试按钮) */
submitFailed?: boolean
}
/**
* 题目卡片组件。
*
* 展示单道题目及其作答/结果区域,负责:
* - 题目元信息(类型、难度、变式标记)
* - 题目内容(使用 QuestionContent 替代 JSON.stringify
* - 作答输入(未作答时)或结果展示(已作答时)
* - 操作按钮(提交、跳过、重试)
*/
export function QuestionCard({
answer,
index,
total,
userAnswer,
onAnswerChange,
onSubmit,
onSkip,
onRetry,
isAnswered,
result,
isPending,
submitFailed = false,
}: QuestionCardProps): React.ReactNode {
const t = useTranslations("practice")
const question = answer.question
const content = answer.variantContent ?? question?.content
const questionType = question?.type ?? "unknown"
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
{t("session.question")} {index + 1}/{total}
</CardTitle>
<div className="flex items-center gap-2">
<Badge variant="outline" aria-label={`题型: ${questionType}`}>
{questionType}
</Badge>
{question?.difficulty ? (
<Badge variant="secondary" aria-label={`难度: ${question.difficulty}`}>
{t("session.difficulty")}: {question.difficulty}
</Badge>
) : null}
{answer.isVariant ? (
<Badge variant="default" aria-label={t("session.variant")}>
{t("session.variant")}
</Badge>
) : null}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* 题目内容 */}
<div
className="rounded-md border bg-muted/30 p-4 text-sm"
role="region"
aria-label={`题目 ${index + 1}`}
>
<QuestionContent content={content} />
</div>
{/* 作答区域 */}
{!isAnswered ? (
<AnswerInput
questionType={questionType}
content={content}
userAnswer={userAnswer}
onAnswerChange={onAnswerChange}
disabled={isPending}
/>
) : (
<AnswerResult answer={answer} result={result} />
)}
{/* 提交失败提示 + 重试 */}
{submitFailed && !isAnswered ? (
<div
className="rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400"
role="alert"
aria-live="assertive"
>
{t("session.submitFailedDescription")}
</div>
) : null}
{/* 操作按钮 */}
{!isAnswered ? (
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={onSkip}
disabled={isPending}
aria-label={t("session.skip")}
>
{t("session.skip")}
</Button>
{submitFailed && onRetry ? (
<Button
variant="outline"
onClick={onRetry}
disabled={isPending}
aria-label={t("session.retry")}
>
{t("session.retry")}
</Button>
) : null}
<Button
onClick={() => onSubmit(userAnswer)}
disabled={isPending || userAnswer === undefined}
aria-label={t("session.submit")}
>
{isPending ? t("session.submitting") : t("session.submit")}
</Button>
</div>
) : null}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,69 @@
"use client"
import { isRecord } from "@/shared/lib/type-guards"
import { extractOptions } from "../lib/grading"
interface QuestionContentProps {
/** 题目内容(字符串或结构化对象) */
content: unknown
}
/**
* 题目内容渲染器。
*
* 替代原先的 JSON.stringify 兜底渲染,根据内容结构智能展示:
* - 字符串:直接渲染文本
* - 对象含 question/stem/prompt 字段:提取题干文本
* - 对象含 options 字段:渲染选项列表(仅展示,不含交互)
* - 其他:降级为格式化 JSON带 aria-label 供屏幕阅读器)
*/
export function QuestionContent({ content }: QuestionContentProps): React.ReactNode {
// 1. 字符串:直接渲染
if (typeof content === "string") {
return (
<pre
className="whitespace-pre-wrap break-words font-sans"
aria-label="题目内容"
>
{content}
</pre>
)
}
// 2. 结构化对象:尝试提取题干
if (isRecord(content)) {
const stem = content.question ?? content.stem ?? content.prompt ?? content.text
const options = extractOptions(content)
if (typeof stem === "string" || typeof stem === "number") {
return (
<div className="space-y-3" aria-label="题目内容">
<pre className="whitespace-pre-wrap break-words font-sans">
{String(stem)}
</pre>
{options.length > 0 ? (
<ol className="space-y-1 text-sm text-muted-foreground">
{options.map((opt, idx) => (
<li key={opt.id} className="flex gap-2">
<span className="font-medium">{String.fromCharCode(65 + idx)}.</span>
<span>{opt.text}</span>
</li>
))}
</ol>
) : null}
</div>
)
}
}
// 3. 降级:格式化 JSON带语义化标签
return (
<pre
className="whitespace-pre-wrap break-words font-mono text-xs"
aria-label="题目原始内容"
role="region"
>
{JSON.stringify(content, null, 2)}
</pre>
)
}

View File

@@ -5,7 +5,12 @@ import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
import { db } from "@/shared/db"
import { practiceAnswers, practiceSessions, questions, questionsToKnowledgePoints, knowledgePoints } from "@/shared/db/schema"
import { getActiveStudentIdsByClassId, getClassNameById, getClassesByGradeId } from "@/modules/classes/data-access"
import {
getActiveStudentIdsByClassId,
getClassNamesByIds,
getClassNameById,
getClassesByGradeId,
} from "@/modules/classes/data-access"
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
// ---------------------------------------------------------------------------
@@ -54,6 +59,8 @@ export interface PracticeTypeBreakdown {
* 获取班级专项练习统计。
*
* 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。
* 使用单条 SQL 同时计算汇总统计与活跃学生数COUNT(DISTINCT)
* 避免 N+1 查询。
*
* @param classId 班级 ID
*/
@@ -63,12 +70,14 @@ export const getClassPracticeStats = cache(async (
const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return null
// 单条 SQL 同时获取汇总统计与活跃学生数(替代原先两条 SQL
const rows = await db
.select({
totalSessions: count(),
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
})
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
@@ -78,14 +87,7 @@ export const getClassPracticeStats = cache(async (
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
const totalCorrect = Number(row.totalCorrect)
// 统计参与练习的学生数
const activeStudentsResult = await db
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
const activeStudents = Number(row.activeStudents)
return {
classId,
@@ -141,6 +143,9 @@ export const getClassStudentPracticeSummaries = cache(async (
/**
* 获取年级专项练习统计。
*
* 使用单条 SQL 同时计算汇总统计与活跃学生数COUNT(DISTINCT)
* 避免 N+1 查询。
*
* @param gradeId 年级 ID
*/
export const getGradePracticeStats = cache(async (
@@ -162,6 +167,7 @@ export const getGradePracticeStats = cache(async (
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
})
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
@@ -172,18 +178,13 @@ export const getGradePracticeStats = cache(async (
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
const totalCorrect = Number(row.totalCorrect)
const activeStudentsResult = await db
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
return {
totalSessions: Number(row.totalSessions),
completedSessions: Number(row.completedSessions),
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents: Number(activeStudentsResult[0]?.count ?? 0),
activeStudents: Number(row.activeStudents),
}
})
@@ -271,6 +272,10 @@ export interface TeacherClassPracticeOverview {
*
* 用于教师端分析页面顶部展示每个班级的练习情况。
*
* N+1 优化:
* - 批量获取所有班级名称1 条 SQL 替代 N 条)
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL
*
* @param classIds 教师/年级主任可访问的班级 ID 列表
*/
export const getTeacherClassPracticeOverviews = cache(async (
@@ -278,23 +283,30 @@ export const getTeacherClassPracticeOverviews = cache(async (
): Promise<TeacherClassPracticeOverview[]> => {
if (classIds.length === 0) return []
const results = await Promise.all(
classIds.map(async (classId): Promise<TeacherClassPracticeOverview | null> => {
const className = await getClassNameById(classId)
const studentIds = await getActiveStudentIdsByClassId(classId)
// 批量获取所有班级名称1 条 SQL
const classNameMap = await getClassNamesByIds(classIds)
// 并行获取每个班级的学生 ID 列表
const studentIdsPerClass = await Promise.all(
classIds.map(async (classId) => ({
classId,
studentIds: await getActiveStudentIdsByClassId(classId),
})),
)
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
const statsPerClass = await Promise.all(
studentIdsPerClass.map(async ({ classId, studentIds }) => {
const totalStudents = studentIds.length
if (totalStudents === 0) {
return {
classId,
className: className ?? "",
totalStudents: 0,
totalSessions: 0,
completedSessions: 0,
totalQuestionsAnswered: 0,
totalCorrect: 0,
averageAccuracy: 0,
activeStudents: 0,
totalStudents: 0,
participationRate: 0,
}
}
@@ -304,39 +316,41 @@ export const getTeacherClassPracticeOverviews = cache(async (
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
})
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
const row = rows[0]
if (!row) return null
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
const totalCorrect = Number(row.totalCorrect)
const activeStudentsResult = await db
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
return {
classId,
className: className ?? "",
totalSessions: Number(row.totalSessions),
completedSessions: Number(row.completedSessions),
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents,
totalStudents,
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
totalSessions: Number(row?.totalSessions ?? 0),
completedSessions: Number(row?.completedSessions ?? 0),
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
totalCorrect: Number(row?.totalCorrect ?? 0),
activeStudents: Number(row?.activeStudents ?? 0),
}
}),
)
return results.filter((r): r is TeacherClassPracticeOverview => r !== null)
return statsPerClass.map((s) => {
const totalQuestionsAnswered = s.totalQuestionsAnswered
const totalCorrect = s.totalCorrect
const activeStudents = s.activeStudents
return {
classId: s.classId,
className: classNameMap.get(s.classId) ?? "",
totalSessions: s.totalSessions,
completedSessions: s.completedSessions,
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents,
totalStudents: s.totalStudents,
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
}
})
})
// ---------------------------------------------------------------------------
@@ -427,6 +441,10 @@ export interface GradeClassPracticeComparison {
/**
* 获取年级各班级专项练习对比数据。
*
* N+1 优化:
* - 班级列表已含名称,无需额外查询
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL
*
* @param gradeId 年级 ID
*/
export const getGradeClassPracticeComparison = cache(async (
@@ -435,23 +453,27 @@ export const getGradeClassPracticeComparison = cache(async (
const classList = await getClassesByGradeId(gradeId)
if (classList.length === 0) return []
const results = await Promise.all(
classList.map(async (cls): Promise<GradeClassPracticeComparison> => {
const studentIds = await getActiveStudentIdsByClassId(cls.id)
const totalStudents = studentIds.length
// 并行获取每个班级的学生 ID 列表
const studentIdsPerClass = await Promise.all(
classList.map(async (cls) => ({
cls,
studentIds: await getActiveStudentIdsByClassId(cls.id),
})),
)
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
const statsPerClass = await Promise.all(
studentIdsPerClass.map(async ({ cls, studentIds }) => {
const totalStudents = studentIds.length
if (totalStudents === 0) {
return {
classId: cls.id,
className: cls.name,
cls,
totalStudents: 0,
totalSessions: 0,
completedSessions: 0,
totalQuestionsAnswered: 0,
totalCorrect: 0,
averageAccuracy: 0,
activeStudents: 0,
totalStudents: 0,
participationRate: 0,
}
}
@@ -461,35 +483,41 @@ export const getGradeClassPracticeComparison = cache(async (
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
})
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
const row = rows[0]
const totalQuestionsAnswered = Number(row?.totalQuestionsAnswered ?? 0)
const totalCorrect = Number(row?.totalCorrect ?? 0)
const activeStudentsResult = await db
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
return {
classId: cls.id,
className: cls.name,
cls,
totalStudents,
totalSessions: Number(row?.totalSessions ?? 0),
completedSessions: Number(row?.completedSessions ?? 0),
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
totalCorrect: Number(row?.totalCorrect ?? 0),
activeStudents: Number(row?.activeStudents ?? 0),
}
}),
)
const results = statsPerClass.map((s) => {
const totalQuestionsAnswered = s.totalQuestionsAnswered
const totalCorrect = s.totalCorrect
const activeStudents = s.activeStudents
return {
classId: s.cls.id,
className: s.cls.name,
totalSessions: s.totalSessions,
completedSessions: s.completedSessions,
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents,
totalStudents,
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
totalStudents: s.totalStudents,
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
}
}),
)
})
// 按参与率降序排列
return results.sort((a, b) => b.participationRate - a.participationRate)

View File

@@ -10,6 +10,7 @@ import {
questionsToKnowledgePoints,
} from "@/shared/db/schema"
import { isWeakChapterSourceMeta, isAiRecommendedSourceMeta, isKnowledgePointSourceMeta, isErrorVariantSourceMeta } from "./lib/source-meta"
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
// ---------------------------------------------------------------------------
@@ -22,22 +23,25 @@ const DEFAULT_QUESTION_COUNT = 10
/** 薄弱知识点掌握度阈值(低于此值视为薄弱) */
const WEAK_MASTERY_THRESHOLD = 60
/** 自动识别薄弱知识点默认返回数量 */
const DEFAULT_WEAK_KP_LIMIT = 5
// ---------------------------------------------------------------------------
// 出题策略
// ---------------------------------------------------------------------------
/**
* 错题变式练习出题策略
* 错题重做出题策略(原"错题变式"
*
* 从错题本中选取错题,直接使用原题进行练习(不依赖 AI 生成变式题,
* 确保即使 AI 不可用也能练习
* 从错题本中选取错题,直接使用原题进行练习
* 该策略不依赖 AI 生成变式题,确保即使 AI 不可用也能练习。
*
* 策略:
* 1. 从 sourceQuestionIds 中查询题库中存在的题目
* 2. 排除已在该练习会话中的题目(由调用方保证)
* 3. 按难度升序排列(先易后难,建立信心)
*/
async function selectForErrorVariant(
export async function selectForErrorVariant(
sourceMeta: PracticeSourceMeta,
questionCount: number,
): Promise<QuestionSelectionResult> {
@@ -79,7 +83,7 @@ async function selectForErrorVariant(
* 2. 按难度筛选(如指定)
* 3. 随机抽取指定数量
*/
async function selectForKnowledgePoint(
export async function selectForKnowledgePoint(
sourceMeta: PracticeSourceMeta,
questionCount: number,
): Promise<QuestionSelectionResult> {
@@ -125,14 +129,14 @@ async function selectForKnowledgePoint(
/**
* 薄弱章节练习出题策略。
*
* 根据学生掌握度自动识别薄弱知识点,从这些知识点中抽题。
* 行为:
* - 若 sourceMeta.weakKnowledgePointIds 已传入,直接使用
* - 若未传入,调用 identifyWeakKnowledgePoints(studentId, chapterId) 自动识别
*
* 策略:
* 1. 查询学生在指定章节知识点上的掌握度
* 2. 筛选掌握度低于阈值的知识点
* 3. 从薄弱知识点中抽题
* 然后从(自动识别或显式传入的)薄弱知识点中抽题,
* 排除学生已做过的题目避免重复。
*/
async function selectForWeakChapter(
export async function selectForWeakChapter(
studentId: string,
sourceMeta: PracticeSourceMeta,
questionCount: number,
@@ -141,8 +145,15 @@ async function selectForWeakChapter(
return { questionIds: [], variants: new Map() }
}
const { weakKnowledgePointIds } = sourceMeta
if (weakKnowledgePointIds.length === 0) {
const { chapterId, weakKnowledgePointIds } = sourceMeta
// 自动识别薄弱知识点(若未传入)
let kpIds = weakKnowledgePointIds
if (!kpIds || kpIds.length === 0) {
kpIds = await identifyWeakKnowledgePoints(studentId, chapterId, DEFAULT_WEAK_KP_LIMIT)
}
if (kpIds.length === 0) {
return { questionIds: [], variants: new Map() }
}
@@ -151,7 +162,7 @@ async function selectForWeakChapter(
// 从薄弱知识点中抽题
const conditions = [
inArray(questionsToKnowledgePoints.knowledgePointId, weakKnowledgePointIds),
inArray(questionsToKnowledgePoints.knowledgePointId, kpIds),
sql`${questions.parentId} IS NULL`,
]
@@ -185,7 +196,7 @@ async function selectForWeakChapter(
* AI 推荐的知识点列表由上层AI 分析)提供,
* 此函数仅负责从推荐知识点中抽题。
*/
async function selectForAiRecommended(
export async function selectForAiRecommended(
studentId: string,
sourceMeta: PracticeSourceMeta,
questionCount: number,
@@ -274,7 +285,7 @@ export async function selectQuestionsForPractice(
* 从专项练习中汇总已答题目。
* 为控制查询量,仅查询最近 1000 条记录。
*/
async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
const rows = await db
.select({ questionId: practiceAnswers.questionId })
.from(practiceAnswers)
@@ -298,7 +309,7 @@ async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[
export async function identifyWeakKnowledgePoints(
studentId: string,
chapterId?: string,
limit: number = 5,
limit: number = DEFAULT_WEAK_KP_LIMIT,
): Promise<string[]> {
const conditions = [
eq(knowledgePointMastery.studentId, studentId),
@@ -317,27 +328,3 @@ export async function identifyWeakKnowledgePoints(
return rows.map((r) => r.knowledgePointId)
}
// ---------------------------------------------------------------------------
// 类型守卫
// ---------------------------------------------------------------------------
function isErrorVariantSourceMeta(meta: PracticeSourceMeta): meta is { errorBookItemIds: string[]; sourceQuestionIds: string[] } {
return typeof meta === "object" && meta !== null &&
"errorBookItemIds" in meta && "sourceQuestionIds" in meta
}
function isKnowledgePointSourceMeta(meta: PracticeSourceMeta): meta is { knowledgePointIds: string[]; difficulty?: number } {
return typeof meta === "object" && meta !== null &&
"knowledgePointIds" in meta
}
function isWeakChapterSourceMeta(meta: PracticeSourceMeta): meta is { chapterId: string; weakKnowledgePointIds: string[] } {
return typeof meta === "object" && meta !== null &&
"chapterId" in meta && "weakKnowledgePointIds" in meta
}
function isAiRecommendedSourceMeta(meta: PracticeSourceMeta): meta is { recommendedKnowledgePointIds: string[]; reason: string } {
return typeof meta === "object" && meta !== null &&
"recommendedKnowledgePointIds" in meta && "reason" in meta
}

View File

@@ -11,10 +11,17 @@ import {
questions,
} from "@/shared/db/schema"
import { selectQuestionsForPractice } from "./data-access-strategy"
import { autoGradeAnswer } from "./lib/grading"
import {
asPracticeAnswerStatus,
asPracticeSourceMeta,
asPracticeStatus,
asPracticeType,
} from "./lib/type-guards"
import { practiceErrors } from "./lib/errors"
import type {
PracticeAnswerRecord,
PracticeAnswerStatus,
PracticeSessionDetail,
PracticeSessionSummary,
PracticeSourceMeta,
@@ -34,8 +41,8 @@ function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessi
id: row.id,
studentId: row.studentId,
subjectId: row.subjectId,
practiceType: row.practiceType as PracticeType,
status: row.status as PracticeStatus,
practiceType: asPracticeType(row.practiceType),
status: asPracticeStatus(row.status),
totalQuestions: row.totalQuestions,
answeredQuestions,
correctCount,
@@ -56,7 +63,7 @@ function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
variantContent: row.variantContent,
isVariant: row.isVariant,
orderIndex: row.orderIndex,
status: row.status as PracticeAnswerStatus,
status: asPracticeAnswerStatus(row.status),
studentAnswer: row.studentAnswer,
isCorrect: row.isCorrect,
score: row.score,
@@ -170,7 +177,7 @@ export const getPracticeSessionById = cache(async (
return {
...summary,
sourceMeta: session.sourceMeta as PracticeSourceMeta | null,
sourceMeta: asPracticeSourceMeta(session.sourceMeta),
answers: mappedAnswers,
}
})
@@ -213,7 +220,7 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
}
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
practiceType: type as PracticeType,
practiceType: asPracticeType(type),
sessionCount: stat.sessionCount,
totalQuestions: stat.totalQuestions,
correctCount: stat.correctCount,
@@ -241,6 +248,8 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
* 2. 创建会话记录
* 3. 创建答题记录(初始状态为 pending
*
* @throws {PracticeError} 未找到题目时抛 no_questions_found
*
* @returns 会话 ID 和选中的题目数量
*/
export async function createPracticeSession(
@@ -263,7 +272,7 @@ export async function createPracticeSession(
)
if (selection.questionIds.length === 0) {
return { sessionId: "", selectedCount: 0 }
throw practiceErrors.noQuestionsFound()
}
const sessionId = createId()
@@ -276,6 +285,7 @@ export async function createPracticeSession(
studentId,
subjectId: input.subjectId ?? null,
practiceType,
// sourceMeta 已在上层通过 parseSourceMeta 校验,此处直接写入
sourceMeta: sourceMeta as unknown,
status: "in_progress",
totalQuestions: selection.questionIds.length,
@@ -314,6 +324,12 @@ export async function createPracticeSession(
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
* - 填空题暂不自动判分isCorrect = null
*
* 并发安全:整个校验+判分+统计更新流程包裹在事务中,
* 对答题记录加行锁SELECT ... FOR UPDATE
* 防止同一答案被并发重复判分导致统计累加错误。
*
* @throws {PracticeError} 会话/答题记录不存在、已结束、已作答时抛对应错误码
*
* @returns 是否判分成功
*/
export async function submitPracticeAnswer(
@@ -323,43 +339,49 @@ export async function submitPracticeAnswer(
answer: unknown,
skip: boolean = false,
): Promise<{ isCorrect: boolean | null; score: number | null }> {
// 校验会话归属
const session = await db.query.practiceSessions.findFirst({
where: and(
// 事务:行锁 + 校验 + 判分 + 统计更新(防止并发重复判分)
return await db.transaction(async (tx) => {
// 1. 校验会话归属(带行锁)
const [session] = await tx
.select()
.from(practiceSessions)
.where(and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
),
})
))
.for("update")
if (!session) {
throw new Error("练习会话不存在或无权访问")
throw practiceErrors.sessionNotFound()
}
if (session.status !== "in_progress") {
throw new Error("练习会话已结束")
throw practiceErrors.sessionEnded()
}
// 查询答题记录
const answerRecord = await db.query.practiceAnswers.findFirst({
where: and(
// 2. 查询答题记录(带行锁,防止并发重复提交)
const [answerRecord] = await tx
.select()
.from(practiceAnswers)
.where(and(
eq(practiceAnswers.id, answerId),
eq(practiceAnswers.sessionId, sessionId),
),
})
))
.for("update")
if (!answerRecord) {
throw new Error("答题记录不存在")
throw practiceErrors.answerNotFound()
}
if (answerRecord.status === "answered") {
throw new Error("此题已作答")
throw practiceErrors.answerAlreadySubmitted()
}
const now = new Date()
if (skip) {
// 跳过此题
await db
// 跳过此题:状态置为 skipped不累加已答题数与正确数
await tx
.update(practiceAnswers)
.set({
status: "skipped",
@@ -367,18 +389,26 @@ export async function submitPracticeAnswer(
})
.where(eq(practiceAnswers.id, answerId))
// 更新会话统计
await updateSessionStats(sessionId, 0, false)
// 累加已答题数(不累加正确数)
await tx
.update(practiceSessions)
.set({
answeredQuestions: session.answeredQuestions + 1,
})
.where(eq(practiceSessions.id, sessionId))
return { isCorrect: null, score: null }
}
// 自动判分:查询题目内容并提取正确答案
const question = await db.query.questions.findFirst({
where: eq(questions.id, answerRecord.questionId),
})
// 3. 自动判分:查询题目内容并提取正确答案
const [question] = await tx
.select()
.from(questions)
.where(eq(questions.id, answerRecord.questionId))
.limit(1)
if (!question) {
throw new Error("题目不存在")
throw practiceErrors.questionNotFound()
}
// 如果是变式题,使用变式题内容
@@ -387,7 +417,8 @@ export async function submitPracticeAnswer(
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
await db
// 4. 更新答题记录
await tx
.update(practiceAnswers)
.set({
status: "answered",
@@ -398,20 +429,33 @@ export async function submitPracticeAnswer(
})
.where(eq(practiceAnswers.id, answerId))
// 更新会话统计
await updateSessionStats(
sessionId,
1,
isCorrect === true,
)
// 5. 累加会话统计(基于步骤 1 已加锁的 session 行)
await tx
.update(practiceSessions)
.set({
answeredQuestions: session.answeredQuestions + 1,
correctCount: session.correctCount + (isCorrect === true ? 1 : 0),
})
.where(eq(practiceSessions.id, sessionId))
return { isCorrect, score }
})
}
// ---------------------------------------------------------------------------
// 写入:完成/放弃练习会话
// ---------------------------------------------------------------------------
/**
* 完成练习会话。
*
* 完整性校验必须答完所有题目answeredQuestions === totalQuestions才能完成
* 防止学生提前完成导致统计失真。
*
* 注意:跳过的题目也算"已作答"status=skipped与 answeredQuestions 累加逻辑一致。
*
* @throws {PracticeError} 会话不存在 → session_not_found未答完 → session_not_complete
*/
export async function completePracticeSession(
sessionId: string,
studentId: string,
@@ -424,13 +468,19 @@ export async function completePracticeSession(
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
throw practiceErrors.sessionNotFound()
}
if (session.status !== "in_progress") {
// 已完成或已放弃,幂等返回
return
}
// 完整性校验:必须答完所有题目
if (session.answeredQuestions !== session.totalQuestions) {
throw practiceErrors.sessionNotComplete()
}
await db
.update(practiceSessions)
.set({
@@ -440,6 +490,13 @@ export async function completePracticeSession(
.where(eq(practiceSessions.id, sessionId))
}
/**
* 放弃练习会话。
*
* 幂等:已完成或已放弃的会话再次调用不会报错。
*
* @throws {PracticeError} 会话不存在 → session_not_found
*/
export async function abandonPracticeSession(
sessionId: string,
studentId: string,
@@ -452,10 +509,11 @@ export async function abandonPracticeSession(
})
if (!session) {
throw new Error("练习会话不存在或无权访问")
throw practiceErrors.sessionNotFound()
}
if (session.status !== "in_progress") {
// 已完成或已放弃,幂等返回
return
}
@@ -472,148 +530,5 @@ export async function abandonPracticeSession(
// 内部辅助函数
// ---------------------------------------------------------------------------
/**
* 更新会话统计(已答题数、正确数)
*/
async function updateSessionStats(
sessionId: string,
newlyAnswered: number,
newlyCorrect: boolean,
): Promise<void> {
const session = await db.query.practiceSessions.findFirst({
where: eq(practiceSessions.id, sessionId),
columns: {
answeredQuestions: true,
correctCount: true,
},
})
if (!session) return
await db
.update(practiceSessions)
.set({
answeredQuestions: session.answeredQuestions + newlyAnswered,
correctCount: session.correctCount + (newlyCorrect ? 1 : 0),
})
.where(eq(practiceSessions.id, sessionId))
}
/**
* 自动判分:比对学生答案与正确答案。
*
* 支持题型:
* - single_choice: 比对选中选项 ID
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
* - judgment: 比对布尔值
* - text: 不自动判分(返回 null
*
* @param questionType 题目类型
* @param content 题目内容(或变式题内容)
* @param studentAnswer 学生答案
* @returns 是否正确null 表示无法自动判分)
*/
function autoGradeAnswer(
questionType: string,
content: unknown,
studentAnswer: unknown,
): boolean | null {
if (questionType === "single_choice" || questionType === "multiple_choice") {
const correctIds = extractChoiceCorrectIds(content)
if (correctIds.length === 0) return null
const studentIds = normalizeAnswerToIds(studentAnswer)
if (studentIds.length === 0) return false
if (questionType === "single_choice") {
return studentIds.length === 1 && studentIds[0] === correctIds[0]
}
// multiple_choice: 集合比对
if (studentIds.length !== correctIds.length) return false
const correctSet = new Set(correctIds)
return studentIds.every((id) => correctSet.has(id))
}
if (questionType === "judgment") {
const correctAnswer = extractJudgmentCorrectAnswer(content)
if (correctAnswer === null) return null
const studentBool = normalizeAnswerToBool(studentAnswer)
if (studentBool === null) return null
return studentBool === correctAnswer
}
// text 题型不自动判分
return null
}
/**
* 从题目内容中提取选择题正确选项 ID 列表。
*/
function extractChoiceCorrectIds(content: unknown): string[] {
if (!isRecord(content)) return []
const options = content.options
if (!Array.isArray(options)) return []
return options
.filter((opt: unknown) => isRecord(opt) && opt.isCorrect === true)
.map((opt: unknown) => {
const record = opt as Record<string, unknown>
return typeof record.id === "string" ? record.id : ""
})
.filter((id: string) => id.length > 0)
}
/**
* 从题目内容中提取判断题正确答案。
*/
function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
if (!isRecord(content)) return null
const answer = content.answer
if (typeof answer === "boolean") return answer
if (typeof answer === "string") {
const lower = answer.toLowerCase()
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
}
return null
}
/**
* 将学生答案归一化为选项 ID 列表。
*/
function normalizeAnswerToIds(answer: unknown): string[] {
if (typeof answer === "string") return [answer]
if (Array.isArray(answer)) {
return answer.filter((v): v is string => typeof v === "string")
}
if (isRecord(answer)) {
const ids = answer.selectedIds
if (Array.isArray(ids)) {
return ids.filter((v): v is string => typeof v === "string")
}
if (typeof answer.id === "string") return [answer.id]
}
return []
}
/**
* 将学生答案归一化为布尔值。
*/
function normalizeAnswerToBool(answer: unknown): boolean | null {
if (typeof answer === "boolean") return answer
if (typeof answer === "string") {
const lower = answer.toLowerCase()
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
}
return null
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
// 自动判分、答案归一化等纯函数已抽取至 lib/grading.ts便于单测与复用。
// 会话统计累加逻辑已内联到 submitPracticeSession 事务中,确保原子性

View File

@@ -0,0 +1,13 @@
/**
* Adaptive Practice 答题相关工具函数。
*/
/**
* 判断值是否为非空字符串数组。
*
* 用于 multiple_choice 题型的学生答案类型收窄,
* 替代 `as string[]` 断言。
*/
export function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string")
}

View File

@@ -0,0 +1,58 @@
/**
* Adaptive Practice 模块错误类型。
*
* 用结构化错误码替代硬编码中文 message
* 前端通过 errorCode 查 i18n 翻译键展示。
*
* 继承 BusinessError使 handleActionError 能自动保留 errorCode。
*/
import { BusinessError } from "@/shared/lib/action-utils"
export const PRACTICE_ERROR_CODES = [
"session_not_found",
"session_ended",
"answer_not_found",
"answer_already_submitted",
"question_not_found",
"session_not_complete",
"no_questions_found",
"forbidden",
] as const
export type PracticeErrorCode = (typeof PRACTICE_ERROR_CODES)[number]
/**
* 业务错误:携带 errorCode 的错误类。
*
* 用于 data-access 层抛错actions 层通过 handleActionError 捕获后
* 转换为 ActionState.errorCode 返回前端,前端 t(`errors.${errorCode}`) 查翻译。
*/
export class PracticeError extends BusinessError {
readonly code: PracticeErrorCode
constructor(code: PracticeErrorCode) {
// message 留空,前端通过 errorCode 查 i18n 文案
super(code, code)
this.name = "PracticeError"
this.code = code
}
}
export function isPracticeError(e: unknown): e is PracticeError {
return e instanceof PracticeError
}
/**
* 便捷工厂函数。
*/
export const practiceErrors = {
sessionNotFound: () => new PracticeError("session_not_found"),
sessionEnded: () => new PracticeError("session_ended"),
answerNotFound: () => new PracticeError("answer_not_found"),
answerAlreadySubmitted: () => new PracticeError("answer_already_submitted"),
questionNotFound: () => new PracticeError("question_not_found"),
sessionNotComplete: () => new PracticeError("session_not_complete"),
noQuestionsFound: () => new PracticeError("no_questions_found"),
forbidden: () => new PracticeError("forbidden"),
}

View File

@@ -0,0 +1,142 @@
/**
* Adaptive Practice 自动判分纯函数。
*
* 从 data-access.ts 抽取为独立模块,便于单测与复用。
*
* 支持题型:
* - single_choice: 比对选中选项 ID
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
* - judgment: 比对布尔值
* - text: 不自动判分(返回 null
*/
import { isRecord } from "@/shared/lib/type-guards"
/**
* 自动判分:比对学生答案与正确答案。
*
* @param questionType 题目类型
* @param content 题目内容(或变式题内容)
* @param studentAnswer 学生答案
* @returns 是否正确null 表示无法自动判分)
*/
export function autoGradeAnswer(
questionType: string,
content: unknown,
studentAnswer: unknown,
): boolean | null {
if (questionType === "single_choice" || questionType === "multiple_choice") {
const correctIds = extractChoiceCorrectIds(content)
if (correctIds.length === 0) return null
const studentIds = normalizeAnswerToIds(studentAnswer)
if (studentIds.length === 0) return false
if (questionType === "single_choice") {
return studentIds.length === 1 && studentIds[0] === correctIds[0]
}
// multiple_choice: 集合比对
if (studentIds.length !== correctIds.length) return false
const correctSet = new Set(correctIds)
return studentIds.every((id) => correctSet.has(id))
}
if (questionType === "judgment") {
const correctAnswer = extractJudgmentCorrectAnswer(content)
if (correctAnswer === null) return null
const studentBool = normalizeAnswerToBool(studentAnswer)
if (studentBool === null) return null
return studentBool === correctAnswer
}
// text 题型不自动判分
return null
}
/**
* 从题目内容中提取选择题正确选项 ID 列表。
*/
export function extractChoiceCorrectIds(content: unknown): string[] {
if (!isRecord(content)) return []
const options = content.options
if (!Array.isArray(options)) return []
return options
.filter((opt: unknown): opt is Record<string, unknown> =>
isRecord(opt) && opt.isCorrect === true,
)
.map((opt) => {
const id = opt.id
return typeof id === "string" ? id : ""
})
.filter((id) => id.length > 0)
}
/**
* 从题目内容中提取判断题正确答案。
*/
export function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
if (!isRecord(content)) return null
const answer = content.answer
if (typeof answer === "boolean") return answer
if (typeof answer === "string") {
const lower = answer.toLowerCase()
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
}
return null
}
/**
* 将学生答案归一化为选项 ID 列表。
*/
export function normalizeAnswerToIds(answer: unknown): string[] {
if (typeof answer === "string") return [answer]
if (Array.isArray(answer)) {
return answer.filter((v): v is string => typeof v === "string")
}
if (isRecord(answer)) {
const ids = answer.selectedIds
if (Array.isArray(ids)) {
return ids.filter((v): v is string => typeof v === "string")
}
if (typeof answer.id === "string") return [answer.id]
}
return []
}
/**
* 将学生答案归一化为布尔值。
*/
export function normalizeAnswerToBool(answer: unknown): boolean | null {
if (typeof answer === "boolean") return answer
if (typeof answer === "string") {
const lower = answer.toLowerCase()
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
}
return null
}
/**
* 从题目内容中提取选项列表(用于 UI 渲染)。
*/
export function extractOptions(content: unknown): Array<{ id: string; text: string }> {
if (!isRecord(content)) return []
const options = content.options
if (!Array.isArray(options)) return []
return options
.filter((opt): opt is Record<string, unknown> =>
typeof opt === "object" && opt !== null && typeof opt.id === "string",
)
.map((opt) => ({
id: opt.id as string,
text: typeof opt.text === "string" ? opt.text : String(opt.text ?? ""),
}))
}

View File

@@ -0,0 +1,114 @@
/**
* Adaptive Practice 来源元数据类型守卫。
*
* 从 data-access-strategy.ts 抽取为独立模块,便于单测与跨模块复用。
*
* 严格校验 PracticeSourceMeta 联合类型的字段结构,
* 替代原先仅检查 key 存在性的弱校验。
*/
import { isRecord } from "@/shared/lib/type-guards"
import type {
AiRecommendedSourceMeta,
ErrorVariantSourceMeta,
KnowledgePointSourceMeta,
PracticeSourceMeta,
WeakChapterSourceMeta,
} from "../types"
/**
* 判断值是否为非空字符串数组。
*/
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string")
}
/**
* 判断 sourceMeta 是否为错题重做类型。
*
* 严格校验 errorBookItemIds 与 sourceQuestionIds 均为非空 string[]。
*/
export function isErrorVariantSourceMeta(meta: unknown): meta is ErrorVariantSourceMeta {
if (!isRecord(meta)) return false
return isStringArray(meta.errorBookItemIds) && isStringArray(meta.sourceQuestionIds)
}
/**
* 判断 sourceMeta 是否为知识点专项类型。
*
* 严格校验 knowledgePointIds 为非空 string[]difficulty 为可选 1-5 数字。
*/
export function isKnowledgePointSourceMeta(meta: unknown): meta is KnowledgePointSourceMeta {
if (!isRecord(meta)) return false
if (!isStringArray(meta.knowledgePointIds)) return false
if (meta.difficulty !== undefined) {
if (typeof meta.difficulty !== "number") return false
if (!Number.isInteger(meta.difficulty)) return false
if (meta.difficulty < 1 || meta.difficulty > 5) return false
}
return true
}
/**
* 判断 sourceMeta 是否为薄弱章节类型。
*
* chapterId 可选——未传时跨所有章节自动识别薄弱知识点;
* 若传入必须为非空 string。
* weakKnowledgePointIds 可选——未传时由 data-access 自动识别;
* 若存在必须为非空 string[]。
*/
export function isWeakChapterSourceMeta(meta: unknown): meta is WeakChapterSourceMeta {
if (!isRecord(meta)) return false
// chapterId 可选:未传 → undefined若传必须为非空 string
if (meta.chapterId !== undefined) {
if (typeof meta.chapterId !== "string" || meta.chapterId.length === 0) return false
}
// weakKnowledgePointIds 可选:未传 → undefined若传必须为非空 string[]
if (meta.weakKnowledgePointIds === undefined) return true
return isStringArray(meta.weakKnowledgePointIds)
}
/** AI 推荐理由枚举集合(与 AiRecommendedReason 类型对齐) */
const AI_RECOMMENDED_REASONS = new Set([
"student_initiated",
"teacher_assigned",
"parent_suggested",
])
/**
* 判断 sourceMeta 是否为 AI 推荐类型。
*
* 严格校验 recommendedKnowledgePointIds 为非空 string[]
* reason 为 AiRecommendedReason 枚举值业务数据UI 层翻译)。
*/
export function isAiRecommendedSourceMeta(meta: unknown): meta is AiRecommendedSourceMeta {
if (!isRecord(meta)) return false
if (!isStringArray(meta.recommendedKnowledgePointIds)) return false
return typeof meta.reason === "string" && AI_RECOMMENDED_REASONS.has(meta.reason)
}
/**
* 根据练习类型字符串尝试收窄 sourceMeta。
*
* 用于在 actions 层替代 `as unknown as PracticeSourceMeta` 断言。
*
* @returns 收窄后的 sourceMeta如果类型不匹配返回 null。
*/
export function parseSourceMeta(
practiceType: string,
raw: unknown,
): PracticeSourceMeta | null {
switch (practiceType) {
case "error_variant":
return isErrorVariantSourceMeta(raw) ? raw : null
case "knowledge_point":
return isKnowledgePointSourceMeta(raw) ? raw : null
case "weak_chapter":
return isWeakChapterSourceMeta(raw) ? raw : null
case "ai_recommended":
return isAiRecommendedSourceMeta(raw) ? raw : null
default:
return null
}
}

View File

@@ -0,0 +1,105 @@
/**
* Adaptive Practice 练习类型与状态类型守卫。
*
* 替代 data-access.ts 中的 `as` 断言,从 DB 取出的 enum 字段
* 通过类型守卫严格校验后再使用。
*/
import {
isAiRecommendedSourceMeta,
isErrorVariantSourceMeta,
isKnowledgePointSourceMeta,
isWeakChapterSourceMeta,
} from "./source-meta"
import type {
PracticeAnswerStatus,
PracticeSourceMeta,
PracticeStatus,
PracticeType,
} from "../types"
const PRACTICE_TYPES = new Set<PracticeType>([
"error_variant",
"knowledge_point",
"weak_chapter",
"ai_recommended",
])
const PRACTICE_STATUSES = new Set<PracticeStatus>([
"in_progress",
"completed",
"abandoned",
])
const PRACTICE_ANSWER_STATUSES = new Set<PracticeAnswerStatus>([
"pending",
"answered",
"skipped",
])
export function isPracticeType(value: unknown): value is PracticeType {
return typeof value === "string" && PRACTICE_TYPES.has(value as PracticeType)
}
export function isPracticeStatus(value: unknown): value is PracticeStatus {
return typeof value === "string" && PRACTICE_STATUSES.has(value as PracticeStatus)
}
export function isPracticeAnswerStatus(value: unknown): value is PracticeAnswerStatus {
return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value as PracticeAnswerStatus)
}
/**
* 安全转换 DB 行的 practiceType 字段。
*
* @throws {Error} 当值不是合法 PracticeType
*/
export function asPracticeType(value: unknown): PracticeType {
if (!isPracticeType(value)) {
throw new Error(`Invalid practice type: ${String(value)}`)
}
return value
}
/**
* 安全转换 DB 行的 status 字段。
*
* @throws {Error} 当值不是合法 PracticeStatus
*/
export function asPracticeStatus(value: unknown): PracticeStatus {
if (!isPracticeStatus(value)) {
throw new Error(`Invalid practice status: ${String(value)}`)
}
return value
}
/**
* 安全转换 DB 行的 answer status 字段。
*
* @throws {Error} 当值不是合法 PracticeAnswerStatus
*/
export function asPracticeAnswerStatus(value: unknown): PracticeAnswerStatus {
if (!isPracticeAnswerStatus(value)) {
throw new Error(`Invalid practice answer status: ${String(value)}`)
}
return value
}
/**
* 安全转换 DB 行的 sourceMeta 字段。
*
* 不假设 practiceType 上下文,依次尝试四种来源元数据类型守卫,
* 任意一个通过即返回收窄后的值DB 为 null 或结构不合法时返回 null。
*
* 替代 `value as PracticeSourceMeta` 断言,避免脏数据导致运行时崩溃。
*/
export function asPracticeSourceMeta(value: unknown): PracticeSourceMeta | null {
if (value === null || value === undefined) return null
if (isErrorVariantSourceMeta(value)) return value
if (isKnowledgePointSourceMeta(value)) return value
if (isWeakChapterSourceMeta(value)) return value
if (isAiRecommendedSourceMeta(value)) return value
// 结构不匹配任何已知类型,视为损坏数据
return null
}

View File

@@ -14,29 +14,83 @@ export const PracticeTypeSchema = z.enum([
export const PracticeAnswerStatusSchema = z.enum(["pending", "answered", "skipped"])
// ---------------------------------------------------------------------------
// 来源元数据验证
// 来源元数据验证(判别式联合类型,替代 z.record(z.string(), z.unknown())
// ---------------------------------------------------------------------------
/**
* 错题重做练习来源。
*
* practiceType="error_variant" 时使用。
*/
export const ErrorVariantSourceMetaSchema = z.object({
errorBookItemIds: z.array(z.string().min(1)).min(1),
sourceQuestionIds: z.array(z.string().min(1)).min(1),
})
/**
* 知识点专项练习来源。
*
* practiceType="knowledge_point" 时使用。
*/
export const KnowledgePointSourceMetaSchema = z.object({
knowledgePointIds: z.array(z.string().min(1)).min(1),
difficulty: z.number().int().min(1).max(5).optional(),
})
/**
* 薄弱章节练习来源。
*
* practiceType="weak_chapter" 时使用。
* chapterId 可选——未传时跨所有章节自动识别薄弱知识点。
* weakKnowledgePointIds 可选——未传时由后端自动识别薄弱知识点。
*/
export const WeakChapterSourceMetaSchema = z.object({
chapterId: z.string().min(1),
weakKnowledgePointIds: z.array(z.string().min(1)).min(1),
chapterId: z.string().min(1).optional(),
weakKnowledgePointIds: z.array(z.string().min(1)).min(1).optional(),
})
/**
* AI 推荐练习来源。
*
* practiceType="ai_recommended" 时使用。
* reason 为枚举字符串(业务数据,不应直接存翻译文本)。
*/
export const AiRecommendedReasonSchema = z.enum([
"student_initiated",
"teacher_assigned",
"parent_suggested",
])
export const AiRecommendedSourceMetaSchema = z.object({
recommendedKnowledgePointIds: z.array(z.string().min(1)).min(1),
reason: z.string().min(1),
reason: AiRecommendedReasonSchema,
})
/**
* 判别式联合类型 schema。
*
* 通过 practiceType 字段判别,确保 sourceMeta 结构与练习类型匹配。
* 替代原先的 z.record(z.string(), z.unknown()) 弱类型校验。
*/
export const PracticeSourceMetaSchema = z.discriminatedUnion("practiceType", [
z.object({
practiceType: z.literal("error_variant"),
sourceMeta: ErrorVariantSourceMetaSchema,
}),
z.object({
practiceType: z.literal("knowledge_point"),
sourceMeta: KnowledgePointSourceMetaSchema,
}),
z.object({
practiceType: z.literal("weak_chapter"),
sourceMeta: WeakChapterSourceMetaSchema,
}),
z.object({
practiceType: z.literal("ai_recommended"),
sourceMeta: AiRecommendedSourceMetaSchema,
}),
])
// ---------------------------------------------------------------------------
// Action 输入验证
// ---------------------------------------------------------------------------
@@ -72,3 +126,4 @@ export type CreatePracticeSessionInput = z.infer<typeof CreatePracticeSessionSch
export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema>
export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema>
export type AbandonPracticeSessionInput = z.infer<typeof AbandonPracticeSessionSchema>
export type AiRecommendedReason = z.infer<typeof AiRecommendedReasonSchema>

View File

@@ -0,0 +1,160 @@
"use client"
import { createContext, useContext, type ReactNode } from "react"
import type { ActionState } from "@/shared/types/action-state"
import {
abandonPracticeSessionAction,
completePracticeSessionAction,
createPracticeSessionAction,
getPracticeSessionDetailAction,
getPracticeSessionsAction,
getPracticeStatsAction,
submitPracticeAnswerAction,
} from "../actions"
import type {
PracticeSessionDetail,
PracticeSessionSummary,
PracticeStats,
} from "../types"
// ─── 数据服务接口 ──────────────────────────────────────────
/**
* 专项练习数据服务接口(抽象数据依赖)。
*
* 组件层通过 `usePracticeService()` 消费此接口,而非直接 import actions
* 实现完全解耦:
* - 默认实现调用真实 Server Actions见下方 `defaultPracticeService`
* - 测试时可注入 mock 实现以隔离数据层
* - 不同角色student / parent / teacher可注入不同实现
* 例如 ParentPracticeService 在调用时自动附加子女 studentId
*
* 所有写入方法签名与 Server Action 保持一致prevState + formData
* 便于无缝替换;读取方法直接返回数据,由 Server Action 内部处理权限校验。
*/
export interface PracticeService {
/** 创建练习会话 */
createSession(
prevState: ActionState<{ sessionId: string; selectedCount: number }> | undefined,
formData: FormData,
): Promise<ActionState<{ sessionId: string; selectedCount: number }>>
/** 提交单题答案 */
submitAnswer(
prevState: ActionState<{ isCorrect: boolean | null; score: number | null }> | undefined,
formData: FormData,
): Promise<ActionState<{ isCorrect: boolean | null; score: number | null }>>
/** 完成练习会话 */
completeSession(
prevState: ActionState<void> | undefined,
formData: FormData,
): Promise<ActionState<void>>
/** 放弃练习会话 */
abandonSession(
prevState: ActionState<void> | undefined,
formData: FormData,
): Promise<ActionState<void>>
/** 获取练习会话列表 */
getSessions(
studentId?: string,
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>>
/** 获取练习会话详情(含答题记录) */
getSessionDetail(
sessionId: string,
studentId?: string,
): Promise<ActionState<PracticeSessionDetail>>
/** 获取练习统计 */
getStats(studentId?: string): Promise<ActionState<PracticeStats>>
}
// ─── 监控埋点接口 ──────────────────────────────────────────
/**
* 专项练习监控埋点接口。
*
* 预留关键操作埋点,供后续接入实际监控 SDK如 PostHog / Mixpanel
* 默认实现为空操作(见 `noopPracticeAnalytics`),生产环境通过
* Provider 注入实际实现。
*/
export interface PracticeAnalytics {
/** 练习会话创建 */
trackSessionStart(practiceType: string, questionCount: number): void
/** 单题答案提交 */
trackAnswerSubmit(sessionId: string, answerId: string, isCorrect: boolean | null): void
/** 练习会话完成 */
trackSessionComplete(sessionId: string, accuracy: number): void
/** 练习会话放弃 */
trackSessionAbandon(sessionId: string): void
/** 提交失败重试 */
trackErrorRetry(answerId: string): void
}
const noopPracticeAnalytics: PracticeAnalytics = {
trackSessionStart: () => {},
trackAnswerSubmit: () => {},
trackSessionComplete: () => {},
trackSessionAbandon: () => {},
trackErrorRetry: () => {},
}
// ─── 默认实现(代理到真实 Server Actions ──────────────────
/**
* 默认 PracticeService 实现:直接代理到真实 Server Actions。
*
* 测试或角色定制时可在 Provider 中注入其他实现以覆盖此默认行为。
*/
export const defaultPracticeService: PracticeService = {
createSession: (prevState, formData) => createPracticeSessionAction(prevState, formData),
submitAnswer: (prevState, formData) => submitPracticeAnswerAction(prevState, formData),
completeSession: (prevState, formData) => completePracticeSessionAction(prevState, formData),
abandonSession: (prevState, formData) => abandonPracticeSessionAction(prevState, formData),
getSessions: (studentId) => getPracticeSessionsAction(studentId),
getSessionDetail: (sessionId, studentId) =>
getPracticeSessionDetailAction(sessionId, studentId),
getStats: (studentId) => getPracticeStatsAction(studentId),
}
// ─── React Context 依赖注入 ────────────────────────────────
const PracticeServiceContext = createContext<PracticeService | null>(null)
const PracticeAnalyticsContext = createContext<PracticeAnalytics>(noopPracticeAnalytics)
interface PracticeServiceProviderProps {
/** 注入的服务实现;不传则使用默认实现(调用真实 Server Actions */
service?: PracticeService
/** 注入的监控埋点实现;不传则使用空操作 */
analytics?: PracticeAnalytics
children: ReactNode
}
/**
* 专项练习服务 Provider在页面层注入角色特定的实现
*
* 未包裹 Provider 时 `usePracticeService()` 回退到默认实现,保证向后兼容。
*/
export function PracticeServiceProvider({
service,
analytics,
children,
}: PracticeServiceProviderProps): ReactNode {
return (
<PracticeServiceContext.Provider value={service ?? defaultPracticeService}>
<PracticeAnalyticsContext.Provider value={analytics ?? noopPracticeAnalytics}>
{children}
</PracticeAnalyticsContext.Provider>
</PracticeServiceContext.Provider>
)
}
/** 获取当前注入的专项练习数据服务 */
export function usePracticeService(): PracticeService {
const ctx = useContext(PracticeServiceContext)
return ctx ?? defaultPracticeService
}
/** 获取当前注入的监控埋点接口 */
export function usePracticeAnalytics(): PracticeAnalytics {
return useContext(PracticeAnalyticsContext)
}

View File

@@ -33,19 +33,28 @@ export interface KnowledgePointSourceMeta {
/** 薄弱章节练习来源 */
export interface WeakChapterSourceMeta {
chapterId: string
/** 自动识别的薄弱知识点 */
weakKnowledgePointIds: string[]
/** 章节 ID可选未传时跨所有章节自动识别薄弱知识点 */
chapterId?: string
/** 自动识别的薄弱知识点(可选;未传时由 data-access 自动识别) */
weakKnowledgePointIds?: string[]
}
/** AI 推荐练习来源 */
export interface AiRecommendedSourceMeta {
/** AI 推荐的知识点列表 */
recommendedKnowledgePointIds: string[]
/** 推荐理由 */
reason: string
/** 推荐理由枚举值UI 层翻译) */
reason: AiRecommendedReason
}
/**
* AI 推荐理由枚举。
*
* 业务数据存数据库UI 层通过 t(`reasons.${reason}`) 查 i18n。
* 严禁存翻译文本到数据库。
*/
export type AiRecommendedReason = "student_initiated" | "teacher_assigned" | "parent_suggested"
export type PracticeSourceMeta =
| ErrorVariantSourceMeta
| KnowledgePointSourceMeta

View File

@@ -18,6 +18,7 @@ import {
WeaknessAnalysisInputSchema,
ChildSummaryInputSchema,
StudyPathInputSchema,
ExplainErrorInputSchema,
} from "./schema"
import type {
AiChatMessage,
@@ -37,6 +38,8 @@ import type {
StudyPathInput,
StudyPathResult,
AiUsageStats,
ExplainErrorInput,
ExplainErrorResult,
} from "./types"
// ---------------------------------------------------------------------------
@@ -327,15 +330,14 @@ export async function recommendStudyPathAction(
// 同步填充 currentMastery若未传入
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
serviceInput.currentMastery = kps
.filter((kp) => masteryMap.has(kp.id))
.map((kp) => {
const m = masteryMap.get(kp.id)!
return {
serviceInput.currentMastery = kps.flatMap((kp) => {
const m = masteryMap.get(kp.id)
if (!m) return []
return [{
knowledgePoint: kp.name,
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
errorCount: m.totalQuestions - m.correctQuestions,
}
}]
})
}
}
@@ -376,6 +378,38 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
return { success: false, message: t("error.chatFailed") }
return { success: false, message: t("error.statsFailed") }
}
}
// ---------------------------------------------------------------------------
// 错题 AI 解释P2-1 新增)
// ---------------------------------------------------------------------------
export async function explainErrorAction(
input: ExplainErrorInput
): Promise<ActionState<ExplainErrorResult>> {
const t = await getTranslations("ai")
try {
const ctx = await requireAiPermission(
Permissions.AI_CHAT,
Permissions.ERROR_BOOK_READ
)
const parsed = ExplainErrorInputSchema.safeParse(input)
if (!parsed.success) {
return { success: false, message: t("error.invalidInput") }
}
const service = createAiService(ctx.userId)
const result = await safeAiCall(() => service.explainError(parsed.data))
if (!result.ok) {
return { success: false, message: result.message }
}
return { success: true, data: result.data }
} catch (error) {
if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message }
}
return { success: false, message: t("error.analysisFailed") }
}
}

View File

@@ -229,11 +229,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
contextMessage: "Current page: Homework grading view",
contextMessage: t("chat.contextMessage.teacherGrading"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.0"),
"What are common mistakes in this type of question?",
"How should I give constructive feedback?",
t("chat.suggestedPrompts.context.teacherGrading.0"),
t("chat.suggestedPrompts.context.teacherGrading.1"),
],
}
}
@@ -243,11 +243,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
contextMessage: "Current page: Lesson plan editor",
contextMessage: t("chat.contextMessage.teacherLesson"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.1"),
"Suggest a hook for this lesson",
"What are some differentiation strategies?",
t("chat.suggestedPrompts.context.teacherLesson.0"),
t("chat.suggestedPrompts.context.teacherLesson.1"),
],
}
}
@@ -257,11 +257,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
contextMessage: "Current page: Exam builder",
contextMessage: t("chat.contextMessage.teacherExam"),
suggestedPrompts: [
t("chat.suggestedPrompts.teacher.2"),
"Generate a question on this topic",
"Analyze the difficulty distribution",
t("chat.suggestedPrompts.context.teacherExam.0"),
t("chat.suggestedPrompts.context.teacherExam.1"),
],
}
}
@@ -271,7 +271,7 @@ function inferContextFromPath(
return {
systemPrompt:
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
contextMessage: "Current page: Error book (student view)",
contextMessage: t("chat.contextMessage.studentErrorBook"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.student.1"),
@@ -285,11 +285,11 @@ function inferContextFromPath(
return {
systemPrompt:
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
contextMessage: "Current page: Student homework view",
contextMessage: t("chat.contextMessage.studentHomework"),
suggestedPrompts: [
t("chat.suggestedPrompts.student.0"),
"Give me a hint, not the answer",
"Help me understand this concept",
t("chat.suggestedPrompts.context.studentHomework.0"),
t("chat.suggestedPrompts.context.studentHomework.1"),
],
}
}
@@ -299,7 +299,7 @@ function inferContextFromPath(
return {
systemPrompt:
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
contextMessage: "Current page: Parent dashboard",
contextMessage: t("chat.contextMessage.parent"),
suggestedPrompts: [
t("chat.suggestedPrompts.parent.0"),
t("chat.suggestedPrompts.parent.1"),
@@ -312,7 +312,7 @@ function inferContextFromPath(
return {
systemPrompt:
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
contextMessage: "Current page: Admin dashboard",
contextMessage: t("chat.contextMessage.admin"),
suggestedPrompts: [
t("chat.suggestedPrompts.admin.0"),
t("chat.suggestedPrompts.admin.1"),

View File

@@ -1,6 +1,7 @@
"use client"
import { useMemo } from "react"
import { useTranslations } from "next-intl"
import {
Bar,
BarChart,
@@ -27,6 +28,7 @@ import {
type ChartConfig,
} from "@/shared/components/ui/chart"
import { cn } from "@/shared/lib/utils"
import { AiChartSpecSchema } from "../schema"
/**
* AI 图表渲染器
@@ -111,14 +113,15 @@ interface AiChartRendererProps {
/**
* 解析 JSON 规格,失败时返回 null
*
* 使用 Zod schema 校验,避免 as 断言。
*/
function parseSpec(spec: string): AiChartSpec | null {
try {
const parsed = JSON.parse(spec) as AiChartSpec
if (!parsed || !Array.isArray(parsed.data) || !Array.isArray(parsed.series)) {
return null
}
return parsed
const parsed: unknown = JSON.parse(spec)
const result = AiChartSpecSchema.safeParse(parsed)
if (!result.success) return null
return result.data
} catch {
return null
}
@@ -139,12 +142,13 @@ export function AiChartRenderer({
spec,
className,
}: AiChartRendererProps): React.ReactNode {
const t = useTranslations("ai")
const parsed = useMemo(() => parseSpec(spec), [spec])
if (!parsed) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
{t("chart.parseError")}
</div>
)
}

View File

@@ -1,11 +1,14 @@
"use client"
import { Component, type ReactNode } from "react"
import { AlertCircle, RefreshCw } from "lucide-react"
import { useTranslations } from "next-intl"
/**
* AI 专用 Error Boundary
*
* 薄包装:委托给共享 SectionErrorBoundary使用 ai 命名空间。
* 保留同名导出以兼容现有 import。
*/
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import type { ReactNode } from "react"
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
type AiErrorBoundaryProps = {
children: ReactNode
@@ -15,74 +18,14 @@ type AiErrorBoundaryProps = {
onError?: (error: Error, info: unknown) => void
}
type AiErrorBoundaryState = {
error: Error | null
}
/**
* AI 专用 Error Boundary
*
* 包裹所有 AI 数据区块,防止单个 AI 调用失败导致整页崩溃。
* 提供重试按钮与友好的错误提示。
*/
export class AiErrorBoundary extends Component<
AiErrorBoundaryProps,
AiErrorBoundaryState
> {
constructor(props: AiErrorBoundaryProps) {
super(props)
this.state = { error: null }
}
static getDerivedStateFromError(error: Error): AiErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: unknown): void {
if (this.props.onError) {
this.props.onError(error, info)
}
}
private handleReset = (): void => {
this.setState({ error: null })
}
render(): ReactNode {
if (this.state.error) {
if (this.props.fallback) {
return this.props.fallback(this.state.error, this.handleReset)
}
return <DefaultAiErrorFallback error={this.state.error} onReset={this.handleReset} />
}
return this.props.children
}
}
function DefaultAiErrorFallback({
error,
onReset,
}: {
error: Error
onReset: () => void
}): ReactNode {
const t = useTranslations("ai")
export function AiErrorBoundary({
children,
fallback,
onError,
}: AiErrorBoundaryProps): ReactNode {
return (
<Card className="border-destructive/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<AlertCircle className="h-4 w-4" />
{t("error.boundaryTitle")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t("error.boundaryDescription")}</p>
<p className="text-xs text-muted-foreground/70 font-mono">{error.message}</p>
<Button type="button" variant="outline" size="sm" onClick={onReset}>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("error.retry")}
</Button>
</CardContent>
</Card>
<SectionErrorBoundary namespace="ai" fallback={fallback} onError={onError}>
{children}
</SectionErrorBoundary>
)
}

View File

@@ -28,6 +28,28 @@ const CHART_TYPES: Record<string, AiChartType> = {
"chart:radar": "radar",
}
/**
* 类型守卫:判断字符串是否为合法的 AiChartType
*/
function isAiChartType(value: string): value is AiChartType {
return value === "bar" || value === "line" || value === "pie" || value === "radar"
}
/**
* 从 language 标识中解析图表类型
*/
function resolveChartType(lang: string): AiChartType | undefined {
// 优先查表(兼容 "chart:bar" 形式)
const fromTable = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
if (fromTable) return fromTable
// 兼容 "chart:bar" 前缀形式
if (lang.startsWith(CHART_LANG_PREFIX)) {
const suffix = lang.slice(CHART_LANG_PREFIX.length)
return isAiChartType(suffix) ? suffix : undefined
}
return undefined
}
/**
* AI Markdown 渲染器
*
@@ -87,12 +109,9 @@ function AiMarkdownRendererImpl({
// 检测图表代码块language-chart:bar / chart:line / chart:pie / chart:radar
const lang = codeClass?.replace("language-", "").trim() ?? ""
const chartType = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
?? (lang.startsWith(CHART_LANG_PREFIX)
? (lang.slice(CHART_LANG_PREFIX.length) as AiChartType)
: undefined)
const chartType = resolveChartType(lang)
if (chartType && (chartType === "bar" || chartType === "line" || chartType === "pie" || chartType === "radar")) {
if (chartType) {
const raw = String(children).replace(/\n$/, "")
return <AiChartRenderer type={chartType} spec={raw} />
}

View File

@@ -63,7 +63,7 @@ export function AiProviderSelector({
render={({ field }) => (
<FormItem>
<FormLabel>{t("provider.label")}</FormLabel>
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
<Select value={typeof field.value === "string" ? field.value : ""} onValueChange={field.onChange} disabled={loading}>
<FormControl>
<SelectTrigger>
<SelectValue

View File

@@ -1,164 +0,0 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Sparkles, Check, RefreshCw } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import { AiSuggestionSkeleton } from "./ai-skeleton"
import { useAiClient } from "../context/ai-client-provider"
import type { SimilarQuestionResult } from "../types"
type AiSuggestionCardProps = {
/** 原始题目文本 */
questionText: string
/** 题目类型 */
questionType: string
/** 学科 */
subject?: string
/** 知识点 ID 列表 */
knowledgePointIds?: string[]
/** 需要生成的题目数量 */
count?: number
/** 选中题目后的回调 */
onSelectQuestion?: (question: SimilarQuestionResult) => void
}
/**
* AI 相似题建议卡片
*
* 可复用组件,展示 AI 生成的相似练习题。
* 用于错题本、作业练习等场景。
*/
export function AiSuggestionCard({
questionText,
questionType,
subject,
knowledgePointIds,
count = 3,
onSelectQuestion,
}: AiSuggestionCardProps): React.ReactNode {
const t = useTranslations("ai")
const aiClient = useAiClient()
const [loading, setLoading] = useState(false)
const [questions, setQuestions] = useState<SimilarQuestionResult[]>([])
const [hasLoaded, setHasLoaded] = useState(false)
const handleGenerate = async (): Promise<void> => {
setLoading(true)
try {
const result = await aiClient.suggestSimilarQuestions({
questionText,
questionType,
subject,
knowledgePointIds,
count,
})
if (result.success && result.data) {
setQuestions(result.data)
setHasLoaded(true)
toast.success(t("suggestion.loaded"))
} else {
toast.error(result.message ?? t("suggestion.error"))
}
} catch {
toast.error(t("suggestion.error"))
} finally {
setLoading(false)
}
}
const handleSelect = (question: SimilarQuestionResult): void => {
onSelectQuestion?.(question)
toast.success(t("suggestion.selected"))
}
if (loading) {
return <AiSuggestionSkeleton />
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" />
{t("suggestion.title")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{hasLoaded && questions.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("suggestion.empty")}</p>
) : questions.length > 0 ? (
<>
{questions.map((question, index) => (
<div
key={index}
className="rounded-md border p-3 space-y-2"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm flex-1">{question.text}</p>
{question.difficulty ? (
<Badge variant="outline" className="shrink-0">
{t("suggestion.difficulty")}: {question.difficulty}
</Badge>
) : null}
</div>
{question.options && question.options.length > 0 ? (
<ul className="text-xs text-muted-foreground space-y-1">
{question.options.map((opt, optIndex) => (
<li key={optIndex}>
<span className="font-medium">{opt.id}.</span> {opt.text}
</li>
))}
</ul>
) : null}
{question.explanation ? (
<p className="text-xs text-muted-foreground italic">
{question.explanation}
</p>
) : null}
{onSelectQuestion ? (
<div className="flex justify-end gap-2 pt-1">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => handleSelect(question)}
>
<Check className="mr-1 h-3.5 w-3.5" />
{t("suggestion.select")}
</Button>
</div>
) : null}
</div>
))}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerate}
className="w-full"
>
<RefreshCw className="mr-1 h-3.5 w-3.5" />
{t("suggestion.regenerate")}
</Button>
</>
) : (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleGenerate}
className="w-full"
>
<Sparkles className="mr-1 h-3.5 w-3.5" />
{t("suggestion.generate")}
</Button>
)}
</CardContent>
</Card>
)
}

View File

@@ -1,6 +1,6 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl"
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
import { toast } from "sonner"
@@ -33,7 +33,7 @@ export function AiUsageDashboard(): React.ReactNode {
const [stats, setStats] = useState<AiUsageStats | null>(null)
const [loading, setLoading] = useState(false)
const loadStats = async (): Promise<void> => {
const loadStats = useCallback(async (): Promise<void> => {
if (!aiClient.getAiUsageStats) return
setLoading(true)
try {
@@ -41,19 +41,18 @@ export function AiUsageDashboard(): React.ReactNode {
if (result.success && result.data) {
setStats(result.data)
} else {
toast.error(result.message ?? t("error.chatFailed"))
toast.error(result.message ?? t("error.statsFailed"))
}
} catch {
toast.error(t("error.chatFailed"))
toast.error(t("error.statsFailed"))
} finally {
setLoading(false)
}
}
}, [aiClient, t])
useEffect(() => {
void loadStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [loadStats])
const statCards = stats
? [

View File

@@ -0,0 +1,54 @@
import "server-only"
import {
aiChatAction,
suggestSimilarQuestionsAction,
suggestGradingAction,
generateLessonContentAction,
generateQuestionVariantAction,
analyzeWeaknessAction,
generateChildSummaryAction,
recommendStudyPathAction,
getAiUsageStatsAction,
explainErrorAction,
} from "../actions"
import type { AiClientService } from "../types"
/**
* 创建完整的 AI 客户端服务(含全部 10 个 Action
*
* 用于全局 layout 或需要全部 AI 能力的页面。
* 通过 React Context 注入,客户端组件通过 useAiClient() 消费。
*/
export function createFullAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
generateChildSummary: generateChildSummaryAction,
recommendStudyPath: recommendStudyPathAction,
getAiUsageStats: getAiUsageStatsAction,
explainError: explainErrorAction,
}
}
/**
* 创建核心 AI 客户端服务(仅 6 个常用 Action
*
* 用于只需要 AI 业务能力(不含家长摘要/学习路径/统计/错题解释)的页面。
* 可选字段generateChildSummary/recommendStudyPath/getAiUsageStats/explainError不注入
* 调用方组件需自行处理 undefined 情况。
*/
export function createCoreAiClientService(): AiClientService {
return {
chat: aiChatAction,
suggestSimilarQuestions: suggestSimilarQuestionsAction,
suggestGrading: suggestGradingAction,
generateLessonContent: generateLessonContentAction,
generateQuestionVariant: generateQuestionVariantAction,
analyzeWeakness: analyzeWeaknessAction,
}
}

View File

@@ -1,57 +0,0 @@
"use client"
import { useState, useCallback } from "react"
import { useAiClient } from "../context/ai-client-provider"
import type { AiChatMessage, AiChatResult } from "../types"
/**
* AI 聊天 Hook
*
* 封装 AI 聊天逻辑,与 UI 分离。
* 通过 useAiClient() 获取 Server Action 引用。
*/
export function useAiChat(): {
messages: AiChatMessage[]
loading: boolean
error: string | null
send: (messages: AiChatMessage[], providerId?: string) => Promise<AiChatResult | null>
clear: () => void
} {
const aiClient = useAiClient()
const [messages, setMessages] = useState<AiChatMessage[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const send = useCallback(
async (input: AiChatMessage[], providerId?: string): Promise<AiChatResult | null> => {
setLoading(true)
setError(null)
try {
const result = await aiClient.chat({ messages: input, providerId })
if (result.success && result.data) {
const assistantContent = result.data.content
setMessages((prev) => [...prev, ...input, {
role: "assistant",
content: assistantContent,
}])
return result.data
}
setError(result.message ?? "AI request failed")
return null
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return null
} finally {
setLoading(false)
}
},
[aiClient]
)
const clear = useCallback((): void => {
setMessages([])
setError(null)
}, [])
return { messages, loading, error, send, clear }
}

View File

@@ -0,0 +1,130 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import type { Position } from "./use-position-persistence"
import { clampPosition } from "./use-position-persistence"
type DragState = {
active: boolean
moved: boolean
startX: number
startY: number
originX: number
originY: number
pointerId: number
}
type DragCallbacks = {
/** 拖拽开始时触发pointer down 后) */
onDragStart: () => void
/** 拖拽释放时触发moved 表示是否发生了实际移动 */
onRelease: (moved: boolean) => void
}
/**
* 拖拽位置 Hook
*
* 处理 pointer 事件,跟踪拖拽状态与位置变化。
* 不处理边缘吸附、持久化等业务逻辑,通过回调委托给调用方。
*/
export function useDragPosition(
position: Position,
setPosition: React.Dispatch<React.SetStateAction<Position>>,
callbacks: DragCallbacks
): {
dragging: boolean
handlers: {
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerCancel: () => void
}
} {
const [dragging, setDragging] = useState(false)
const dragStateRef = useRef<DragState>({
active: false,
moved: false,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
pointerId: -1,
})
const callbacksRef = useRef(callbacks)
useEffect(() => {
callbacksRef.current = callbacks
}, [callbacks])
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
// 仅主键响应拖拽
if (e.button !== 0 && e.pointerType === "mouse") return
const s = dragStateRef.current
s.active = true
s.moved = false
s.startX = e.clientX
s.startY = e.clientY
s.originX = position.x
s.originY = position.y
s.pointerId = e.pointerId
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
// ignore
}
setDragging(true)
callbacksRef.current.onDragStart()
},
[position]
)
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
const s = dragStateRef.current
if (!s.active || e.pointerId !== s.pointerId) return
const dx = e.clientX - s.startX
const dy = e.clientY - s.startY
// 阈值过滤微抖动
if (!s.moved && Math.abs(dx) + Math.abs(dy) < 4) return
s.moved = true
const next = clampPosition({
x: s.originX + dx,
y: s.originY + dy,
})
setPosition(next)
},
[setPosition]
)
const onPointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>): void => {
const s = dragStateRef.current
if (!s.active || e.pointerId !== s.pointerId) return
s.active = false
setDragging(false)
try {
e.currentTarget.releasePointerCapture(e.pointerId)
} catch {
// ignore
}
callbacksRef.current.onRelease(s.moved)
},
[]
)
const onPointerCancel = useCallback((): void => {
dragStateRef.current.active = false
setDragging(false)
}, [])
return {
dragging,
handlers: {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
},
}
}

View File

@@ -2,50 +2,43 @@
import { useCallback, useEffect, useRef, useState } from "react"
type Position = { x: number; y: number }
import {
BALL_SIZE,
HIDE_THRESHOLD,
MARGIN,
type Position,
clampPosition,
getDefaultPosition,
savePosition,
usePositionPersistence,
} from "./use-position-persistence"
import { useDragPosition } from "./use-drag-position"
const STORAGE_KEY = "ai-widget-position"
const HIDE_THRESHOLD = 0.55
const BALL_SIZE = 56
const MARGIN = 16
function clampPosition(pos: Position): Position {
if (typeof window === "undefined") return pos
const maxX = window.innerWidth - BALL_SIZE - MARGIN
const maxY = window.innerHeight - BALL_SIZE - MARGIN
return {
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
}
/**
* 计算吸附到最近边缘后的 X 坐标
*/
function snapToEdge(x: number): number {
if (typeof window === "undefined") return x
const w = window.innerWidth
const centerX = x + BALL_SIZE / 2
const distanceToLeft = centerX
const distanceToRight = w - centerX
return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - MARGIN
}
function loadPosition(): Position {
if (typeof window === "undefined") {
return { x: 9999, y: 9999 }
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<Position>
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampPosition({ x: parsed.x, y: parsed.y })
}
}
} catch {
// ignore
}
const x = window.innerWidth - BALL_SIZE - MARGIN * 2
const y = window.innerHeight - BALL_SIZE - MARGIN * 4
return clampPosition({ x, y })
}
function savePosition(pos: Position): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
} catch {
// ignore
}
/**
* 计算半隐藏时的视觉偏移量
*/
function calculateHiddenOffset(
position: Position,
hidden: boolean,
hovered: boolean,
dragging: boolean
): number {
if (!hidden || hovered || dragging) return 0
return position.x <= MARGIN + 2
? -(BALL_SIZE * HIDE_THRESHOLD)
: BALL_SIZE * HIDE_THRESHOLD
}
/**
@@ -57,135 +50,69 @@ function savePosition(pos: Position): void {
* - 单击(未发生拖动)触发 onClick
* - 位置持久化到 localStorage
* - 窗口 resize 时自动校正位置
*
* V3拆分为 use-position-persistence + use-drag-position + 本 hook 组合
*/
export function useFloatingBall(onClick: () => void) {
// 服务端与客户端首次渲染一致position 在屏幕外,不渲染按钮)
// 在 useEffect 中加载真实位置,避免 hydration mismatch
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
export function useFloatingBall(onClick: () => void): {
position: Position
hidden: boolean
dragging: boolean
hovered: boolean
hiddenOffset: number
handlers: {
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
onPointerCancel: () => void
onMouseEnter: () => void
onMouseLeave: () => void
}
show: () => void
resetPosition: () => void
} {
const { position, setPosition } = usePositionPersistence()
const [hidden, setHidden] = useState(false)
const [dragging, setDragging] = useState(false)
const [hovered, setHovered] = useState(false)
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
const justHiddenRef = useRef(false)
const dragStateRef = useRef({
active: false,
moved: false,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
pointerId: -1,
})
const onClickRef = useRef(onClick)
useEffect(() => {
onClickRef.current = onClick
}, [onClick])
// 初始化位置:在客户端 mount 后加载真实位置
// 避免 hydration mismatch服务端与客户端位置不同
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPosition(loadPosition())
}, [])
// 窗口 resize 时校正
useEffect(() => {
const handleResize = () => {
setPosition((prev) => clampPosition(prev))
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
// 仅主键响应拖拽
if (e.button !== 0 && e.pointerType === "mouse") return
const state = dragStateRef.current
state.active = true
state.moved = false
state.startX = e.clientX
state.startY = e.clientY
state.originX = position.x
state.originY = position.y
state.pointerId = e.pointerId
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
// ignore
}
const handleDragStart = useCallback((): void => {
setHidden(false)
setDragging(true)
},
[position]
)
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
const state = dragStateRef.current
if (!state.active || e.pointerId !== state.pointerId) return
const dx = e.clientX - state.startX
const dy = e.clientY - state.startY
if (!state.moved && Math.abs(dx) + Math.abs(dy) < 4) return
state.moved = true
const next = clampPosition({
x: state.originX + dx,
y: state.originY + dy,
})
setPosition(next)
},
[]
)
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLButtonElement>) => {
const state = dragStateRef.current
if (!state.active || e.pointerId !== state.pointerId) return
state.active = false
setDragging(false)
try {
e.currentTarget.releasePointerCapture(e.pointerId)
} catch {
// ignore
}
}, [])
const handleRelease = useCallback(
(moved: boolean): void => {
// 未移动 → 视为点击
if (!state.moved) {
if (!moved) {
onClickRef.current()
return
}
// 移动了 → 吸附到最近边缘
const w = window.innerWidth
const centerX = position.x + BALL_SIZE / 2
const distanceToLeft = centerX
const distanceToRight = w - centerX
const snapLeft = distanceToLeft < distanceToRight
const snappedX = snapLeft ? MARGIN : w - BALL_SIZE - MARGIN
// 判断是否半隐藏:吸附后位置贴近边缘
const shouldHide = true
const snappedX = snapToEdge(position.x)
const finalPos = clampPosition({ x: snappedX, y: position.y })
setPosition(finalPos)
savePosition(finalPos)
setHidden(shouldHide)
setHidden(true)
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
justHiddenRef.current = shouldHide
justHiddenRef.current = true
// 清除 hovered确保 hiddenOffset 生效
setHovered(false)
},
[position]
[position, setPosition]
)
const handlePointerCancel = useCallback(() => {
const state = dragStateRef.current
state.active = false
setDragging(false)
}, [])
const { dragging, handlers: dragHandlers } = useDragPosition(
position,
setPosition,
{ onDragStart: handleDragStart, onRelease: handleRelease }
)
const handleMouseEnter = useCallback(() => {
const handleMouseEnter = useCallback((): void => {
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
if (justHiddenRef.current) {
justHiddenRef.current = false
@@ -195,33 +122,26 @@ export function useFloatingBall(onClick: () => void) {
if (hidden) setHidden(false)
}, [hidden])
const handleMouseLeave = useCallback(() => {
const handleMouseLeave = useCallback((): void => {
setHovered(false)
// 离开后清除 justHidden 标记,下次进入可正常展开
justHiddenRef.current = false
}, [])
const show = useCallback(() => {
const show = useCallback((): void => {
justHiddenRef.current = false
setHidden(false)
}, [])
const resetPosition = useCallback(() => {
justHiddenRef.current = false
const fresh = typeof window === "undefined"
? loadPosition()
: clampPosition({
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
})
setPosition(fresh)
savePosition(fresh)
setHidden(false)
}, [])
// 半隐藏时的视觉偏移量
const hiddenOffset = hidden && !hovered && !dragging
? (position.x <= MARGIN + 2 ? -(BALL_SIZE * HIDE_THRESHOLD) : BALL_SIZE * HIDE_THRESHOLD)
: 0
const resetPosition = useCallback((): void => {
justHiddenRef.current = false
const fresh = getDefaultPosition()
setPosition(fresh)
savePosition(fresh)
setHidden(false)
}, [setPosition])
const hiddenOffset = calculateHiddenOffset(position, hidden, hovered, dragging)
return {
position,
@@ -230,10 +150,7 @@ export function useFloatingBall(onClick: () => void) {
hovered,
hiddenOffset,
handlers: {
onPointerDown: handlePointerDown,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerCancel: handlePointerCancel,
...dragHandlers,
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
},

View File

@@ -0,0 +1,99 @@
"use client"
import { useEffect, useState } from "react"
export type Position = { x: number; y: number }
export const STORAGE_KEY = "ai-widget-position"
export const HIDE_THRESHOLD = 0.55
export const BALL_SIZE = 56
export const MARGIN = 16
/**
* 将位置限制在视口内
*/
export function clampPosition(pos: Position): Position {
if (typeof window === "undefined") return pos
const maxX = window.innerWidth - BALL_SIZE - MARGIN
const maxY = window.innerHeight - BALL_SIZE - MARGIN
return {
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
}
}
/**
* 从 localStorage 加载位置,失败时返回默认右下角位置
*/
export function loadPosition(): Position {
if (typeof window === "undefined") {
return { x: 9999, y: 9999 }
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) {
const parsed = JSON.parse(raw) as Partial<Position>
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
return clampPosition({ x: parsed.x, y: parsed.y })
}
}
} catch {
// ignore
}
const x = window.innerWidth - BALL_SIZE - MARGIN * 2
const y = window.innerHeight - BALL_SIZE - MARGIN * 4
return clampPosition({ x, y })
}
/**
* 持久化位置到 localStorage
*/
export function savePosition(pos: Position): void {
if (typeof window === "undefined") return
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
} catch {
// ignore
}
}
/**
* 默认位置(右下角)
*/
export function getDefaultPosition(): Position {
if (typeof window === "undefined") return { x: 9999, y: 9999 }
return clampPosition({
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
})
}
/**
* 位置持久化 Hook
*
* 管理 position 状态mount 时从 localStorage 加载resize 时校正。
* 服务端与客户端首次渲染一致position 在屏幕外),避免 hydration mismatch。
*/
export function usePositionPersistence(): {
position: Position
setPosition: React.Dispatch<React.SetStateAction<Position>>
} {
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
// 初始化位置:在客户端 mount 后加载真实位置
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPosition(loadPosition())
}, [])
// 窗口 resize 时校正
useEffect(() => {
const handleResize = (): void => {
setPosition((prev) => clampPosition(prev))
}
window.addEventListener("resize", handleResize)
return () => window.removeEventListener("resize", handleResize)
}, [])
return { position, setPosition }
}

View File

@@ -225,3 +225,50 @@ export const StudyPathResultSchema = z.object({
summary: z.string().min(1),
motivation: z.string().min(1),
})
// ---------------------------------------------------------------------------
// 错题 AI 解释校验
// ---------------------------------------------------------------------------
export const ExplainErrorInputSchema = z.object({
questionText: z.string().min(1).max(4000),
questionType: z.string().min(1),
studentAnswer: z.string().min(1).max(8000),
correctAnswer: z.string().optional(),
subject: z.string().optional(),
knowledgePointIds: z.array(z.string()).optional(),
})
export const ExplainErrorResultSchema = z.object({
errorAnalysis: z.string().min(1),
correctApproach: z.string().min(1),
keyConcepts: z.array(z.string().min(1)),
preventionTips: z.array(z.string().min(1)),
practiceSuggestion: z.string().min(1),
})
// ---------------------------------------------------------------------------
// AI 图表规格校验(用于 ai-chart-renderer.tsx 解析 AI 返回的图表 JSON
// ---------------------------------------------------------------------------
export const AiChartSeriesSchema = z.object({
dataKey: z.string().min(1),
name: z.string().min(1),
color: z.string().optional(),
fillOpacity: z.number().optional(),
strokeWidth: z.number().optional(),
strokeDasharray: z.string().optional(),
})
export const AiChartSpecSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
type: z.enum(["bar", "line", "pie", "radar"]).optional(),
data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
xKey: z.string().optional(),
angleKey: z.string().optional(),
series: z.array(AiChartSeriesSchema),
yDomain: z.tuple([z.number(), z.number()]).optional(),
height: z.number().optional(),
showLegend: z.boolean().optional(),
})

View File

@@ -11,6 +11,7 @@ import {
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
CHILD_SUMMARY_SYSTEM_PROMPT,
STUDY_PATH_SYSTEM_PROMPT,
EXPLAIN_ERROR_SYSTEM_PROMPT,
} from "./prompt-templates"
import { withAiTracking } from "./usage-tracker"
import {
@@ -21,6 +22,7 @@ import {
WeaknessAnalysisResultSchema,
ChildSummaryResultSchema,
StudyPathResultSchema,
ExplainErrorResultSchema,
} from "../schema"
import type {
AiChatMessage,
@@ -41,6 +43,8 @@ import type {
ChildSummaryResult,
StudyPathInput,
StudyPathResult,
ExplainErrorInput,
ExplainErrorResult,
} from "../types"
// ---------------------------------------------------------------------------
@@ -144,9 +148,10 @@ const callAi = async (
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
...(options?.providerId ? { providerId: options.providerId } : {}),
})
// 从 unknown 类型安全提取 total_tokens避免 as 断言)
const tokenUsage =
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
? Number(result.usage.total_tokens ?? 0)
: undefined
return { content: result.content, tokenUsage }
}
@@ -169,7 +174,11 @@ export class DefaultAiService implements AiService {
...options,
temperature: options?.temperature ?? 0.7,
})
return { result: { content, usage: null }, tokenUsage }
// usage 字段返回 token 用量对象unknown 类型),便于调用方按需类型缩小
return {
result: { content, usage: tokenUsage !== undefined ? { total_tokens: tokenUsage } : null },
tokenUsage,
}
})
}
@@ -192,9 +201,10 @@ export class DefaultAiService implements AiService {
{ temperature: 0.5, maxTokens: 3000 }
)
const parsed = extractJson(content)
// 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
const list =
parsed && typeof parsed === "object" && "questions" in parsed
? (parsed as Record<string, unknown>).questions
? parsed.questions
: parsed
const validated = SimilarQuestionListSchema.safeParse(list)
if (!validated.success) return { result: [] }
@@ -413,6 +423,39 @@ export class DefaultAiService implements AiService {
return { result: validated.data }
})
}
async explainError(input: ExplainErrorInput): Promise<ExplainErrorResult> {
return withAiTracking(this.userId, "explain_error", undefined, async () => {
const userLines = [
`Question Type: ${input.questionType}`,
input.subject ? `Subject: ${input.subject}` : "",
input.knowledgePointIds?.length
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
: "",
`Question:\n${input.questionText}`,
`Student Answer:\n${input.studentAnswer}`,
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
].filter((line) => line.length > 0)
const { content } = await callAi(
buildChatMessages(EXPLAIN_ERROR_SYSTEM_PROMPT, userLines.join("\n\n")),
{ temperature: 0.4, maxTokens: 2000 }
)
const parsed = extractJson(content)
const validated = ExplainErrorResultSchema.safeParse(parsed)
if (!validated.success) {
return {
result: {
errorAnalysis: "Unable to analyze the error at this time.",
correctApproach: "Please consult your teacher for help.",
keyConcepts: [],
preventionTips: [],
practiceSuggestion: "Review the relevant chapter and try again.",
},
}
}
return { result: validated.data }
})
}
}
/**

View File

@@ -275,3 +275,26 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
"- motivation should be age-appropriate and encouraging.",
"Never output placeholders.",
].join("\n")
export const EXPLAIN_ERROR_SYSTEM_PROMPT = [
"You are an expert K12 tutor specializing in helping students understand their mistakes.",
"Analyze the student's error and provide a clear, encouraging explanation.",
"Return JSON only without markdown.",
"Output schema:",
"{",
' "errorAnalysis": "detailed analysis of why the student made this error",',
' "correctApproach": "step-by-step correct solution approach",',
' "keyConcepts": ["list of key concepts the student needs to review"],',
' "preventionTips": ["tips to avoid similar mistakes in the future"],',
' "practiceSuggestion": "specific practice recommendation"',
"}",
"Rules:",
"- Use age-appropriate language for K12 students.",
"- Be encouraging and constructive, never dismissive.",
"- errorAnalysis should identify the specific misconception, not just say 'wrong'.",
"- correctApproach should be step-by-step and easy to follow.",
"- keyConcepts should list 2-5 fundamental concepts.",
"- preventionTips should be actionable and specific.",
"- practiceSuggestion should recommend a specific type of practice problem.",
"Never output placeholders.",
].join("\n")

View File

@@ -5,7 +5,7 @@ import { recordAiEvent } from "../data-access"
export type AiUsageEvent = {
userId: string
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path"
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path" | "explain_error"
providerId?: string
model?: string
success: boolean
@@ -23,6 +23,7 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
weakness_analysis: "ai.weakness_analysis",
child_summary: "ai.child_summary",
study_path: "ai.study_path",
explain_error: "ai.explain_error",
}
/**

View File

@@ -221,6 +221,36 @@ export type AiUsageStats = {
}>
}
/** 错题 AI 解释输入 */
export type ExplainErrorInput = {
/** 题目文本 */
questionText: string
/** 题目类型 */
questionType: string
/** 学生错误答案 */
studentAnswer: string
/** 正确答案 */
correctAnswer?: string
/** 学科 */
subject?: string
/** 知识点 ID 列表 */
knowledgePointIds?: string[]
}
/** 错题 AI 解释结果 */
export type ExplainErrorResult = {
/** 错误原因分析 */
errorAnalysis: string
/** 正确解题思路 */
correctApproach: string
/** 关键知识点 */
keyConcepts: string[]
/** 类似错误防范建议 */
preventionTips: string[]
/** 练习建议 */
practiceSuggestion: string
}
// ---------------------------------------------------------------------------
// AI 能力配置(角色驱动)
// ---------------------------------------------------------------------------
@@ -236,6 +266,7 @@ export type AiCapability =
| "study-path"
| "child-summary"
| "usage-stats"
| "explain-error"
// ---------------------------------------------------------------------------
// 服务接口
@@ -257,6 +288,7 @@ export interface AiService {
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
explainError(input: ExplainErrorInput): Promise<ExplainErrorResult>
}
/**
@@ -290,6 +322,9 @@ export interface AiClientService {
input: StudyPathInput
) => Promise<ActionState<StudyPathResult>>
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
explainError?: (
input: ExplainErrorInput
) => Promise<ActionState<ExplainErrorResult>>
/** 预留埋点接口 */
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
}

382
src/modules/auth/actions.ts Normal file
View File

@@ -0,0 +1,382 @@
"use server"
import { z } from "zod"
import { getTranslations } from "next-intl/server"
import type { ActionState } from "@/shared/types/action-state"
import { rateLimit, rateLimitKey } from "@/shared/lib/rate-limit"
import { resolveClientIp } from "@/shared/lib/http-utils"
import { logLoginEvent } from "@/shared/lib/login-logger"
import { trackAuthEvent, trackEvent } from "@/shared/lib/track-event"
import { checkBreachedPassword } from "@/shared/lib/breached-password"
import {
validateInvitationCode,
consumeInvitationCode,
} from "@/modules/invitation-codes/data-access"
import { RegisterSchema } from "./schema"
import { buildRegisterInput, createUser, isEmailAvailable } from "./data-access"
import { preflightTwoFactorByEmail } from "./services/two-factor-service"
import type { RegisterResult } from "./types"
/**
* 注册速率限制规则。
*
* 独立于 LOGIN 限制,避免恶意用户通过注册接口枚举邮箱或爆破邀请码。
* 规则15 分钟内最多 5 次(与 LOGIN 一致,便于运维记忆)。
*/
const REGISTER_RATE_LIMIT = {
limit: 5,
windowMs: 15 * 60 * 1000,
} as const
/**
* 注册错误码枚举(前端通过 t(`register.errors.${errorCode}`) 查 i18n 翻译)。
*/
export const REGISTER_ERROR_CODES = {
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
VALIDATION_FAILED: "VALIDATION_FAILED",
EMAIL_TAKEN: "EMAIL_TAKEN",
BREACHED_PASSWORD: "BREACHED_PASSWORD",
INVITATION_CODE_INVALID: "INVITATION_CODE_INVALID",
INVITATION_CODE_USED: "INVITATION_CODE_USED",
DEFAULT_ROLE_MISSING: "DEFAULT_ROLE_MISSING",
UNKNOWN: "UNKNOWN",
} as const
export type RegisterErrorCode = typeof REGISTER_ERROR_CODES[keyof typeof REGISTER_ERROR_CODES]
/**
* 将原始数据库/系统错误归一化为 RegisterErrorCode。
*
* 不向客户端暴露 SQL 文案、表名或字段名等敏感信息。
*/
function classifyRegisterError(error: unknown): RegisterErrorCode {
if (error instanceof Error) {
const msg = error.message.toLowerCase()
if (error.message === "DEFAULT_ROLE_NOT_FOUND") {
return REGISTER_ERROR_CODES.DEFAULT_ROLE_MISSING
}
// 捕获 MySQL 唯一索引冲突(不暴露原始 sqlMessage
if (
msg.includes("duplicate") ||
msg.includes("unique") ||
msg.includes("er_dup_entry")
) {
return REGISTER_ERROR_CODES.EMAIL_TAKEN
}
}
return REGISTER_ERROR_CODES.UNKNOWN
}
/**
* P1-1: 通过 next-intl 服务端翻译查找错误消息。
* i18n 命名空间:`auth.register.errors.{errorCode}`
*/
async function messageForError(
code: RegisterErrorCode
): Promise<string> {
const t = await getTranslations("auth.register")
return t(`errors.${code}`)
}
/**
* 注册 Server ActionP0-1从 register/page.tsx 内联实现下沉到模块层)。
*
* 安全设计:
* - Zod 校验输入(含未成年人监护人条件校验)
* - 独立速率限制 key `register:{ip}`,与 LOGIN 限制隔离
* - 注册前查询邮箱可用性(不暴露 userId
* - 数据库写入委托给 data-access.createUser不直接操作 schema
* - 成功/失败均写入 loginLogs 审计日志action="signup"
* - 错误响应只返回结构化 errorCode不暴露 SQL/堆栈信息
*
* 返回值:
* - 成功:`{ success: true, data: { userId } }`userId 仅用于内部跟踪,前端可忽略)
* - 失败:`{ success: false, errorCode, message }`
*/
export async function registerAction(
formData: FormData
): Promise<ActionState<RegisterResult>> {
// 1) 速率限制(独立 key避免与登录失败计数叠加
const ip = await resolveClientIp()
const limitKey = rateLimitKey("register", ip)
const limitResult = await rateLimit({
key: limitKey,
limit: REGISTER_RATE_LIMIT.limit,
windowMs: REGISTER_RATE_LIMIT.windowMs,
})
if (!limitResult.success) {
// 速率限制命中也记录日志,便于运维识别攻击
const email = String(formData.get("email") ?? "")
await logLoginEvent({
userEmail: email,
action: "signup",
status: "failure",
errorMessage: "RATE_LIMIT_EXCEEDED",
}).catch(() => {
/* 日志失败不影响主流程 */
})
return {
success: false,
errorCode: REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED,
message: await messageForError(REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED),
}
}
// 2) 提取并校验表单字段
const raw = {
name: String(formData.get("name") ?? ""),
email: String(formData.get("email") ?? ""),
password: String(formData.get("password") ?? ""),
birthDate: String(formData.get("birthDate") ?? ""),
guardianName: String(formData.get("guardianName") ?? ""),
guardianPhone: String(formData.get("guardianPhone") ?? ""),
guardianRelation: String(formData.get("guardianRelation") ?? ""),
agreedTerms: formData.get("agreedTerms") === "true",
agreedGuardian: formData.get("agreedGuardian") === "true",
// audit-P2-3: 邀请码字段(可选)
invitationCode: String(formData.get("invitationCode") ?? ""),
}
const parsed = RegisterSchema.safeParse(raw)
if (!parsed.success) {
return {
success: false,
errorCode: REGISTER_ERROR_CODES.VALIDATION_FAILED,
message: await messageForError(REGISTER_ERROR_CODES.VALIDATION_FAILED),
errors: parsed.error.flatten().fieldErrors,
}
}
const data = parsed.data
const normalizedEmail = data.email.toLowerCase()
// 3) 邮箱可用性预检查(避免直接触发数据库唯一约束,且能给出更友好错误码)
const available = await isEmailAvailable(normalizedEmail)
if (!available) {
await logLoginEvent({
userEmail: normalizedEmail,
action: "signup",
status: "failure",
errorMessage: "EMAIL_TAKEN",
}).catch(() => {
/* ignore */
})
return {
success: false,
errorCode: REGISTER_ERROR_CODES.EMAIL_TAKEN,
message: await messageForError(REGISTER_ERROR_CODES.EMAIL_TAKEN),
}
}
// audit-P2-4: Breached password 检测HIBP k-anonymity API
// fail-open 策略API 不可用时 checkSkipped=true不阻断注册流程
const breachCheck = await checkBreachedPassword(data.password)
if (breachCheck.isBreached) {
await logLoginEvent({
userEmail: normalizedEmail,
action: "signup",
status: "failure",
errorMessage: "BREACHED_PASSWORD",
}).catch(() => {
/* ignore */
})
return {
success: false,
errorCode: REGISTER_ERROR_CODES.BREACHED_PASSWORD,
message: await messageForError(REGISTER_ERROR_CODES.BREACHED_PASSWORD),
}
}
// audit-P2-3: 邀请码校验(可选)
// 若提供邀请码,校验通过后使用邀请码中的 role 覆盖默认 student。
// 校验失败返回结构化错误码(不暴露具体失败原因防枚举)。
let invitedRole: string | undefined
let invitedClassId: string | undefined
let invitationCodeForConsume: string | undefined
if (data.invitationCode && data.invitationCode.length > 0) {
const codeValue = data.invitationCode.toUpperCase()
invitationCodeForConsume = codeValue
const validationResult = await validateInvitationCode(codeValue, normalizedEmail)
if (!validationResult.valid) {
await logLoginEvent({
userEmail: normalizedEmail,
action: "signup",
status: "failure",
errorMessage: "INVITATION_CODE_INVALID",
}).catch(() => {
/* ignore */
})
return {
success: false,
errorCode: REGISTER_ERROR_CODES.INVITATION_CODE_INVALID,
message: await messageForError(REGISTER_ERROR_CODES.INVITATION_CODE_INVALID),
}
}
if (validationResult.code) {
invitedRole = validationResult.code.role
invitedClassId = validationResult.code.classId ?? undefined
}
}
// 4) 调用 data-access 创建用户(含角色分配,但不自动创建角色)
const input = buildRegisterInput({
name: data.name,
email: normalizedEmail,
password: data.password,
birthDate: data.birthDate && data.birthDate.length > 0 ? data.birthDate : null,
guardianName: data.guardianName ?? "",
guardianPhone: data.guardianPhone ?? "",
guardianRelation: data.guardianRelation ?? "",
role: invitedRole,
classId: invitedClassId,
})
const t = await getTranslations("auth.register")
try {
const result = await createUser(input)
// audit-P2-3: 注册成功后标记邀请码为已使用(乐观锁,避免并发使用)
// 使用失败不阻断注册流程(用户已创建),仅记录日志
if (invitationCodeForConsume) {
const consumed = await consumeInvitationCode({
code: invitationCodeForConsume,
email: normalizedEmail,
userId: result.userId,
}).catch(() => false)
if (!consumed) {
// 邀请码已被他人使用(极端并发场景)
console.warn(
`[register] Invitation code ${invitationCodeForConsume} was already consumed by another user (email=${normalizedEmail})`,
)
} else {
void trackEvent({
event: "invitation_codes.consumed",
userId: result.userId,
properties: {
email: normalizedEmail,
role: invitedRole,
hasClassId: Boolean(invitedClassId),
},
})
}
}
// 5) 成功审计日志
await logLoginEvent({
userId: result.userId,
userEmail: normalizedEmail,
action: "signup",
status: "success",
}).catch(() => {
/* 日志失败不影响注册成功 */
})
// audit-P1-9注册成功埋点用于注册转化率、注册渠道分析
// 非阻塞trackEvent 内部已吞掉异常
await trackAuthEvent("auth.signup", {
userId: result.userId,
properties: {
email: normalizedEmail,
role: input.role,
isMinor: input.isMinor,
usedInvitationCode: Boolean(invitationCodeForConsume),
},
})
return {
success: true,
data: result,
message: t("toast.createSuccess"),
}
} catch (error) {
// 6) 错误分类(不向客户端暴露原始错误信息)
const errorCode = classifyRegisterError(error)
await logLoginEvent({
userEmail: normalizedEmail,
action: "signup",
status: "failure",
errorMessage: errorCode,
}).catch(() => {
/* ignore */
})
return {
success: false,
errorCode,
message: await messageForError(errorCode),
}
}
}
/**
* 邮箱可用性查询 Server Action。
*
* 用于注册表单实时检查邮箱是否已被占用。限流复用 REGISTER 规则,
* 避免被用于邮箱枚举攻击。
*/
export async function checkEmailAvailabilityAction(
email: string
): Promise<ActionState<{ available: boolean }>> {
const ip = await resolveClientIp()
const limitKey = rateLimitKey("register", ip)
const limitResult = await rateLimit({
key: limitKey,
limit: REGISTER_RATE_LIMIT.limit,
windowMs: REGISTER_RATE_LIMIT.windowMs,
})
if (!limitResult.success) {
return {
success: false,
errorCode: REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED,
message: await messageForError(REGISTER_ERROR_CODES.RATE_LIMIT_EXCEEDED),
}
}
const emailSchema = z.string().trim().email()
const parsed = emailSchema.safeParse(email)
if (!parsed.success) {
const t = await getTranslations("auth.register")
return {
success: false,
errorCode: REGISTER_ERROR_CODES.VALIDATION_FAILED,
message: t("emailFormatError"),
}
}
const available = await isEmailAvailable(parsed.data.toLowerCase())
return {
success: true,
data: { available },
}
}
/**
* 2FA 预检 Server Action薄封装供 LoginForm 客户端组件调用)。
*
* 委托给 `services/two-factor-service.preflightTwoFactorByEmail`
* LoginForm 不再跨模块依赖 `modules/settings/actions-security`
* 符合 audit-P0-2 同模块依赖原则。
*
* 返回值:
* - `{ required: true }` — 用户启用了 2FA登录表单应展示 2FA 输入框
* - `{ required: false }` — 用户未启用 2FA 或不存在(防邮箱枚举)
*/
export async function preflightTwoFactorAction(
email: string
): Promise<{ required: boolean }> {
return preflightTwoFactorByEmail(email)
}

View File

@@ -1,24 +1,42 @@
import { GraduationCap } from "lucide-react"
import { DEFAULT_BRAND_CONFIG, type BrandConfig } from "@/modules/settings/brand-config"
interface AuthLayoutProps {
children: React.ReactNode
/** audit-P2-6: 品牌配置(由 Server Component layout 注入,未配置时使用默认值) */
brand?: BrandConfig
}
export function AuthLayout({ children }: AuthLayoutProps) {
/**
* 认证页面布局audit-P2-6: 支持品牌配置注入)
*
* 品牌配置通过 props 注入,来源:
* - `app/(auth)/layout.tsx`Server Component调用 `getBrandConfig()` 获取
* - 未配置时使用 `DEFAULT_BRAND_CONFIG` 默认值
*/
export function AuthLayout({ children, brand = DEFAULT_BRAND_CONFIG }: AuthLayoutProps) {
const { schoolName, logoUrl, testimonialQuote, testimonialAuthor } = brand
return (
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium">
{logoUrl ? (
// eslint-disable-next-line @next/next/no-img-element -- brand logo from admin config
<img src={logoUrl} alt={schoolName} className="mr-2 h-6 w-6 object-contain" />
) : (
<GraduationCap className="mr-2 h-6 w-6" />
Next_Edu
)}
{schoolName}
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
&ldquo;This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.&rdquo;
&ldquo;{testimonialQuote}&rdquo;
</p>
<footer className="text-sm">Sofia Davis</footer>
<footer className="text-sm">{testimonialAuthor}</footer>
</blockquote>
</div>
</div>

View File

@@ -4,34 +4,71 @@ import * as React from "react"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
import { signIn } from "next-auth/react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import { Alert, AlertDescription } from "@/shared/components/ui/alert"
import { cn } from "@/shared/lib/utils"
import { Loader2, Github, ShieldCheck } from "lucide-react"
import { preflightTwoFactorAction } from "@/modules/settings/actions-security"
import { Loader2, Github, ShieldCheck, AlertCircle } from "lucide-react"
import { preflightTwoFactorAction } from "@/modules/auth/actions"
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
/** 触发账户锁定提示的失败次数阈值(与后端 PASSWORD_RULES.maxFailedAttempts 对齐) */
const LOCK_HINT_THRESHOLD = 3
/** 简单邮箱格式校验(与服务端 Zod 校验对齐,仅用于前置拦截) */
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
export function LoginForm({ className, ...props }: LoginFormProps) {
const t = useTranslations("auth.login")
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [requiresTwoFactor, setRequiresTwoFactor] = React.useState<boolean>(false)
const [totpCode, setTotpCode] = React.useState<string>("")
const [error, setError] = React.useState<string>("")
const [failedAttempts, setFailedAttempts] = React.useState<number>(0)
const router = useRouter()
const searchParams = useSearchParams()
/** 清除错误并重置为初始状态(用于输入变更时) */
const clearError = React.useCallback(() => {
setError("")
}, [])
function validateCredentials(email: string, password: string): string | null {
if (!email.trim()) return t("errors.emailRequired")
if (!EMAIL_RE.test(email)) return t("errors.emailFormat")
if (!password) return t("errors.passwordRequired")
return null
}
async function onSubmit(event: React.SyntheticEvent) {
event.preventDefault()
setIsLoading(true)
setError("")
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
const email = String(formData.get("email") ?? "")
const email = String(formData.get("email") ?? "").trim()
const password = String(formData.get("password") ?? "")
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
// 客户端校验2FA 模式下校验 totpCode否则校验 email/password
if (requiresTwoFactor) {
if (!totpCode.trim()) {
setError(t("errors.totpRequired"))
return
}
} else {
const validationError = validateCredentials(email, password)
if (validationError) {
setError(validationError)
return
}
}
setIsLoading(true)
// 首次提交:检查是否需要 2FA
if (!requiresTwoFactor) {
try {
@@ -60,25 +97,39 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
router.push(result?.url ?? callbackUrl)
router.refresh()
} else {
// 2FA 验证码错误时保留 2FA 输入框,允许用户重新输入
// 失败计数:仅在非 2FA 模式下累计2FA 失败不计入账户锁定计数)
const nextAttempts = requiresTwoFactor ? failedAttempts : failedAttempts + 1
setFailedAttempts(nextAttempts)
// 根据失败次数与上下文选择错误消息
if (requiresTwoFactor) {
setError("Invalid 2FA code. Please try again.")
setError(t("errors.invalid2fa"))
} else if (nextAttempts >= LOCK_HINT_THRESHOLD) {
// 达到阈值后切换为更严重的提示(账户可能已被锁定)
setError(t("errors.tooManyAttempts"))
} else {
setError("Invalid email or password.")
setError(t("errors.invalidCredentials"))
}
}
}
/** 切换回普通登录模式时重置 2FA 相关状态与失败计数 */
function handleBackToLogin() {
setRequiresTwoFactor(false)
setTotpCode("")
setError("")
}
const showLockHint = failedAttempts >= LOCK_HINT_THRESHOLD && !requiresTwoFactor
return (
<div className={cn("grid gap-6", className)} {...props}>
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Welcome back
{t("title")}
</h1>
<p className="text-sm text-muted-foreground">
{requiresTwoFactor
? "Enter the 6-digit code from your authenticator app"
: "Enter your email to sign in to your account"}
{requiresTwoFactor ? t("subtitle2fa") : t("subtitle")}
</p>
</div>
<form onSubmit={onSubmit}>
@@ -86,7 +137,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
{!requiresTwoFactor ? (
<>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
name="email"
@@ -96,16 +147,17 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
autoComplete="email"
autoCorrect="off"
disabled={isLoading}
onChange={clearError}
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">{t("password")}</Label>
<Link
href="/forgot-password"
className="text-sm font-medium text-muted-foreground hover:underline"
>
Forgot password?
{t("forgotPassword")}
</Link>
</div>
<Input
@@ -114,6 +166,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
type="password"
autoComplete="current-password"
disabled={isLoading}
onChange={clearError}
/>
</div>
</>
@@ -121,7 +174,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
<div className="grid gap-2">
<Label htmlFor="totpCode" className="flex items-center gap-1.5">
<ShieldCheck className="h-4 w-4" />
2FA Code
{t("totpLabel")}
</Label>
<Input
id="totpCode"
@@ -132,35 +185,47 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
placeholder="123456"
maxLength={8}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value)}
onChange={(e) => {
setTotpCode(e.target.value)
clearError()
}}
disabled={isLoading}
autoFocus
/>
<p className="text-xs text-muted-foreground">
Enter your 6-digit authenticator code or an 8-character backup code.
{t("totpHint")}
</p>
<button
type="button"
onClick={() => {
setRequiresTwoFactor(false)
setTotpCode("")
setError("")
}}
onClick={handleBackToLogin}
className="text-xs text-muted-foreground hover:underline justify-self-start"
disabled={isLoading}
>
Back to login
{t("backToLogin")}
</button>
</div>
)}
{error ? (
<p className="text-sm text-red-600">{error}</p>
<Alert className="border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{/* 账户锁定提示:达到失败阈值后展示,引导用户重置密码 */}
{showLockHint && !error ? (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{t("errors.accountLockedHint")}</AlertDescription>
</Alert>
) : null}
<Button disabled={isLoading}>
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{requiresTwoFactor ? "Verify & Sign In" : "Sign In with Email"}
{requiresTwoFactor ? t("verifyAndSignIn") : t("signIn")}
</Button>
</div>
</form>
@@ -170,7 +235,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
{t("orContinueWith")}
</span>
</div>
</div>
@@ -183,12 +248,12 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
GitHub
</Button>
<p className="px-8 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
{t("noAccount")}{" "}
<Link
href="/register"
className="underline underline-offset-4 hover:text-primary"
>
Sign up
{t("register")}
</Link>
</p>
</div>

View File

@@ -3,6 +3,7 @@
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
@@ -18,6 +19,7 @@ import {
import { cn } from "@/shared/lib/utils"
import { Loader2 } from "lucide-react"
import type { ActionState } from "@/shared/types/action-state"
import type { RegisterResult } from "@/modules/auth/types"
const ADULT_AGE = 18
@@ -35,10 +37,11 @@ function calcAge(birth: string): number | null {
}
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
registerAction: (formData: FormData) => Promise<ActionState>
registerAction: (formData: FormData) => Promise<ActionState<RegisterResult>>
}
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
const t = useTranslations("auth.register")
const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [birthDate, setBirthDate] = React.useState<string>("")
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
@@ -53,15 +56,15 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
event.preventDefault()
if (!agreedTerms) {
toast.error("请阅读并同意《隐私政策》和《用户协议》后再注册")
toast.error(t("toast.needAgreeTerms"))
return
}
if (isMinor && !agreedGuardian) {
toast.error("未成年人注册须确认已获得监护人同意")
toast.error(t("toast.needGuardianConsent"))
return
}
if (isMinor && !guardianRelation) {
toast.error("请选择监护人与您的关系")
toast.error(t("toast.needGuardianRelation"))
return
}
@@ -72,14 +75,14 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
const res = await registerAction(formData)
if (res.success) {
toast.success(res.message || "账户创建成功")
toast.success(res.message || t("toast.createSuccess"))
router.push("/login")
router.refresh()
} else {
toast.error(res.message || "注册失败")
toast.error(res.message || t("toast.createFailed"))
}
} catch {
toast.error("注册失败")
toast.error(t("toast.createFailed"))
} finally {
setIsLoading(false)
}
@@ -88,19 +91,19 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
return (
<div className={cn("grid gap-6", className)} {...props}>
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight"></h1>
<h1 className="text-2xl font-semibold tracking-tight">{t("createAccount")}</h1>
<p className="text-sm text-muted-foreground">
{t("subtitleAlt")}
</p>
</div>
<form onSubmit={onSubmit}>
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="name"></Label>
<Label htmlFor="name">{t("name")}</Label>
<Input
id="name"
name="name"
placeholder="请输入姓名"
placeholder={t("namePlaceholder")}
type="text"
autoCapitalize="words"
autoComplete="name"
@@ -109,7 +112,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/>
</div>
<div className="grid gap-2">
<Label htmlFor="email"></Label>
<Label htmlFor="email">{t("email")}</Label>
<Input
id="email"
name="email"
@@ -122,7 +125,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password"></Label>
<Label htmlFor="password">{t("password")}</Label>
<Input
id="password"
name="password"
@@ -132,7 +135,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/>
</div>
<div className="grid gap-2">
<Label htmlFor="birthDate"></Label>
<Label htmlFor="birthDate">{t("birthDate")}</Label>
<Input
id="birthDate"
name="birthDate"
@@ -142,47 +145,62 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
onChange={(e) => setBirthDate(e.target.value)}
/>
{age !== null && (
<p className="text-xs text-muted-foreground">{age} </p>
<p className="text-xs text-muted-foreground">{t("currentAge", { age })}</p>
)}
</div>
{/* audit-P2-3: 邀请码(可选,提供时覆盖默认 student 角色) */}
<div className="grid gap-2">
<Label htmlFor="invitationCode">{t("invitationCodeLabel")}</Label>
<Input
id="invitationCode"
name="invitationCode"
type="text"
placeholder={t("invitationCodePlaceholder")}
autoCapitalize="characters"
autoCorrect="off"
disabled={isLoading}
maxLength={64}
/>
</div>
{isMinor && (
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
{t("guardianSectionTitle")}
</p>
<div className="grid gap-2">
<Label htmlFor="guardianName"></Label>
<Label htmlFor="guardianName">{t("guardianName")}</Label>
<Input
id="guardianName"
name="guardianName"
placeholder="请输入监护人姓名"
placeholder={t("guardianNamePlaceholder")}
type="text"
disabled={isLoading}
required={isMinor}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="guardianPhone"></Label>
<Label htmlFor="guardianPhone">{t("guardianPhone")}</Label>
<Input
id="guardianPhone"
name="guardianPhone"
placeholder="请输入监护人手机号"
placeholder={t("guardianPhonePlaceholder")}
type="tel"
disabled={isLoading}
required={isMinor}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="guardianRelation"></Label>
<Label htmlFor="guardianRelation">{t("guardianRelation")}</Label>
<Select value={guardianRelation} onValueChange={setGuardianRelation}>
<SelectTrigger id="guardianRelation">
<SelectValue placeholder="请选择关系" />
<SelectValue placeholder={t("guardianRelationPlaceholder")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="父亲"></SelectItem>
<SelectItem value="母亲"></SelectItem>
<SelectItem value="其他法定监护人"></SelectItem>
<SelectItem value="父亲">{t("guardianRelationFather")}</SelectItem>
<SelectItem value="母亲">{t("guardianRelationMother")}</SelectItem>
<SelectItem value="其他法定监护人">{t("guardianRelationOther")}</SelectItem>
</SelectContent>
</Select>
<input
@@ -201,25 +219,30 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
onCheckedChange={(v) => setAgreedTerms(v === true)}
disabled={isLoading}
/>
<input type="hidden" name="agreedTerms" value={agreedTerms ? "true" : "false"} />
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
{t.rich("agreeTermsRich", {
privacy: (chunks) => (
<Link
href="/privacy"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
>
{chunks}
</Link>
),
terms: (chunks) => (
<Link
href="/terms"
target="_blank"
rel="noopener noreferrer"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
>
{chunks}
</Link>
),
})}
</Label>
</div>
@@ -231,8 +254,9 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
onCheckedChange={(v) => setAgreedGuardian(v === true)}
disabled={isLoading}
/>
<input type="hidden" name="agreedGuardian" value={agreedGuardian ? "true" : "false"} />
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
使
{t("agreeGuardianLabel")}
</Label>
</div>
)}
@@ -241,17 +265,17 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
{isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("createAccount")}
</Button>
</div>
</form>
<p className="px-8 text-center text-sm text-muted-foreground">
{" "}
{t("alreadyHaveAccount")}{" "}
<Link
href="/login"
className="underline underline-offset-4 hover:text-primary"
>
{t("loginNow")}
</Link>
</p>
</div>

View File

@@ -0,0 +1,122 @@
import "server-only"
import { createId } from "@paralleldrive/cuid2"
import { eq } from "drizzle-orm"
import { hash } from "bcryptjs"
import { db } from "@/shared/db"
import { roles, users, usersToRoles } from "@/shared/db/schema"
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
import { calcAge, isMinorByBirthDate } from "./schema"
import type { RegisterInput, RegisterResult } from "./types"
/** bcrypt cost factor推荐 12平衡性能与安全 */
const BCRYPT_COST = 12
/** 默认注册角色:仅学生可通过开放注册创建账号 */
const DEFAULT_REGISTER_ROLE = "student"
/**
* 查询邮箱是否已被注册。
*
* 仅返回布尔值,不暴露用户 id 等敏感信息,避免邮箱枚举攻击。
*/
export async function isEmailAvailable(email: string): Promise<boolean> {
const normalized = email.trim().toLowerCase()
if (!normalized) return false
const existing = await db.query.users.findFirst({
where: eq(users.email, normalized),
columns: { id: true },
})
return !existing
}
/**
* 创建新用户(注册)。
*
* - 仅创建 users 与 users_to_roles默认 student记录不写其他表。
* - 角色查找失败时返回错误(不自动创建角色,避免污染 RBAC
* - 密码使用 bcrypt cost=12 哈希并规范化。
* - 写入 `consentAcceptedAt` 标记用户已同意隐私政策与用户协议。
*
* 安全设计:
* - 不返回 password 字段。
* - 不触发审计日志(由 actions 层调用 logLoginEvent
* - 调用方应在外层添加速率限制与 Zod 校验。
*/
export async function createUser(
input: RegisterInput
): Promise<RegisterResult> {
const normalizedEmail = input.email.trim().toLowerCase()
const hashedPassword = normalizeBcryptHash(await hash(input.password, BCRYPT_COST))
const userId = createId()
await db.insert(users).values({
id: userId,
name: input.name.length ? input.name : null,
email: normalizedEmail,
password: hashedPassword,
birthDate: input.birthDate ? new Date(input.birthDate) : null,
age: input.age ?? null,
guardianName: input.guardianName || null,
guardianPhone: input.guardianPhone || null,
guardianRelation: input.guardianRelation || null,
consentAcceptedAt: new Date(),
})
// 查找默认角色(不自动创建)
// audit-P2-3: 若 input.role 存在(邀请码注册),使用邀请码中的 role否则使用 DEFAULT_REGISTER_ROLE
const roleName = input.role ?? DEFAULT_REGISTER_ROLE
const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, roleName),
columns: { id: true },
})
if (!roleRow?.id) {
throw new Error("DEFAULT_ROLE_NOT_FOUND")
}
await db.insert(usersToRoles).values({
userId,
roleId: roleRow.id,
})
return { userId }
}
/**
* 解析注册输入为 RegisterInput派生 age / isMinor
*
* 纯函数,便于单测。
* audit-P2-3: 新增可选 role/classId 参数(邀请码注册时透传)。
*/
export function buildRegisterInput(formData: {
name: string
email: string
password: string
birthDate: string | null
guardianName: string
guardianPhone: string
guardianRelation: string
role?: string
classId?: string
}): RegisterInput {
const birthDate = formData.birthDate && formData.birthDate.length > 0
? formData.birthDate
: null
const age = birthDate ? calcAge(birthDate) : null
const isMinor = birthDate ? isMinorByBirthDate(birthDate) : false
return {
name: formData.name,
email: formData.email,
password: formData.password,
birthDate,
age,
isMinor,
guardianName: formData.guardianName,
guardianPhone: formData.guardianPhone,
guardianRelation: formData.guardianRelation,
role: formData.role,
classId: formData.classId,
}
}

130
src/modules/auth/schema.ts Normal file
View File

@@ -0,0 +1,130 @@
import { z } from "zod"
/**
* 注册输入 Zod 校验。
*
* 与 `register-form.tsx` 客户端校验保持一致,但服务端独立校验以防绕过。
* 包含未成年人监护人字段的条件校验。
*/
const ADULT_AGE = 18
const guardianRelationEnum = z.enum([
"父亲",
"母亲",
"其他法定监护人",
])
export const RegisterSchema = z
.object({
name: z
.string()
.trim()
.min(1, "register.nameRequired")
.max(50, "register.nameMax"),
email: z
.string()
.trim()
.min(1, "register.emailRequired")
.email("register.emailInvalid"),
password: z
.string()
.min(8, "register.passwordMin")
.max(128, "register.passwordMax"),
birthDate: z
.string()
.trim()
.optional()
.or(z.literal("")),
guardianName: z
.string()
.trim()
.max(255, "register.guardianNameMax")
.optional()
.or(z.literal("")),
guardianPhone: z
.string()
.trim()
.max(20, "register.guardianPhoneMax")
.optional()
.or(z.literal("")),
guardianRelation: guardianRelationEnum.optional().or(z.literal("")),
agreedTerms: z.boolean().refine((v) => v === true, {
message: "register.consentRequired",
}),
agreedGuardian: z.boolean().optional(),
// audit-P2-3: 邀请码(可选)。提供时使用邀请码中的 role 覆盖默认 student。
invitationCode: z
.string()
.trim()
.toUpperCase()
.max(64, "register.invitationCodeMax")
.optional()
.or(z.literal("")),
})
.superRefine((data, ctx) => {
if (!data.birthDate) return
const birthDate = new Date(data.birthDate)
if (Number.isNaN(birthDate.getTime())) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["birthDate"],
message: "register.birthDateInvalid",
})
return
}
const age = calcAge(data.birthDate)
if (age === null) return
if (age < ADULT_AGE) {
if (!data.guardianName || data.guardianName.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["guardianName"],
message: "register.guardianNameRequired",
})
}
if (!data.guardianPhone || data.guardianPhone.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["guardianPhone"],
message: "register.guardianPhoneRequired",
})
}
if (!data.guardianRelation || data.guardianRelation.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["guardianRelation"],
message: "register.guardianRelationRequired",
})
}
if (!data.agreedGuardian) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["agreedGuardian"],
message: "register.guardianConsentRequired",
})
}
}
})
export type RegisterFormData = z.infer<typeof RegisterSchema>
/** 计算年龄(纯函数,与 register-form.tsx 同步) */
export function calcAge(birth: string): number | null {
if (!birth) return null
const birthDate = new Date(birth)
if (Number.isNaN(birthDate.getTime())) return null
const now = new Date()
let age = now.getFullYear() - birthDate.getFullYear()
const monthDiff = now.getMonth() - birthDate.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && now.getDate() < birthDate.getDate())) {
age -= 1
}
return age >= 0 ? age : null
}
/** 未成年人判定(与 register-form.tsx 同步) */
export function isMinorByBirthDate(birth: string): boolean {
const age = calcAge(birth)
return age !== null && age < ADULT_AGE
}

View File

@@ -0,0 +1,213 @@
import "server-only"
import { compare } from "bcryptjs"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { users, roles, usersToRoles, passwordSecurity } from "@/shared/db/schema"
import { logLoginEvent } from "@/shared/lib/login-logger"
import {
PASSWORD_RULES,
isAccountLocked,
} from "@/shared/lib/password-policy"
import { RATE_LIMIT_RULES, rateLimit, rateLimitKey, resetRateLimit } from "@/shared/lib/rate-limit"
import { normalizeBcryptHash } from "@/shared/lib/bcrypt-utils"
import { resolveClientIp } from "@/shared/lib/http-utils"
import {
getOrCreatePasswordSecurity,
recordFailedLogin,
resetFailedLogin,
} from "@/shared/lib/password-security-service"
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
import { trackAuthEvent } from "@/shared/lib/track-event"
import { verifyTwoFactorForLogin } from "./two-factor-service"
/**
* Auth 模块登录服务audit-P1-3 拆分)
*
* 此服务将 `auth.ts` 中 `authorize` 回调混合的 7 类职责抽取为独立纯函数:
* 1. 输入解析与校验
* 2. 速率限制IP + email 双维度)
* 3. 用户查询
* 4. 账户锁定检查
* 5. 密码校验bcrypt + 失败计数)
* 6. 2FA 校验TOTP / 备用码)
* 7. 角色加载与返回值构造
*
* 依赖关系:
* - auth.ts → modules/auth/services/login-service同模块动态 import 避免 edge runtime 冲突)
* - login-service → modules/auth/services/two-factor-service同模块
* - login-service → shared/lib/*(基础设施层)
* - login-service → shared/db + shared/db/schema数据访问层
*
* 设计目标:
* - `auth.ts` 的 authorize 回调降至 3 行(薄包装调用 authenticateUser
* - 所有登录逻辑可独立测试(无需启动 NextAuth
* - 错误路径统一通过 logLoginEvent 记录,返回 null 表示失败
*/
/** authorize 回调收到的原始 credentials字段均为 unknown */
export interface AuthenticateUserInput {
email?: unknown
password?: unknown
totpCode?: unknown
}
/** 认证成功后的用户对象(与 next-auth User 接口兼容) */
export interface AuthenticatedUser {
id: string
name?: string
email: string
role: string
roles: string[]
}
/**
* 认证用户:从原始 credentials 解析并校验,返回用户对象或 null。
*
* 失败路径均通过 logLoginEvent 记录审计日志,返回 null。
* 成功路径重置失败计数与速率限制,返回包含角色信息的用户对象。
*/
export async function authenticateUser(
credentials: AuthenticateUserInput,
): Promise<AuthenticatedUser | null> {
// 1. 输入解析与校验
const email = String(credentials?.email ?? "").trim().toLowerCase()
const password = String(credentials?.password ?? "")
const totpCode = String(credentials?.totpCode ?? "").trim()
if (!email || !password) return null
// 2. 速率限制IP + email 双维度,减缓暴力破解)
const clientIp = await resolveClientIp()
const loginLimitKey = rateLimitKey("login", `${clientIp}:${email}`)
const limit = await rateLimit({
key: loginLimitKey,
...RATE_LIMIT_RULES.LOGIN,
})
if (!limit.success) {
await logLoginEvent({
userEmail: email,
action: "signin",
status: "failure",
errorMessage: "Rate limit exceeded",
})
// audit-P1-9限流触发埋点用于限流触发率、异常登录地理/设备告警)
await trackAuthEvent("auth.rate_limited", {
properties: {
email,
clientIp,
limit: RATE_LIMIT_RULES.LOGIN.limit,
windowMs: RATE_LIMIT_RULES.LOGIN.windowMs,
},
})
return null
}
// 3. 用户查询
const user = await db.query.users.findFirst({
where: eq(users.email, email),
})
if (!user) return null
// 4. 账户锁定检查
const security = await getOrCreatePasswordSecurity(db, passwordSecurity, user.id)
const lastFailedAt = security.lockedUntil
? new Date(security.lockedUntil.getTime() - PASSWORD_RULES.lockoutDurationMinutes * 60 * 1000)
: null
if (isAccountLocked(security.failedLoginAttempts, lastFailedAt)) {
await logLoginEvent({
userId: user.id,
userEmail: email,
action: "signin",
status: "failure",
errorMessage: "Account locked",
})
// audit-P1-9账户锁定埋点用于账户锁定触发率告警
await trackAuthEvent("auth.account_locked", {
userId: user.id,
properties: {
email,
failedAttempts: security.failedLoginAttempts,
lockedUntil: security.lockedUntil?.toISOString(),
},
})
return null
}
// 5. 密码校验bcrypt + 失败计数)
const storedPassword = user.password ?? null
if (!storedPassword) return null
const normalizedPassword = normalizeBcryptHash(storedPassword)
if (!normalizedPassword.startsWith("$2")) return null
const ok = await compare(password, normalizedPassword)
if (!ok) {
await recordFailedLogin(db, passwordSecurity, user.id)
await logLoginEvent({
userId: user.id,
userEmail: email,
action: "signin",
status: "failure",
errorMessage: "Invalid credentials",
})
// audit-P1-9登录失败埋点密码错误用于登录成功率、异常登录尝试告警
// 注意:不区分"用户不存在"和"密码错误"以防用户枚举,但此处已知用户存在
await trackAuthEvent("auth.signin_failure", {
userId: user.id,
properties: {
email,
reason: "invalid_password",
failedAttempts: security.failedLoginAttempts + 1,
},
})
return null
}
// 成功登录:重置失败计数与速率限制
await resetFailedLogin(db, passwordSecurity, user.id)
await resetRateLimit(loginLimitKey)
// 6. 2FA 校验TOTP / 备用码,可选)
const twoFactorResult = await verifyTwoFactorForLogin({
userId: user.id,
token: totpCode || undefined,
})
if (twoFactorResult.required && !twoFactorResult.valid) {
await logLoginEvent({
userId: user.id,
userEmail: email,
action: "signin",
status: "failure",
errorMessage: totpCode
? "Invalid 2FA code"
: "2FA required but not provided",
})
// audit-P1-92FA 校验失败埋点(用于 2FA 失败率、暴力 2FA 尝试告警)
await trackAuthEvent("auth.signin_failure", {
userId: user.id,
properties: {
email,
reason: totpCode ? "invalid_2fa_code" : "2fa_required_not_provided",
},
})
return null
}
// 7. 角色加载与返回值构造
const roleRows = await db
.select({ name: roles.name })
.from(usersToRoles)
.innerJoin(roles, eq(usersToRoles.roleId, roles.id))
.where(eq(usersToRoles.userId, user.id))
const roleNames = roleRows.map((r) => r.name)
const resolvedRole = resolvePrimaryRole(roleNames)
return {
id: user.id,
name: user.name ?? undefined,
email: user.email,
role: resolvedRole,
roles: roleNames,
}
}

View File

@@ -0,0 +1,120 @@
import "server-only"
import { eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import {
getBackupCodesHashed,
getTotpSecret,
getTwoFactorEnabled,
setBackupCodesHashed,
} from "@/modules/settings/data-access-two-factor"
import {
consumeBackupCode,
verifyBackupCode,
verifyTotpCode,
} from "@/modules/settings/lib/totp"
/**
* Auth 模块 2FA 服务P0-2 解耦修复)
*
* 此服务将登录流程中需要的 2FA 校验逻辑从 `modules/settings/actions-security`
* 抽取到 `modules/auth/services`,使 `auth.ts` 不再直接依赖 settings 模块的
* Server Actions符合三层架构与跨模块通过 data-access 通信的规则。
*
* 依赖关系:
* - auth.ts → modules/auth/services/two-factor-service同模块
* - two-factor-service → modules/settings/data-access-two-factor跨模块 data-access允许
* - two-factor-service → modules/settings/lib/totp跨模块工具库允许
*/
export interface TwoFactorLoginResult {
/** 用户是否启用了 2FA */
required: boolean
/** 提供的 token 是否有效(未启用时视为有效) */
valid: boolean
}
export interface TwoFactorPreflightResult {
/** 是否需要 2FA用户不存在时返回 false 以防邮箱枚举) */
required: boolean
}
/**
* 登录时校验 2FA检查用户是否启用 2FA并校验提供的一次性码或备份码。
*
* 返回值:
* - `{ required: true }` — 用户启用了 2FA 但未提供 token登录流程应要求输入
* - `{ required: false, valid: true }` — 未启用 2FA或已提供有效 token
* - `{ required: false, valid: false }` — 启用了 2FA 且提供的 token 无效
*
* 此函数不使用 requirePermission登录时还未建立会话
* 由 `auth.ts` 的 authorize 回调直接调用。
*/
export async function verifyTwoFactorForLogin(params: {
userId: string
token?: string
}): Promise<TwoFactorLoginResult> {
const { userId, token } = params
const enabled = await getTwoFactorEnabled(userId)
if (!enabled) {
return { required: false, valid: true }
}
if (!token) {
return { required: true, valid: false }
}
const secret = await getTotpSecret(userId)
const backupHashed = await getBackupCodesHashed(userId)
// 先尝试 TOTP
if (secret && verifyTotpCode(token, secret)) {
return { required: true, valid: true }
}
// 再尝试备份码
if (backupHashed) {
const idx = await verifyBackupCode(token, backupHashed)
if (idx >= 0) {
const nextHashed = await consumeBackupCode(backupHashed, idx)
await setBackupCodesHashed(userId, nextHashed)
return { required: true, valid: true }
}
}
return { required: true, valid: false }
}
/**
* 预检:根据邮箱查询用户是否启用了 2FA。
*
* 登录表单在首次提交前可调用此函数,若返回 `required=true` 则先展示
* 2FA 验证码输入框。
*
* 为防止邮箱枚举攻击,无论用户是否存在都返回 `required=false`
* (不存在则视为未启用)。
*/
export async function preflightTwoFactorByEmail(
email: string
): Promise<TwoFactorPreflightResult> {
try {
const normalized = email.trim().toLowerCase()
if (!normalized) return { required: false }
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.email, normalized))
.limit(1)
if (!user) return { required: false }
const enabled = await getTwoFactorEnabled(user.id)
return { required: enabled }
} catch {
return { required: false }
}
}

45
src/modules/auth/types.ts Normal file
View File

@@ -0,0 +1,45 @@
/**
* Auth 模块类型定义。
*
* 仅包含注册/登录相关数据契约,认证上下文与权限类型见
* `@/shared/types/permissions`AuthContext与 `@/shared/lib/session`
* AppSession
* 纯类型文件,无副作用,可被客户端组件 import。
*/
/** 注册结果(成功时仅返回 userId避免泄漏额外信息 */
export interface RegisterResult {
userId: string
}
/** 注册输入参数(已通过 Zod 校验) */
export interface RegisterInput {
name: string
email: string
password: string
birthDate: string | null
age: number | null
isMinor: boolean
guardianName: string
guardianPhone: string
guardianRelation: string
/**
* 注册后分配的角色audit-P2-3 新增)。
*
* - 邀请码注册:使用邀请码中的 role覆盖默认 student
* - 开放注册:留空,由 createUser 使用 DEFAULT_REGISTER_ROLEstudent
*/
role?: string
/**
* 邀请码注册时自动加入的班级 IDaudit-P2-3 新增)。
*
* 来自邀请码,注册成功后可触发班级加入逻辑。
* 当前仅透传,未在 createUser 中自动加入班级(避免引入 classes 模块依赖)。
*/
classId?: string
}
/** 邮箱可用性查询结果 */
export interface EmailAvailabilityResult {
available: boolean
}

View File

@@ -0,0 +1,310 @@
"use server"
import { createId } from "@paralleldrive/cuid2"
import {
requirePermission,
checkPermission,
PermissionDeniedError,
} from "@/shared/lib/auth-guard"
import { trackEvent } from "@/shared/lib/track-event"
import { logAudit } from "@/shared/lib/audit-logger"
import { Permissions } from "@/shared/types/permissions"
import type { ActionState } from "@/shared/types/action-state"
import { storageProvider } from "@/shared/lib/storage-provider"
import {
generateStoragePath,
isAllowedMimeType,
MAX_FILE_SIZE,
} from "@/shared/lib/file-storage"
import { UploadMetadataSchema, BatchDeleteSchema, FileListQuerySchema } from "./schema"
import {
createFileAttachment,
getFileAttachment,
getFileAttachmentsWithFilters,
getFileStats,
getFileAttachmentsByIds,
deleteFileAttachment,
deleteFileAttachments,
} from "./data-access"
import type {
FileAttachment,
FileUploadResult,
FileStats as FileStatsType,
FileAttachmentQueryParams,
BatchDeleteResult,
} from "./types"
function handleActionError(e: unknown): ActionState<never> {
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
/**
* Upload a file: persist to storage + create DB record.
*
* Requires `FILE_UPLOAD` permission. Performs Zod-validated metadata,
* MIME/size checks, and writes to disk via the storageProvider abstraction.
* Records `file.uploaded` track event + audit log entry.
*
* @returns ActionState<FileUploadResult>
*/
export async function uploadFileAction(
file: File,
rawMetadata: { targetType?: string | null; targetId?: string | null }
): Promise<ActionState<FileUploadResult>> {
try {
const ctx = await requirePermission(Permissions.FILE_UPLOAD)
const meta = UploadMetadataSchema.parse(rawMetadata)
if (file.size === 0) {
return { success: false, message: "File is empty" }
}
if (file.size > MAX_FILE_SIZE) {
return { success: false, message: "File size exceeds 10MB limit" }
}
const mimeType = file.type || "application/octet-stream"
if (!isAllowedMimeType(mimeType)) {
return { success: false, message: `File type ${mimeType} is not allowed` }
}
const originalName = file.name || "unnamed"
const storagePath = generateStoragePath(originalName)
const bytes = Buffer.from(await file.arrayBuffer())
const url = await storageProvider.save(bytes, storagePath)
const id = createId()
const filename = storagePath.split("/").pop() ?? id
const created = await createFileAttachment({
id,
filename,
originalName,
mimeType,
size: file.size,
storagePath,
url,
uploaderId: ctx.userId,
targetType: meta.targetType ?? null,
targetId: meta.targetId,
})
if (!created) {
return { success: false, message: "Failed to persist file record" }
}
await trackEvent({
event: "file.uploaded",
userId: ctx.userId,
targetId: id,
targetType: "file",
properties: {
filename: originalName,
mimeType,
size: file.size,
targetType: meta.targetType ?? null,
},
})
await logAudit({
action: "upload",
module: "files",
targetId: id,
targetType: "file",
detail: { filename: originalName, mimeType, size: file.size },
})
const result: FileUploadResult = {
id: created.id,
url: created.url ?? url,
filename: created.filename,
originalName: created.originalName,
size: created.size,
mimeType: created.mimeType,
}
return { success: true, data: result }
} catch (e) {
await trackEvent({
event: "file.upload_failed",
targetType: "file",
properties: { reason: e instanceof Error ? e.message : "unknown" },
}).catch(() => undefined)
return handleActionError(e)
}
}
/**
* Get a single file by ID.
*
* Requires `FILE_READ` permission. Non-admin users (those without
* `FILE_DELETE`) can only read files they uploaded themselves,
* preventing horizontal privilege escalation.
*
* Records `file.viewed` track event.
*/
export async function getFileAction(
id: string
): Promise<ActionState<FileAttachment>> {
try {
const ctx = await requirePermission(Permissions.FILE_READ)
const file = await getFileAttachment(id)
if (!file) {
return { success: false, message: "File not found" }
}
// Data-level permission: non-admins can only read their own uploads.
const { allowed: canManage } = await checkPermission(Permissions.FILE_DELETE)
if (!canManage && file.uploaderId !== ctx.userId) {
return { success: false, message: "Permission denied" }
}
await trackEvent({
event: "file.viewed",
userId: ctx.userId,
targetId: id,
targetType: "file",
})
return { success: true, data: file }
} catch (e) {
return handleActionError(e)
}
}
/**
* Delete a single file by ID.
*
* Requires `FILE_DELETE` permission. Persists removal via storageProvider
* abstraction (no direct fs/promises calls). Records `file.deleted` track
* event + audit log entry.
*/
export async function deleteFileAction(
id: string
): Promise<ActionState<{ id: string }>> {
try {
const ctx = await requirePermission(Permissions.FILE_DELETE)
const file = await getFileAttachment(id)
if (!file) {
return { success: false, message: "File not found" }
}
await storageProvider.delete(file.storagePath)
const ok = await deleteFileAttachment(id)
if (!ok) {
return { success: false, message: "Failed to delete file record" }
}
await trackEvent({
event: "file.deleted",
userId: ctx.userId,
targetId: id,
targetType: "file",
properties: { filename: file.originalName, size: file.size },
})
await logAudit({
action: "delete",
module: "files",
targetId: id,
targetType: "file",
detail: { filename: file.originalName, mimeType: file.mimeType },
})
return { success: true, data: { id } }
} catch (e) {
return handleActionError(e)
}
}
/**
* Batch delete files by IDs.
*
* Requires `FILE_DELETE` permission. Input is Zod-validated (max 100 ids
* per call). Persists storage removal via storageProvider abstraction.
* Records `file.batch_deleted` track event + audit log entry.
*/
export async function batchDeleteFilesAction(
rawIds: unknown
): Promise<ActionState<BatchDeleteResult>> {
try {
const ctx = await requirePermission(Permissions.FILE_DELETE)
const { ids } = BatchDeleteSchema.parse({ ids: rawIds })
const files = await getFileAttachmentsByIds(ids)
await Promise.all(
files.map((f) =>
storageProvider.delete(f.storagePath).catch(() => undefined)
)
)
const result = await deleteFileAttachments(ids)
await trackEvent({
event: "file.batch_deleted",
userId: ctx.userId,
targetType: "file",
properties: {
requestedCount: ids.length,
deletedCount: result.deletedCount,
failedCount: result.failedIds.length,
},
})
await logAudit({
action: "batch_delete",
module: "files",
targetType: "file",
detail: {
requestedCount: ids.length,
deletedCount: result.deletedCount,
failedIds: result.failedIds,
},
})
return { success: true, data: result }
} catch (e) {
return handleActionError(e)
}
}
/**
* Get file list with filters (admin).
*
* Requires `FILE_READ` permission. Input is Zod-validated to enforce
* limit (1..200) and offset (>=0) bounds.
*/
export async function getFileListAction(
params?: Partial<FileAttachmentQueryParams>
): Promise<ActionState<{ files: FileAttachment[] }>> {
try {
await requirePermission(Permissions.FILE_READ)
const query = FileListQuerySchema.parse(params ?? {})
const files = await getFileAttachmentsWithFilters(query)
return { success: true, data: { files } }
} catch (e) {
return handleActionError(e)
}
}
/**
* Get file statistics (admin dashboard).
*
* Requires `FILE_READ` permission.
*/
export async function getFileStatsAction(): Promise<ActionState<FileStatsType>> {
try {
await requirePermission(Permissions.FILE_READ)
const stats = await getFileStats()
return { success: true, data: stats }
} catch (e) {
return handleActionError(e)
}
}

View File

@@ -1,12 +1,13 @@
"use client"
import { useMemo, useState } from "react"
import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation"
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Input } from "@/shared/components/ui/input"
import {
Select,
@@ -15,13 +16,14 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { formatDate } from "@/shared/lib/utils"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import { FileUpload } from "./file-upload"
import { FilePreviewDialog } from "./file-preview-dialog"
import { FileUpload } from "./file-upload"
import { useFileBatchOperations } from "../hooks/use-file-batch-operations"
import type { FileAttachment, FileStats } from "../types"
interface AdminFilesViewProps {
@@ -29,174 +31,159 @@ interface AdminFilesViewProps {
stats: FileStats
}
// 文件类型分组选项
const TYPE_OPTIONS: Array<{ value: string; label: string }> = [
{ value: "all", label: "All Types" },
{ value: "image/", label: "Images" },
{ value: "application/pdf", label: "PDF" },
{ value: "application/msword", label: "Word" },
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", label: "Word (docx)" },
{ value: "application/vnd.ms-excel", label: "Excel" },
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", label: "Excel (xlsx)" },
{ value: "application/vnd.ms-powerpoint", label: "PowerPoint" },
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", label: "PowerPoint (pptx)" },
{ value: "text/", label: "Text" },
{ value: "application/zip", label: "ZIP" },
]
interface TypeOption {
value: string
labelKey:
| "allTypes"
| "images"
| "pdf"
| "word"
| "wordDocx"
| "excel"
| "excelXlsx"
| "powerpoint"
| "powerpointPptx"
| "text"
| "zip"
}
export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
const TYPE_OPTIONS = [
{ value: "all", labelKey: "allTypes" },
{ value: "image/", labelKey: "images" },
{ value: "application/pdf", labelKey: "pdf" },
{ value: "application/msword", labelKey: "word" },
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", labelKey: "wordDocx" },
{ value: "application/vnd.ms-excel", labelKey: "excel" },
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", labelKey: "excelXlsx" },
{ value: "application/vnd.ms-powerpoint", labelKey: "powerpoint" },
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", labelKey: "powerpointPptx" },
{ value: "text/", labelKey: "text" },
{ value: "application/zip", labelKey: "zip" },
] as const satisfies TypeOption[]
type TypeLabelKey = TypeOption["labelKey"]
export function AdminFilesView({
files,
stats,
}: AdminFilesViewProps): React.ReactElement {
const t = useTranslations("files.admin")
const router = useRouter()
const [typeFilter, setTypeFilter] = useState<string>("all")
const [search, setSearch] = useState<string>("")
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [deleting, setDeleting] = useState(false)
// 客户端二次筛选(与 server 端筛选互补,提升交互即时性)
const filteredFiles = useMemo(() => {
return files.filter((f) => {
if (typeFilter !== "all") {
if (typeFilter.endsWith("/")) {
if (!f.mimeType.startsWith(typeFilter)) return false
} else if (f.mimeType !== typeFilter) {
return false
const renderTypeLabel = (key: TypeLabelKey): string => {
switch (key) {
case "allTypes": return t("filter.allTypes")
case "images": return t("filter.images")
case "pdf": return t("filter.pdf")
case "word": return t("filter.word")
case "wordDocx": return t("filter.wordDocx")
case "excel": return t("filter.excel")
case "excelXlsx": return t("filter.excelXlsx")
case "powerpoint": return t("filter.powerpoint")
case "powerpointPptx": return t("filter.powerpointPptx")
case "text": return t("filter.text")
case "zip": return t("filter.zip")
}
}
if (search.trim()) {
const kw = search.trim().toLowerCase()
if (
!f.originalName.toLowerCase().includes(kw) &&
!f.filename.toLowerCase().includes(kw)
) {
return false
}
}
return true
const {
selectedIds,
typeFilter,
search,
deleting,
setTypeFilter,
setSearch,
filteredFiles,
allSelected,
someSelected,
toggleAll,
toggleOne,
handleBatchDelete,
} = useFileBatchOperations({
files,
onAfterDelete: () => router.refresh(),
})
}, [files, typeFilter, search])
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
const someSelected = selectedIds.size > 0 && !allSelected
const toggleAll = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
}
}
const toggleOne = (id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const handleUploaded = () => {
router.refresh()
}
const handleDeleted = () => {
router.refresh()
setSelectedIds(new Set())
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return
const ids = Array.from(selectedIds)
setDeleting(true)
try {
const res = await fetch("/api/files/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
})
const body = await res.json().catch(() => null)
if (!res.ok || !body?.success) {
toast.error(body?.message || "Failed to delete files")
return
}
toast.success(`Deleted ${body.deletedCount} file(s)`)
handleDeleted()
} catch {
toast.error("Failed to delete files")
} finally {
setDeleting(false)
}
}
return (
<div className="flex h-full flex-col space-y-6 p-8">
<div className="space-y-1">
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Files className="h-6 w-6" />
Files
<Files className="h-6 w-6" aria-hidden="true" />
{t("title")}
</h2>
<p className="text-muted-foreground">
Upload and manage all files in the system.
</p>
<p className="text-muted-foreground">{t("subtitle")}</p>
</div>
{/* 统计卡片 */}
<WidgetBoundary title={t("stats.totalFiles")} skeletonHeight={120}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Files className="h-3.5 w-3.5" />
Total Files
<Files className="h-3.5 w-3.5" aria-hidden="true" />
{t("stats.totalFiles")}
</div>
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
</div>
<div className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<HardDrive className="h-3.5 w-3.5" />
Total Size
<HardDrive className="h-3.5 w-3.5" aria-hidden="true" />
{t("stats.totalSize")}
</div>
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
</div>
{stats.byType.slice(0, 2).map((t) => (
<div key={t.mimeType} className="rounded-md border p-4">
{stats.byType.slice(0, 2).map((typeStat) => (
<div key={typeStat.mimeType} className="rounded-md border p-4">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon mimeType={t.mimeType} className="h-3.5 w-3.5" />
<span className="truncate" title={t.mimeType}>{t.mimeType}</span>
<FileIcon mimeType={typeStat.mimeType} className="h-3.5 w-3.5" />
<span className="truncate" title={typeStat.mimeType}>
{typeStat.mimeType}
</span>
</div>
<p className="mt-1 text-2xl font-bold">{t.count}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
<p className="mt-1 text-2xl font-bold">{typeStat.count}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(typeStat.size)}
</p>
</div>
))}
</div>
</WidgetBoundary>
<FileUpload onUploaded={handleUploaded} />
<WidgetBoundary title={t("title")} skeletonHeight={160}>
<FileUpload onUploaded={() => router.refresh()} />
</WidgetBoundary>
{/* 筛选与批量操作工具栏 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="Filter by type" />
<SelectValue placeholder={t("filter.byType")} />
</SelectTrigger>
<SelectContent>
{TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
{renderTypeLabel(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1">
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Search
className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
placeholder="Search by file name..."
placeholder={t("filter.search")}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
aria-label={t("filter.search")}
/>
</div>
</div>
{selectedIds.size > 0 ? (
<div className="flex items-center gap-2">
<Badge variant="secondary">{selectedIds.size} selected</Badge>
<Badge variant="secondary">
{t("selection.selected", { count: selectedIds.size })}
</Badge>
<Button
type="button"
variant="destructive"
@@ -204,18 +191,18 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
disabled={deleting}
onClick={() => void handleBatchDelete()}
>
<Trash2 className="mr-2 h-4 w-4" />
{deleting ? "Deleting..." : "Delete Selected"}
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
{deleting ? t("selection.deleting") : t("selection.deleteSelected")}
</Button>
</div>
) : null}
</div>
{/* 文件列表 */}
<WidgetBoundary title={t("title")} skeletonHeight={400}>
{filteredFiles.length === 0 ? (
<EmptyState
title="No files found"
description="Try adjusting your filters or upload a new file."
title={t("empty.title")}
description={t("empty.description")}
icon={FileWarning}
className="h-auto border-none shadow-none"
/>
@@ -225,13 +212,13 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
<Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={toggleAll}
aria-label="Select all"
aria-label={t("columns.file")}
/>
<span className="flex-1">File</span>
<span className="hidden w-24 sm:block">Size</span>
<span className="hidden w-32 md:block">Type</span>
<span className="hidden w-32 md:block">Uploaded</span>
<span className="w-24 text-right">Actions</span>
<span className="flex-1">{t("columns.file")}</span>
<span className="hidden w-24 sm:block">{t("columns.size")}</span>
<span className="hidden w-32 md:block">{t("columns.type")}</span>
<span className="hidden w-32 md:block">{t("columns.uploaded")}</span>
<span className="w-24 text-right">{t("columns.actions")}</span>
</div>
<ul className="divide-y">
{filteredFiles.map((file) => {
@@ -254,6 +241,7 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
aria-label={`${t("columns.file")}: ${file.originalName}`}
>
{file.originalName}
</a>
@@ -264,7 +252,10 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
{formatFileSize(file.size)}
</span>
<span className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block" title={file.mimeType}>
<span
className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block"
title={file.mimeType}
>
{file.mimeType}
</span>
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
@@ -284,6 +275,7 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
</ul>
</div>
)}
</WidgetBoundary>
</div>
)
}

View File

@@ -1,126 +0,0 @@
"use client"
import { useState } from "react"
import { Download, Trash2, FileWarning } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import type { FileAttachment } from "../types"
interface FileListProps {
files: FileAttachment[]
canDelete?: boolean
onDeleted?: (id: string) => void
emptyTitle?: string
emptyDescription?: string
}
export function FileList({
files,
canDelete = false,
onDeleted,
emptyTitle = "No files",
emptyDescription = "There are no files yet.",
}: FileListProps) {
const [deletingId, setDeletingId] = useState<string | null>(null)
const handleDelete = async (file: FileAttachment) => {
setDeletingId(file.id)
try {
const res = await fetch(`/api/files/${file.id}`, { method: "DELETE" })
const body = await res.json().catch(() => null)
if (!res.ok || !body?.success) {
toast.error(body?.message || "Failed to delete file")
return
}
toast.success("File deleted")
onDeleted?.(file.id)
} catch {
toast.error("Failed to delete file")
} finally {
setDeletingId(null)
}
}
if (files.length === 0) {
return (
<EmptyState
title={emptyTitle}
description={emptyDescription}
icon={FileWarning}
className="h-auto border-none shadow-none"
/>
)
}
return (
<ul className="divide-y rounded-md border">
{files.map((file) => (
<li
key={file.id}
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
>
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<a
href={file.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
>
{file.originalName}
</a>
<span className="shrink-0 text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{file.mimeType}</span>
<span>·</span>
<span>{formatDate(file.createdAt, "zh-CN")}</span>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
asChild
variant="ghost"
size="icon"
className="h-8 w-8"
title="Download"
>
<a
href={file.url ?? "#"}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
>
<Download className="h-4 w-4" />
<span className="sr-only">Download</span>
</a>
</Button>
{canDelete ? (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
title="Delete"
disabled={deletingId === file.id}
onClick={() => void handleDelete(file)}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
) : null}
</div>
</li>
))}
</ul>
)
}

View File

@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
import { useTranslations } from "next-intl"
import { Eye } from "lucide-react"
import {
@@ -26,17 +27,26 @@ interface FilePreviewDialogProps {
export function FilePreviewDialog({
file,
trigger,
triggerLabel = "Preview",
triggerLabel,
triggerVariant = "outline",
triggerSize = "sm",
}: FilePreviewDialogProps) {
}: FilePreviewDialogProps): React.ReactElement {
const t = useTranslations("files.preview")
return (
<Dialog>
<DialogTrigger asChild>
{trigger ?? (
<Button type="button" variant={triggerVariant} size={triggerSize}>
<Eye className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"} />
{triggerLabel ? <span>{triggerLabel}</span> : <span className="sr-only">Preview</span>}
<Eye
className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"}
aria-hidden="true"
/>
{triggerLabel ? (
<span>{triggerLabel}</span>
) : (
<span className="sr-only">{t("trigger")}</span>
)}
</Button>
)}
</DialogTrigger>
@@ -44,7 +54,7 @@ export function FilePreviewDialog({
<DialogHeader>
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
<DialogDescription>
File preview · {file.mimeType}
{t("title")} · {file.mimeType}
</DialogDescription>
</DialogHeader>
<div className="max-h-[75vh] overflow-auto">

View File

@@ -1,11 +1,12 @@
"use client"
import { useState } from "react"
import { useTranslations } from "next-intl"
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
import { FileIcon } from "./file-icon"
import { formatFileSize } from "@/shared/lib/file-storage"
import { useFilePreview, useImageZoom } from "../hooks/use-file-preview"
import type { FileAttachment } from "../types"
interface FilePreviewProps {
@@ -42,7 +43,8 @@ function classify(mimeType: string): PreviewKind {
return "other"
}
export function FilePreview({ file, className }: FilePreviewProps) {
export function FilePreview({ file, className }: FilePreviewProps): React.ReactElement {
const t = useTranslations("files.preview")
const kind = classify(file.mimeType)
const url = file.url ?? "#"
@@ -66,9 +68,10 @@ export function FilePreview({ file, className }: FilePreviewProps) {
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
aria-label={`${t("download")} ${file.originalName}`}
>
<Download className="mr-2 h-4 w-4" />
Download
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
{t("download")}
</a>
</Button>
</div>
@@ -86,7 +89,7 @@ function PreviewBody({
kind: PreviewKind
file: FileAttachment
url: string
}) {
}): React.ReactElement {
if (kind === "image") {
return <ImagePreview url={url} alt={file.originalName} />
}
@@ -96,6 +99,7 @@ function PreviewBody({
<iframe
src={url}
title={file.originalName}
aria-label={`PDF preview: ${file.originalName}`}
className="h-[70vh] w-full rounded-md border"
/>
)
@@ -105,35 +109,12 @@ function PreviewBody({
return <TextPreview url={url} />
}
// Office / other: show info card + download button
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
<p className="mt-3 text-sm font-medium">
{kind === "office" ? "Office file preview not available" : "Preview not available"}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{kind === "office"
? "Download the file to view its contents in your Office application."
: "Download the file to view its contents."}
</p>
<Button asChild variant="outline" size="sm" className="mt-4">
<a
href={url}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
>
<Download className="mr-2 h-4 w-4" />
Download
</a>
</Button>
</div>
)
return <OtherPreview kind={kind} file={file} url={url} />
}
function ImagePreview({ url, alt }: { url: string; alt: string }) {
const [zoom, setZoom] = useState(1)
function ImagePreview({ url, alt }: { url: string; alt: string }): React.ReactElement {
const t = useTranslations("files.preview")
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useImageZoom()
return (
<div className="space-y-2">
@@ -143,13 +124,13 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))}
disabled={zoom <= 0.25}
onClick={zoomOut}
disabled={!canZoomOut}
aria-label={t("zoomOut")}
>
<ZoomOut className="h-4 w-4" />
<span className="sr-only">Zoom out</span>
<ZoomOut className="h-4 w-4" aria-hidden="true" />
</Button>
<span className="text-xs text-muted-foreground w-12 text-center">
<span className="w-12 text-center text-xs text-muted-foreground" aria-live="polite">
{Math.round(zoom * 100)}%
</span>
<Button
@@ -157,14 +138,17 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
variant="outline"
size="icon"
className="h-8 w-8"
onClick={() => setZoom((z) => Math.min(4, z + 0.25))}
disabled={zoom >= 4}
onClick={zoomIn}
disabled={!canZoomIn}
aria-label={t("zoomIn")}
>
<ZoomIn className="h-4 w-4" />
<span className="sr-only">Zoom in</span>
<ZoomIn className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
<div className="overflow-auto rounded-md border bg-muted/30 p-2" style={{ maxHeight: "70vh" }}>
<div
className="overflow-auto rounded-md border bg-muted/30 p-2"
style={{ maxHeight: "70vh" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={url}
@@ -177,34 +161,23 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
)
}
function TextPreview({ url }: { url: string }) {
const [content, setContent] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const load = async () => {
setLoading(true)
setError(null)
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
setContent(text)
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load text")
} finally {
setLoading(false)
}
}
function TextPreview({ url }: { url: string }): React.ReactElement {
const t = useTranslations("files.preview.text")
const { content, error, loading, load } = useFilePreview()
if (content === null && !error && !loading) {
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileText className="h-12 w-12 text-muted-foreground" />
<p className="mt-3 text-sm font-medium">Text file</p>
<p className="mt-1 text-xs text-muted-foreground">Click below to load the content.</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => void load()}>
Load preview
<FileText className="h-12 w-12 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-sm font-medium">{t("title")}</p>
<p className="mt-1 text-xs text-muted-foreground">{t("hint")}</p>
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={() => void load(url)}
>
{t("load")}
</Button>
</div>
)
@@ -212,16 +185,29 @@ function TextPreview({ url }: { url: string }) {
if (loading) {
return (
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
Loading...
<div
role="status"
className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"
>
{t("loading")}
</div>
)
}
if (error) {
return (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
Failed to load text: {error}
<div
role="alert"
className="flex flex-col items-center justify-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
>
<span>{t("error", { message: error })}</span>
<Button
variant="outline"
size="sm"
onClick={() => void load(url)}
>
{t("load")}
</Button>
</div>
)
}
@@ -232,3 +218,38 @@ function TextPreview({ url }: { url: string }) {
</pre>
)
}
function OtherPreview({
kind,
file,
url,
}: {
kind: PreviewKind
file: FileAttachment
url: string
}): React.ReactElement {
const t = useTranslations("files.preview")
const isOffice = kind === "office"
const title = isOffice ? t("office.title") : t("other.title")
const hint = isOffice ? t("office.hint") : t("other.hint")
return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
<p className="mt-3 text-sm font-medium">{title}</p>
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
<Button asChild variant="outline" size="sm" className="mt-4">
<a
href={url}
download={file.originalName}
target="_blank"
rel="noopener noreferrer"
aria-label={`${t("download")} ${file.originalName}`}
>
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
{t("download")}
</a>
</Button>
</div>
)
}

View File

@@ -1,18 +1,15 @@
"use client"
import { useCallback, useRef, useState } from "react"
import { useTranslations } from "next-intl"
import { UploadCloud, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Progress } from "@/shared/components/ui/progress"
import { cn } from "@/shared/lib/utils"
import {
ALLOWED_MIME_TYPES,
formatFileSize,
MAX_FILE_SIZE,
} from "@/shared/lib/file-storage"
import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon"
import { useFileUpload } from "../hooks/use-file-upload"
import type { FileTargetType, FileUploadResult } from "../types"
interface FileUploadProps {
@@ -23,146 +20,31 @@ interface FileUploadProps {
className?: string
}
interface UploadTask {
file: File
progress: number
status: "uploading" | "success" | "error"
message?: string
result?: FileUploadResult
}
const ACCEPT_ATTR = (ALLOWED_MIME_TYPES as readonly string[]).join(",")
export function FileUpload({
targetType,
targetId,
onUploaded,
multiple = true,
className,
}: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const [tasks, setTasks] = useState<UploadTask[]>([])
const validateFile = (file: File): string | null => {
if (file.size === 0) return "File is empty"
if (file.size > MAX_FILE_SIZE) return "File size exceeds 10MB limit"
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
return `File type ${file.type || "unknown"} is not allowed`
}
return null
}
const uploadOne = useCallback(
async (file: File): Promise<void> => {
const taskId = `${file.name}-${file.size}-${Date.now()}`
setTasks((prev) => [
...prev,
{ file, progress: 0, status: "uploading" },
])
const validationError = validateFile(file)
if (validationError) {
setTasks((prev) =>
prev.map((t) =>
t.file === file
? { ...t, status: "error", message: validationError, progress: 100 }
: t
)
)
toast.error(`${file.name}: ${validationError}`)
return
}
try {
const formData = new FormData()
formData.append("file", file)
if (targetType) formData.append("targetType", targetType)
if (targetId) formData.append("targetId", targetId)
const xhr = new XMLHttpRequest()
const result = await new Promise<FileUploadResult>((resolve, reject) => {
xhr.open("POST", "/api/upload")
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100)
setTasks((prev) =>
prev.map((t) =>
t.file === file ? { ...t, progress: pct } : t
)
)
}
}
xhr.onload = () => {
try {
const body = JSON.parse(xhr.responseText)
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
resolve(body as FileUploadResult)
} else {
reject(new Error(body.message || "Upload failed"))
}
} catch {
reject(new Error("Invalid response"))
}
}
xhr.onerror = () => reject(new Error("Network error"))
xhr.send(formData)
})
setTasks((prev) =>
prev.map((t) =>
t.file === file
? { ...t, status: "success", progress: 100, result }
: t
)
)
onUploaded?.(result)
toast.success(`${file.name} uploaded`)
void taskId
} catch (e) {
const message = e instanceof Error ? e.message : "Upload failed"
setTasks((prev) =>
prev.map((t) =>
t.file === file ? { ...t, status: "error", message } : t
)
)
toast.error(`${file.name}: ${message}`)
}
},
[targetType, targetId, onUploaded]
)
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return
const files = Array.from(fileList)
if (!multiple) {
void uploadOne(files[0])
} else {
files.forEach((f) => void uploadOne(f))
}
},
[uploadOne, multiple]
)
const handleDrop = useCallback(
(e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
},
[handleFiles]
)
const removeTask = (task: UploadTask) => {
setTasks((prev) => prev.filter((t) => t !== task))
}
}: FileUploadProps): React.ReactElement {
const t = useTranslations("files.upload")
const {
tasks,
isDragging,
inputRef,
setIsDragging,
handleFiles,
removeTask,
acceptAttr,
maxFileSize,
} = useFileUpload({ targetType, targetId, multiple, onUploaded })
return (
<div className={cn("space-y-4", className)}>
<div
role="button"
tabIndex={0}
aria-describedby="file-upload-hint"
onClick={() => inputRef.current?.click()}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
@@ -175,7 +57,11 @@ export function FileUpload({
setIsDragging(true)
}}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}}
className={cn(
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
isDragging
@@ -183,19 +69,18 @@ export function FileUpload({
: "border-input hover:border-primary/50 hover:bg-accent/50"
)}
>
<UploadCloud className="h-10 w-10 text-muted-foreground" />
<p className="mt-2 text-sm font-medium">
Click to upload or drag and drop
</p>
<p className="mt-1 text-xs text-muted-foreground">
Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {formatFileSize(MAX_FILE_SIZE)}
<UploadCloud className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
<p className="mt-2 text-sm font-medium">{t("title")}</p>
<p id="file-upload-hint" className="mt-1 text-xs text-muted-foreground">
{t("hint", { size: formatFileSize(maxFileSize) })}
</p>
<input
ref={inputRef}
type="file"
className="hidden"
accept={ACCEPT_ATTR}
accept={acceptAttr}
multiple={multiple}
aria-label={t("title")}
onChange={(e) => {
handleFiles(e.target.files)
e.target.value = ""
@@ -204,7 +89,7 @@ export function FileUpload({
</div>
{tasks.length > 0 ? (
<ul className="space-y-2">
<ul className="space-y-2" aria-live="polite">
{tasks.map((task, idx) => (
<li
key={`${task.file.name}-${idx}`}
@@ -221,13 +106,17 @@ export function FileUpload({
</span>
</div>
{task.status === "uploading" ? (
<Progress value={task.progress} className="h-1.5" />
<Progress
value={task.progress}
className="h-1.5"
aria-label={`Uploading ${task.file.name}`}
/>
) : null}
{task.status === "error" ? (
<p className="text-xs text-destructive">{task.message}</p>
) : null}
{task.status === "success" ? (
<p className="text-xs text-green-600">Uploaded</p>
<p className="text-xs text-green-600">{t("uploaded")}</p>
) : null}
</div>
<Button
@@ -236,9 +125,9 @@ export function FileUpload({
size="icon"
className="h-7 w-7"
onClick={() => removeTask(task)}
aria-label="Remove"
aria-label={t("remove")}
>
<X className="h-4 w-4" />
<X className="h-4 w-4" aria-hidden="true" />
</Button>
</li>
))}

View File

@@ -0,0 +1,132 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import type { FileAttachment } from "../types"
export interface UseFileBatchOperationsOptions {
files: FileAttachment[]
onAfterDelete?: () => void
}
export interface UseFileBatchOperationsReturn {
selectedIds: Set<string>
typeFilter: string
search: string
deleting: boolean
setTypeFilter: (v: string) => void
setSearch: (v: string) => void
filteredFiles: FileAttachment[]
allSelected: boolean
someSelected: boolean
toggleAll: () => void
toggleOne: (id: string) => void
handleBatchDelete: () => Promise<void>
}
/**
* 文件批量操作 hook封装筛选、选择、批量删除逻辑。
*
* 与 UI 解耦,便于在 AdminFilesView 或其他列表场景复用。
*/
export function useFileBatchOperations({
files,
onAfterDelete,
}: UseFileBatchOperationsOptions): UseFileBatchOperationsReturn {
const router = useRouter()
const t = useTranslations("files.admin")
const [typeFilter, setTypeFilter] = useState<string>("all")
const [search, setSearch] = useState<string>("")
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [deleting, setDeleting] = useState(false)
const filteredFiles = useMemo(() => {
return files.filter((f) => {
if (typeFilter !== "all") {
if (typeFilter.endsWith("/")) {
if (!f.mimeType.startsWith(typeFilter)) return false
} else if (f.mimeType !== typeFilter) {
return false
}
}
if (search.trim()) {
const kw = search.trim().toLowerCase()
if (
!f.originalName.toLowerCase().includes(kw) &&
!f.filename.toLowerCase().includes(kw)
) {
return false
}
}
return true
})
}, [files, typeFilter, search])
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
const someSelected = selectedIds.size > 0 && !allSelected
const toggleAll = useCallback(() => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
}
}, [allSelected, filteredFiles])
const toggleOne = useCallback((id: string) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}, [])
const handleBatchDelete = useCallback(async () => {
if (selectedIds.size === 0) return
const ids = Array.from(selectedIds)
setDeleting(true)
try {
const res = await fetch("/api/files/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
})
const body = (await res.json().catch(() => null)) as {
success?: boolean
message?: string
deletedCount?: number
} | null
if (!res.ok || !body?.success) {
toast.error(body?.message || t("selection.deleteFailed"))
return
}
toast.success(t("selection.deleted", { count: body.deletedCount ?? 0 }))
setSelectedIds(new Set())
onAfterDelete?.()
router.refresh()
} catch {
toast.error(t("selection.deleteFailed"))
} finally {
setDeleting(false)
}
}, [selectedIds, onAfterDelete, router, t])
return {
selectedIds,
typeFilter,
search,
deleting,
setTypeFilter,
setSearch,
filteredFiles,
allSelected,
someSelected,
toggleAll,
toggleOne,
handleBatchDelete,
}
}

View File

@@ -0,0 +1,64 @@
"use client"
import { useCallback, useState } from "react"
/**
* 文本文件预览 hook封装 fetch + 错误处理 + 状态机。
*
* 与 TextPreview 组件解耦,便于复用与测试。
* 错误消息保留原始字符串,由组件层用 i18n 翻译。
*/
export interface UseFilePreviewReturn {
content: string | null
error: string | null
loading: boolean
load: (url: string) => Promise<void>
}
export function useFilePreview(): UseFilePreviewReturn {
const [content, setContent] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const load = useCallback(
async (url: string): Promise<void> => {
setLoading(true)
setError(null)
try {
const res = await fetch(url)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const text = await res.text()
setContent(text)
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load text")
} finally {
setLoading(false)
}
},
[]
)
return { content, error, loading, load }
}
/**
* 图片预览缩放控制 hook
*/
export function useImageZoom(initial = 1, min = 0.25, max = 4): {
zoom: number
zoomIn: () => void
zoomOut: () => void
canZoomIn: boolean
canZoomOut: boolean
} {
const [zoom, setZoom] = useState(initial)
const zoomIn = useCallback(() => setZoom((z) => Math.min(max, z + 0.25)), [max])
const zoomOut = useCallback(() => setZoom((z) => Math.max(min, z - 0.25)), [min])
return {
zoom,
zoomIn,
zoomOut,
canZoomIn: zoom < max,
canZoomOut: zoom > min,
}
}

View File

@@ -0,0 +1,181 @@
"use client"
import { useCallback, useRef, useState } from "react"
import { toast } from "sonner"
import {
ALLOWED_MIME_TYPES,
formatFileSize,
MAX_FILE_SIZE,
} from "@/shared/lib/file-storage"
import { useTranslations } from "next-intl"
import type { FileTargetType, FileUploadResult } from "../types"
export interface UploadTask {
file: File
progress: number
status: "uploading" | "success" | "error"
message?: string
result?: FileUploadResult
}
export interface UseFileUploadOptions {
targetType?: FileTargetType
targetId?: string
multiple?: boolean
onUploaded?: (result: FileUploadResult) => void
}
export interface UseFileUploadReturn {
tasks: UploadTask[]
isDragging: boolean
inputRef: React.RefObject<HTMLInputElement | null>
setIsDragging: (v: boolean) => void
handleFiles: (fileList: FileList | null) => void
removeTask: (task: UploadTask) => void
acceptAttr: string
maxFileSize: number
}
/**
* 文件上传 hook封装 XHR 上传、进度、状态机与校验。
*
* 与 UI 解耦,便于在 `FileUpload`、`AvatarUpload` 等组件中复用,
* 也可独立测试(无 DOM 依赖的逻辑部分)。
*/
export function useFileUpload({
targetType,
targetId,
multiple = true,
onUploaded,
}: UseFileUploadOptions): UseFileUploadReturn {
const t = useTranslations("files.upload")
const inputRef = useRef<HTMLInputElement | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [tasks, setTasks] = useState<UploadTask[]>([])
const validateFile = useCallback(
(file: File): string | null => {
if (file.size === 0) return t("empty")
if (file.size > MAX_FILE_SIZE) return t("tooLarge", { limit: formatFileSize(MAX_FILE_SIZE) })
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
return t("invalidType", { type: file.type || "unknown" })
}
return null
},
[t]
)
const uploadOne = useCallback(
async (file: File): Promise<void> => {
setTasks((prev) => [
...prev,
{ file, progress: 0, status: "uploading" },
])
const validationError = validateFile(file)
if (validationError) {
setTasks((prev) =>
prev.map((tk) =>
tk.file === file
? { ...tk, status: "error", message: validationError, progress: 100 }
: tk
)
)
toast.error(t("error", { name: file.name, message: validationError }))
return
}
try {
const formData = new FormData()
formData.append("file", file)
if (targetType) formData.append("targetType", targetType)
if (targetId) formData.append("targetId", targetId)
const xhr = new XMLHttpRequest()
const result = await new Promise<FileUploadResult>((resolve, reject) => {
xhr.open("POST", "/api/upload")
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const pct = Math.round((e.loaded / e.total) * 100)
setTasks((prev) =>
prev.map((tk) =>
tk.file === file ? { ...tk, progress: pct } : tk
)
)
}
}
xhr.onload = () => {
try {
const body = JSON.parse(xhr.responseText) as { success?: boolean; message?: string } & Partial<FileUploadResult>
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
resolve({
id: body.id ?? "",
url: body.url ?? "",
filename: body.filename ?? file.name,
originalName: body.originalName ?? file.name,
size: body.size ?? file.size,
mimeType: body.mimeType ?? file.type,
})
} else {
reject(new Error(body.message || "Upload failed"))
}
} catch {
reject(new Error(t("invalidResponse")))
}
}
xhr.onerror = () => reject(new Error(t("networkError")))
xhr.send(formData)
})
setTasks((prev) =>
prev.map((tk) =>
tk.file === file
? { ...tk, status: "success", progress: 100, result }
: tk
)
)
onUploaded?.(result)
toast.success(t("success", { name: file.name }))
} catch (e) {
const message = e instanceof Error ? e.message : t("networkError")
setTasks((prev) =>
prev.map((tk) =>
tk.file === file ? { ...tk, status: "error", message } : tk
)
)
toast.error(t("error", { name: file.name, message }))
}
},
[targetType, targetId, onUploaded, t, validateFile]
)
const handleFiles = useCallback(
(fileList: FileList | null) => {
if (!fileList || fileList.length === 0) return
const files = Array.from(fileList)
if (!multiple) {
void uploadOne(files[0])
} else {
files.forEach((f) => void uploadOne(f))
}
},
[uploadOne, multiple]
)
const removeTask = useCallback((task: UploadTask) => {
setTasks((prev) => prev.filter((tk) => tk !== task))
}, [])
return {
tasks,
isDragging,
inputRef,
setIsDragging,
handleFiles,
removeTask,
acceptAttr: (ALLOWED_MIME_TYPES as readonly string[]).join(","),
maxFileSize: MAX_FILE_SIZE,
}
}

View File

@@ -0,0 +1,84 @@
import { z } from "zod"
import { MAX_FILE_SIZE } from "@/shared/lib/file-storage"
import type { FileTargetType } from "./types"
/**
* files 模块 Zod 校验 schema
*
* 用于 Server Action 与 API 路由的输入校验,替代手写 typeof 检查与 as 断言。
*/
// FileTargetType 枚举值同步到 Zod保持单一来源types.ts
const FILE_TARGET_TYPES: readonly FileTargetType[] = [
"exam",
"textbook",
"question",
"announcement",
"homework",
"user_avatar",
"message",
]
export const FileTargetTypeSchema = z.enum(
FILE_TARGET_TYPES as unknown as [FileTargetType, ...FileTargetType[]]
)
/**
* 文件上传元数据校验targetType / targetId 来自 FormData
*
* targetType 可选targetId 仅在 targetType 提供时才校验长度。
*/
export const UploadMetadataSchema = z.object({
targetType: FileTargetTypeSchema.optional().nullable(),
targetId: z
.string()
.trim()
.max(128)
.optional()
.nullable()
.transform((v) => (v && v.length > 0 ? v : null)),
})
export type UploadMetadata = z.infer<typeof UploadMetadataSchema>
/**
* 批量删除请求体校验
*
* - ids 必须为非空字符串数组
* - 单次最多 100 条,防止超长 SQL
* - 每条 id 长度上限 128与 schema.id varchar(128) 一致)
*/
export const BatchDeleteSchema = z.object({
ids: z
.array(z.string().min(1).max(128))
.min(1, "No file ids provided")
.max(100, "Cannot delete more than 100 files at once"),
})
export type BatchDeleteInput = z.infer<typeof BatchDeleteSchema>
/**
* 管理员文件列表筛选参数校验
*
* - mimeType精确或前缀匹配"image/"
* - search文件名模糊匹配
* - limit1..200,默认 100
* - offset>=0默认 0
*/
export const FileListQuerySchema = z.object({
mimeType: z.string().trim().max(128).optional().nullable(),
search: z.string().trim().max(255).optional().nullable(),
limit: z.number().int().min(1).max(200).default(100),
offset: z.number().int().min(0).default(0),
})
export type FileListQuery = z.infer<typeof FileListQuerySchema>
/**
* 文件大小校验(用于客户端/服务端一致校验)
*/
export function validateFileSize(size: number): boolean {
return size > 0 && size <= MAX_FILE_SIZE
}

View File

@@ -1,5 +1,14 @@
// 文件关联的目标资源类型(多态关联)
export type FileTargetType = "exam" | "textbook" | "question" | "announcement" | "homework"
// P1-5 新增 "user_avatar":用于用户头像上传场景的 targetType 字段对齐
// P2-3 新增 "message":用于私信附件上传场景的 targetType 字段对齐
export type FileTargetType =
| "exam"
| "textbook"
| "question"
| "announcement"
| "homework"
| "user_avatar"
| "message"
// 文件附件记录DB 行的 TypeScript 表示)
export interface FileAttachment {

View File

@@ -30,7 +30,12 @@ import {
getUnreadNotificationCount,
archiveNotification,
} from "./data-access"
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
import {
getNotificationPreferences,
upsertNotificationPreferences,
} from "./preferences"
import { UpdateNotificationPreferencesSchema } from "./schema"
import type { NotificationPayload, ChannelSendResult, Notification, NotificationPreferences, UpdateNotificationPreferencesInput } from "./types"
/**
* Zod 校验通知负载sendNotificationAction 入参)
@@ -298,3 +303,81 @@ export async function archiveNotificationAction(
return { success: false, message: "Unexpected error" }
}
}
// ---------------------------------------------------------------------------
// 通知偏好 Server Actions
//
// V3-P1-3: 从 messaging/actions.ts 迁移至 notifications/actions.ts
// 使通知偏好的 data-access、schema、actions 位于同一模块。
// 权限复用 MESSAGE_READ任何能读消息的用户都能管理通知偏好
// ---------------------------------------------------------------------------
/**
* 获取当前用户的通知偏好。
*/
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const prefs = await getNotificationPreferences(ctx.userId)
return { success: true, data: prefs }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/**
* 更新当前用户的通知偏好。
*
* 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交)和时间字符串。
*/
export async function updateNotificationPreferencesAction(
prevState: ActionState<NotificationPreferences> | null,
formData: FormData
): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const parseBool = (key: string): boolean => formData.get(key) === "on"
const parseTime = (key: string): string | null => {
const v = formData.get(key)
if (typeof v !== "string") return null
const trimmed = v.trim()
return trimmed.length > 0 ? trimmed : null
}
const parsed = UpdateNotificationPreferencesSchema.safeParse({
emailEnabled: parseBool("emailEnabled"),
smsEnabled: parseBool("smsEnabled"),
pushEnabled: parseBool("pushEnabled"),
homeworkNotifications: parseBool("homeworkNotifications"),
gradeNotifications: parseBool("gradeNotifications"),
announcementNotifications: parseBool("announcementNotifications"),
messageNotifications: parseBool("messageNotifications"),
attendanceNotifications: parseBool("attendanceNotifications"),
quietHoursEnabled: parseBool("quietHoursEnabled"),
quietHoursStart: parseTime("quietHoursStart"),
quietHoursEnd: parseTime("quietHoursEnd"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input: UpdateNotificationPreferencesInput = parsed.data
const updated = await upsertNotificationPreferences(ctx.userId, input)
if (!updated) {
return { success: false, message: "Failed to update notification preferences" }
}
revalidatePath("/settings")
return { success: true, message: "Notification preferences updated", data: updated }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}

View File

@@ -38,6 +38,7 @@ function getSmsConfig(): {
accessKeySecret: string | undefined
signName: string | undefined
templateCode: string | undefined
smsSdkAppId: string | undefined
} {
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
return {
@@ -46,6 +47,7 @@ function getSmsConfig(): {
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
signName: process.env.SMS_SIGN_NAME,
templateCode: process.env.SMS_TEMPLATE_CODE,
smsSdkAppId: process.env.SMS_SDK_APP_ID,
}
}
@@ -198,7 +200,7 @@ class TencentSmsSender implements NotificationChannelSender {
const params = buildTemplateParams(payload)
const response = await client.SendSms({
PhoneNumberSet: [`+86${recipient.phone}`],
SmsSdkAppId: this.config.templateCode ?? "",
SmsSdkAppId: this.config.smsSdkAppId ?? "",
SignName: this.config.signName ?? "",
TemplateId: this.config.templateCode ?? "",
TemplateParamSet: [params.title, params.content],

View File

@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -32,6 +32,7 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
diagnostic: Stethoscope,
}
/** 轮询降级间隔(毫秒) */

View File

@@ -5,7 +5,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -21,8 +21,11 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
diagnostic: Stethoscope,
}
const TYPE_KEYS: NotificationType[] = ["message", "announcement", "homework", "grade", "diagnostic"]
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
low: "bg-muted text-muted-foreground",
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
@@ -103,7 +106,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
>
{t("filter.all")}
</Button>
{(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => (
{TYPE_KEYS.map((type) => (
<Button
key={type}
variant={filterType === type ? "default" : "outline"}

View File

@@ -37,7 +37,7 @@ import type {
const toIsoRequired = (d: Date): string => d.toISOString()
const isNotificationType = (v: unknown): v is NotificationType =>
v === "message" || v === "announcement" || v === "homework" || v === "grade"
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"
const toNotificationType = (v: string): NotificationType =>
isNotificationType(v) ? v : "message"
@@ -207,6 +207,7 @@ export async function logNotificationSend(
const errorPart = result.error ? ` error="${result.error}"` : ""
// 始终输出 console 日志(便于开发调试)
// TODO V3-P2-8: 接入统一日志服务shared/lib/logger替换 console.info
console.info(
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
)
@@ -227,6 +228,7 @@ export async function logNotificationSend(
})
} catch (dbError) {
// DB 写入失败不阻塞通知流程,仅记录错误
// TODO V3-P2-8: 接入统一日志服务shared/lib/logger替换 console.error
console.error("[NotificationLog] Failed to persist log:", dbError)
}
}

View File

@@ -141,12 +141,6 @@ export async function sendNotification(
export async function sendBatchNotifications(
payloads: NotificationPayload[]
): Promise<ChannelSendResult[][]> {
// 并行处理每个 payload
const results = await Promise.all(payloads.map((p) => sendNotification(p)))
// 汇总日志
const flatResults = results.flat()
logNotificationSendBatch(flatResults)
return results
// 并行处理每个 payload(每条通知的日志已在 sendNotification 内部记录)
return Promise.all(payloads.map((p) => sendNotification(p)))
}

View File

@@ -48,7 +48,11 @@ export {
markNotificationAsReadAction,
markAllNotificationsAsReadAction,
archiveNotificationAction,
getNotificationPreferencesAction,
updateNotificationPreferencesAction,
} from "./actions"
export { UpdateNotificationPreferencesSchema } from "./schema"
export type { UpdateNotificationPreferencesFormInput } from "./schema"
export { NotificationList, NotificationDropdown } from "./components"
export type {
NotificationChannel,

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest"
import { UpdateNotificationPreferencesSchema } from "./schema"
/**
* 通知偏好 Schema 测试
*
* V3-P1-3: 从 messaging/schema.test.ts 迁移至 notifications/schema.test.ts
* 与 notifications/schema.ts 位于同一模块。
*/
describe("UpdateNotificationPreferencesSchema", () => {
const validInput = {
emailEnabled: true,
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: false,
quietHoursEnabled: false,
}
it("should parse valid input without quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
expect(result.success).toBe(true)
})
it("should parse valid input with quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "22:00",
quietHoursEnd: "07:00",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.quietHoursStart).toBe("22:00")
expect(result.data.quietHoursEnd).toBe("07:00")
}
})
it("should accept null quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: null,
quietHoursEnd: null,
})
expect(result.success).toBe(true)
})
it("should accept undefined quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.quietHoursStart).toBeUndefined()
expect(result.data.quietHoursEnd).toBeUndefined()
}
})
it("should reject invalid time format for quietHoursStart", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "25:00",
})
expect(result.success).toBe(false)
})
it("should reject invalid time format for quietHoursEnd", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursEnd: "12:60",
})
expect(result.success).toBe(false)
})
it("should reject non-time string for quietHoursStart", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "not-a-time",
})
expect(result.success).toBe(false)
})
it("should accept boundary time 00:00", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "00:00",
})
expect(result.success).toBe(true)
})
it("should accept boundary time 23:59", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursEnd: "23:59",
})
expect(result.success).toBe(true)
})
it("should reject non-boolean emailEnabled", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
emailEnabled: "yes",
})
expect(result.success).toBe(false)
})
it("should reject non-boolean smsEnabled", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
smsEnabled: 1,
})
expect(result.success).toBe(false)
})
it("should reject missing required boolean field", () => {
const inputWithoutEmail = {
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: false,
quietHoursEnabled: false,
}
const result = UpdateNotificationPreferencesSchema.safeParse(inputWithoutEmail)
expect(result.success).toBe(false)
})
})

View File

@@ -0,0 +1,25 @@
import { z } from "zod"
/**
* 校验通知偏好更新表单8 个布尔字段 + 免打扰时段,来自 checkbox/FormData
*
* V3-P1-3: 从 messaging/schema.ts 迁移至 notifications/schema.ts
* 使通知偏好校验逻辑与其消费方notifications 模块)位于同一模块。
*/
export const UpdateNotificationPreferencesSchema = z.object({
emailEnabled: z.boolean(),
smsEnabled: z.boolean(),
pushEnabled: z.boolean(),
homeworkNotifications: z.boolean(),
gradeNotifications: z.boolean(),
announcementNotifications: z.boolean(),
messageNotifications: z.boolean(),
attendanceNotifications: z.boolean(),
quietHoursEnabled: z.boolean(),
quietHoursStart: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
quietHoursEnd: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
})
export type UpdateNotificationPreferencesFormInput = z.infer<
typeof UpdateNotificationPreferencesSchema
>

View File

@@ -18,7 +18,7 @@
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
/** 站内通知类型message_notifications.type 列) */
export type NotificationType = "message" | "announcement" | "homework" | "grade"
export type NotificationType = "message" | "announcement" | "homework" | "grade" | "diagnostic"
/** 通知优先级message_notifications.priority 列) */
export type NotificationPriority = "low" | "normal" | "high" | "urgent"

View File

@@ -6,6 +6,12 @@ import { eq } from "drizzle-orm"
import type { ActionState } from "@/shared/types/action-state"
import { requireAuth } from "@/shared/lib/auth-guard"
import { logAudit } from "@/shared/lib/audit-logger"
import { resolveDefaultPath } from "@/shared/lib/route-resolver"
import {
rateLimit,
rateLimitKey,
RATE_LIMIT_RULES,
} from "@/shared/lib/rate-limit"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import {
@@ -18,7 +24,6 @@ import {
getOnboardingStatus,
updateUserProfile,
bindParentToChild,
resolveDefaultPathByRoles,
} from "./data-access"
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
@@ -74,7 +79,7 @@ export async function completeOnboardingAction(
const roleNames = ctx.roles
return {
success: true,
data: { defaultPath: resolveDefaultPathByRoles(roleNames) },
data: { defaultPath: resolveDefaultPath(roleNames) },
}
}
@@ -106,6 +111,36 @@ export async function completeOnboardingAction(
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s)
)
// audit-P1-8家长绑定子女独立速率限制。
// 防止被撤销子女关系的家长反复尝试枚举三因子(生日 × 手机后4
// 限制每小时 5 次完整 onboarding 提交,远低于 3.65M 组合枚举所需量级。
// 仅在家长角色且实际提交子女绑定时检查,避免误伤其他角色。
if (normalizedRoles.includes("parent") && input.children.length > 0) {
const bindLimit = await rateLimit({
key: rateLimitKey("onboarding:bind", userId),
limit: RATE_LIMIT_RULES.ONBOARDING_BIND.limit,
windowMs: RATE_LIMIT_RULES.ONBOARDING_BIND.windowMs,
})
if (!bindLimit.success) {
await logAudit({
action: "onboarding.bind_rate_limited",
module: "onboarding",
targetId: userId,
targetType: "user",
detail: {
childrenCount: input.children.length,
retryAfterMs: bindLimit.retryAfterMs,
},
status: "failure",
})
const retryMinutes = Math.ceil(bindLimit.retryAfterMs / 60_000)
return {
success: false,
message: `子女绑定尝试过于频繁,请 ${retryMinutes} 分钟后再试`,
}
}
}
// 收集局部失败项P1-2班级码/子女绑定失败不回滚整个事务
const failures: OnboardingFailureItem[] = []
@@ -190,7 +225,7 @@ export async function completeOnboardingAction(
.set({ onboardedAt: new Date() })
.where(eq(users.id, userId))
return { defaultPath: resolveDefaultPathByRoles(normalizedRoles) }
return { defaultPath: resolveDefaultPath(normalizedRoles) }
})
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross

View File

@@ -1,10 +1,6 @@
"use client"
import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
@@ -12,9 +8,10 @@ import { Label } from "@/shared/components/ui/label"
import { Textarea } from "@/shared/components/ui/textarea"
import { Checkbox } from "@/shared/components/ui/checkbox"
import { cn } from "@/shared/lib/utils"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
import { completeOnboardingAction } from "@/modules/onboarding/actions"
import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
import type { OnboardingStatus } from "@/modules/onboarding/types"
import { useOnboardingForm } from "@/modules/onboarding/hooks/use-onboarding-form"
import { ParentChildrenForm } from "@/modules/onboarding/components/parent-children-form"
interface OnboardingStepperProps {
initialStatus: OnboardingStatus
@@ -22,169 +19,56 @@ interface OnboardingStepperProps {
/**
* v3 i18n所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。
*
* P1-2 重构后结构:
* - 状态与提交逻辑useOnboardingForm Hookhooks/use-onboarding-form.ts
* - 家长子女子表单ParentChildrenFormcomponents/parent-children-form.tsx
* - 本文件:纯展示编排器,负责步骤切换、表单字段、按钮渲染
*/
const STEPS_KEYS = ["roleConfirm", "basicInfo", "roleInfo", "complete"] as const
interface ChildRow {
childEmail: string
childBirthDate: string
childPhoneSuffix: string
childRelation: string
}
const EMPTY_CHILD_ROW: ChildRow = {
childEmail: "",
childBirthDate: "",
childPhoneSuffix: "",
childRelation: "",
}
export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { update } = useSession()
const t = useTranslations("onboarding")
const tCommon = useTranslations("common.actions")
// P1-1URL query 参数持久化当前步骤
const initialStep = clampStep(Number(searchParams.get("step") ?? "0"))
const [step, setStep] = React.useState(initialStep)
const [isSubmitting, setIsSubmitting] = React.useState(false)
const form = useOnboardingForm(initialStatus)
const {
step,
isSubmitting,
name,
phone,
address,
classCodes,
teacherSubjects,
children,
primaryRole,
isStudent,
isTeacher,
isParent,
stepKeys,
currentStepId,
maxStep,
canSkip,
setName,
setPhone,
setAddress,
setClassCodes,
toggleSubject,
addChildRow,
removeChildRow,
updateChildRow,
onNext,
onBack,
onSkip,
onFinish,
} = form
const [name, setName] = React.useState(initialStatus.name ?? "")
const [phone, setPhone] = React.useState("")
const [address, setAddress] = React.useState("")
const [classCodes, setClassCodes] = React.useState("")
const [teacherSubjects, setTeacherSubjects] = React.useState<ClassSubject[]>([])
const [children, setChildren] = React.useState<ChildRow[]>([{ ...EMPTY_CHILD_ROW }])
const primaryRole = initialStatus.roles.primary
const isAdmin = primaryRole === "admin"
const isStudent = primaryRole === "student"
const isTeacher = primaryRole === "teacher"
const isParent = primaryRole === "parent"
const stepKeys = isAdmin ? STEPS_KEYS.filter((_, i) => i !== 2) : STEPS_KEYS
const maxStep = stepKeys.length - 1
const canNext = React.useMemo(() => {
if (step === 1) {
return name.trim().length > 0 && phone.trim().length > 0
}
if (step === 2 && isParent) {
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
)
return validChildren.length > 0
}
return true
}, [step, name, phone, isParent, children])
const toggleSubject = (subject: ClassSubject) => {
setTeacherSubjects((prev) =>
prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]
)
}
const goToStep = React.useCallback(
(next: number) => {
const clamped = Math.max(0, Math.min(maxStep, next))
const params = new URLSearchParams(searchParams.toString())
params.set("step", String(clamped))
router.replace(`/onboarding?${params.toString()}`, { scroll: false })
setStep(clamped)
},
[maxStep, router, searchParams]
)
const onNext = () => {
if (step === 1 && !canNext) {
toast.error(t("validation.needNamePhone"))
return
}
if (step === 2 && isParent && !canNext) {
toast.error(t("validation.needOneChild"))
return
}
goToStep(step + 1)
}
const onBack = () => {
goToStep(step - 1)
}
const canSkipStep2 = isAdmin || isStudent || isTeacher
const onSkip = () => {
if (isParent) return
goToStep(isAdmin ? 2 : 3)
}
const addChildRow = () => {
setChildren((prev) => [...prev, { ...EMPTY_CHILD_ROW }])
}
const removeChildRow = (idx: number) => {
setChildren((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)))
}
const updateChildRow = (idx: number, patch: Partial<ChildRow>) => {
setChildren((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
}
const onFinish = async () => {
if (isParent) {
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
)
if (validChildren.length === 0) {
toast.error(t("validation.needOneChild"))
return
}
}
setIsSubmitting(true)
try {
const formData = new FormData()
formData.set("name", name.trim())
formData.set("phone", phone.trim())
formData.set("address", address.trim())
// v3邀请码统一大写化后提交
formData.set("classCodes", classCodes.trim().toUpperCase())
formData.set("teacherSubjects", JSON.stringify(teacherSubjects))
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
)
formData.set("children", JSON.stringify(validChildren))
const result = await completeOnboardingAction(null, formData)
if (!result.success) {
toast.error(result.message ?? t("toast.submitFailed"))
return
}
if (result.message && result.message.includes("绑定失败")) {
toast.warning(result.message)
} else {
toast.success(t("toast.completeSuccess"))
}
await update?.()
const target = result.data?.defaultPath ?? "/dashboard"
router.push(target)
router.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
toast.error(msg)
} finally {
setIsSubmitting(false)
}
}
const title = t(`steps.${stepKeys[step]}`)
// audit-P1-13标题/描述/内容均按 currentStepId 渲染,不再依赖数字下标。
const title = t(`steps.${currentStepId}`)
const description =
step === 0
currentStepId === "roleConfirm"
? t("role.adminAssigned")
: step === 1
: currentStepId === "basicInfo"
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
: step === 2
: currentStepId === "roleInfo"
? isParent
? t("parent.bindHint")
: t("steps.roleInfo")
@@ -216,8 +100,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
))}
</div>
{/* Step 0: 角色确认(只读) */}
{step === 0 ? (
{/* Step: 角色确认(只读) */}
{currentStepId === "roleConfirm" ? (
<div className="grid gap-2">
<Label>{t("role.yourRole")}</Label>
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm">
@@ -232,8 +116,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
</div>
) : null}
{/* Step 1: 基础信息 */}
{step === 1 ? (
{/* Step: 基础信息 */}
{currentStepId === "basicInfo" ? (
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="onb_name">{t("form.name")} *</Label>
@@ -268,8 +152,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
</div>
) : null}
{/* Step 2: 角色信息 */}
{step === 2 ? (
{/* Step: 角色信息 */}
{currentStepId === "roleInfo" ? (
<div className="grid gap-4">
{isTeacher ? (
<>
@@ -315,91 +199,19 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
) : null}
{isParent ? (
<div className="grid gap-4">
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
{t("parent.bindHint")}
</div>
{children.map((row, idx) => (
<div key={idx} className="grid gap-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("parent.childN", { index: idx + 1 })}</span>
{children.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeChildRow(idx)}
disabled={isSubmitting}
>
{tCommon("remove")}
</Button>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_email_${idx}`}>{t("parent.childEmail")} *</Label>
<Input
id={`onb_child_email_${idx}`}
type="email"
value={row.childEmail}
onChange={(e) => updateChildRow(idx, { childEmail: e.target.value })}
placeholder="student@example.com"
<ParentChildrenForm
childRows={children}
isSubmitting={isSubmitting}
onUpdate={updateChildRow}
onAdd={addChildRow}
onRemove={removeChildRow}
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`onb_child_birth_${idx}`}>{t("parent.childBirthDate")} *</Label>
<Input
id={`onb_child_birth_${idx}`}
type="date"
value={row.childBirthDate}
onChange={(e) => updateChildRow(idx, { childBirthDate: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_phone_${idx}`}>{t("parent.childPhoneSuffix")} *</Label>
<Input
id={`onb_child_phone_${idx}`}
value={row.childPhoneSuffix}
onChange={(e) =>
updateChildRow(idx, {
childPhoneSuffix: e.target.value.replace(/\D/g, "").slice(0, 4),
})
}
placeholder="4 位数字"
inputMode="numeric"
maxLength={4}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_relation_${idx}`}>{t("parent.childRelation")}</Label>
<Input
id={`onb_child_relation_${idx}`}
value={row.childRelation}
onChange={(e) => updateChildRow(idx, { childRelation: e.target.value })}
placeholder={t("parent.childRelationPlaceholder")}
maxLength={50}
/>
</div>
</div>
))}
{children.length < 10 ? (
<Button
type="button"
variant="outline"
onClick={addChildRow}
disabled={isSubmitting}
>
{t("parent.addChild")}
</Button>
) : null}
</div>
) : null}
</div>
) : null}
{/* Step 3: 完成 */}
{step === (isAdmin ? 2 : 3) ? (
{/* Step: 完成 */}
{currentStepId === "complete" ? (
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
<div className="font-medium">{t("complete.ready")}</div>
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
@@ -417,7 +229,7 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
>
{tCommon("previous")}
</Button>
{step === 2 && canSkipStep2 ? (
{canSkip ? (
<Button
type="button"
variant="secondary"
@@ -444,8 +256,3 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
</div>
)
}
function clampStep(value: number): number {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(3, Math.floor(value)))
}

View File

@@ -0,0 +1,122 @@
"use client"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input"
import { Label } from "@/shared/components/ui/label"
import type { ChildRow } from "@/modules/onboarding/types"
interface ParentChildrenFormProps {
/** 当前已填写的子女行(受控)。注意:避免使用 `children` 作为 prop 名,与 React 保留字冲突 */
childRows: ChildRow[]
/** 是否正在提交(用于禁用所有交互按钮) */
isSubmitting: boolean
/** 更新第 idx 行的字段 */
onUpdate: (idx: number, patch: Partial<ChildRow>) => void
/** 新增一行空子女记录 */
onAdd: () => void
/** 删除第 idx 行(保留至少 1 行) */
onRemove: (idx: number) => void
}
/**
* 家长 Onboarding 第 2 步:子女绑定子表单。
*
* 设计目标P1-2 拆分):
* - 将原 onboarding-stepper.tsx 中最大的子表单(~80 行)抽取为独立组件
* - 纯受控组件,状态由父级管理,便于复用与测试
* - 不直接调用任何 Server Action / Toast所有副作用通过 props 回调上抛
*/
export function ParentChildrenForm({
childRows,
isSubmitting,
onUpdate,
onAdd,
onRemove,
}: ParentChildrenFormProps) {
const t = useTranslations("onboarding")
const tCommon = useTranslations("common.actions")
return (
<div className="grid gap-4">
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
{t("parent.bindHint")}
</div>
{childRows.map((row, idx) => (
<div key={idx} className="grid gap-3 rounded-md border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{t("parent.childN", { index: idx + 1 })}</span>
{childRows.length > 1 ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => onRemove(idx)}
disabled={isSubmitting}
>
{tCommon("remove")}
</Button>
) : null}
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_email_${idx}`}>{t("parent.childEmail")} *</Label>
<Input
id={`onb_child_email_${idx}`}
type="email"
value={row.childEmail}
onChange={(e) => onUpdate(idx, { childEmail: e.target.value })}
placeholder="student@example.com"
/>
</div>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor={`onb_child_birth_${idx}`}>{t("parent.childBirthDate")} *</Label>
<Input
id={`onb_child_birth_${idx}`}
type="date"
value={row.childBirthDate}
onChange={(e) => onUpdate(idx, { childBirthDate: e.target.value })}
/>
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_phone_${idx}`}>{t("parent.childPhoneSuffix")} *</Label>
<Input
id={`onb_child_phone_${idx}`}
value={row.childPhoneSuffix}
onChange={(e) =>
onUpdate(idx, {
childPhoneSuffix: e.target.value.replace(/\D/g, "").slice(0, 4),
})
}
placeholder="4 位数字"
inputMode="numeric"
maxLength={4}
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor={`onb_child_relation_${idx}`}>{t("parent.childRelation")}</Label>
<Input
id={`onb_child_relation_${idx}`}
value={row.childRelation}
onChange={(e) => onUpdate(idx, { childRelation: e.target.value })}
placeholder={t("parent.childRelationPlaceholder")}
maxLength={50}
/>
</div>
</div>
))}
{childRows.length < 10 ? (
<Button type="button" variant="outline" onClick={onAdd} disabled={isSubmitting}>
{t("parent.addChild")}
</Button>
) : null}
</div>
)
}

View File

@@ -8,7 +8,7 @@ import {
parentStudentRelations,
} from "@/shared/db/schema"
import type { Role } from "@/shared/types/permissions"
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } from "./types"
/**
@@ -125,15 +125,3 @@ export async function bindParentToChild(
return { studentId: child.id }
}
/**
* 按角色解析默认跳转路径(与 proxy.ts 的 resolveDefaultPath 保持一致)。
*/
export function resolveDefaultPathByRoles(roleNames: string[]): string {
const normalized = roleNames.map((r) => normalizeRole(r))
if (normalized.includes("admin")) return "/admin/dashboard"
if (normalized.includes("teacher")) return "/teacher/dashboard"
if (normalized.includes("student")) return "/student/dashboard"
if (normalized.includes("parent")) return "/parent/dashboard"
return "/dashboard"
}

View File

@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { type ClassSubject } from "@/modules/classes/types"
import { completeOnboardingAction } from "@/modules/onboarding/actions"
import {
EMPTY_CHILD_ROW,
type ChildRow,
type OnboardingStatus,
type OnboardingStepConfig,
getOnboardingStepsForRole,
} from "@/modules/onboarding/types"
function clampStep(value: number, max: number): number {
if (!Number.isFinite(value)) return 0
return Math.max(0, Math.min(max, Math.floor(value)))
}
/**
* Onboarding 表单状态与处理逻辑 HookP1-2 拆分时从 onboarding-stepper.tsx 提取)。
*
* 设计目标:
* - 将所有 React state / 导航 / 提交逻辑集中在此处
* - onboarding-stepper.tsx 仅负责 UI 渲染,状态由本 Hook 提供
* - URL query 参数 `?step=` 持久化当前步骤(支持刷新保持)
*/
export function useOnboardingForm(initialStatus: OnboardingStatus) {
const router = useRouter()
const searchParams = useSearchParams()
const { update } = useSession()
const t = useTranslations("onboarding")
const [isSubmitting, setIsSubmitting] = React.useState(false)
const [name, setName] = React.useState(initialStatus.name ?? "")
const [phone, setPhone] = React.useState("")
const [address, setAddress] = React.useState("")
const [classCodes, setClassCodes] = React.useState("")
const [teacherSubjects, setTeacherSubjects] = React.useState<ClassSubject[]>([])
const [children, setChildren] = React.useState<ChildRow[]>([{ ...EMPTY_CHILD_ROW }])
const primaryRole = initialStatus.roles.primary
const isAdmin = primaryRole === "admin"
const isStudent = primaryRole === "student"
const isTeacher = primaryRole === "teacher"
const isParent = primaryRole === "parent"
// audit-P1-13步骤列表由配置驱动根据角色过滤。
// admin 与 grade_head/teaching_head 跳过 roleInfo 步骤(无需填写内容)。
const stepConfigs = getOnboardingStepsForRole(primaryRole)
const stepKeys = stepConfigs.map((c) => c.id)
const maxStep = stepKeys.length - 1
// P1-1URL query 参数持久化当前步骤clamp 范围与角色适用的步骤数一致)
const initialStep = clampStep(Number(searchParams.get("step") ?? "0"), maxStep)
const [step, setStep] = React.useState(initialStep)
// audit-P1-13以步骤 id而非数字下标判断当前步骤避免角色过滤导致下标偏移。
const currentStepId = stepConfigs[step]?.id
const currentStepConfig: OnboardingStepConfig | undefined = stepConfigs[step]
const canNext = React.useMemo(() => {
if (currentStepId === "basicInfo") {
return name.trim().length > 0 && phone.trim().length > 0
}
if (currentStepId === "roleInfo" && isParent) {
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(),
)
return validChildren.length > 0
}
return true
}, [currentStepId, name, phone, isParent, children])
const toggleSubject = (subject: ClassSubject) => {
setTeacherSubjects((prev) =>
prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject],
)
}
const goToStep = React.useCallback(
(next: number) => {
const clamped = Math.max(0, Math.min(maxStep, next))
const params = new URLSearchParams(searchParams.toString())
params.set("step", String(clamped))
router.replace(`/onboarding?${params.toString()}`, { scroll: false })
setStep(clamped)
},
[maxStep, router, searchParams],
)
const onNext = () => {
if (currentStepId === "basicInfo" && !canNext) {
toast.error(t("validation.needNamePhone"))
return
}
if (currentStepId === "roleInfo" && isParent && !canNext) {
toast.error(t("validation.needOneChild"))
return
}
goToStep(step + 1)
}
const onBack = () => {
goToStep(step - 1)
}
// audit-P1-13跳过按钮可见性由配置驱动OnboardingStepConfig.canSkip
// parent 的 roleInfo 步骤 canSkip 返回 false故不显示跳过按钮。
const canSkip = currentStepConfig?.canSkip?.(primaryRole) ?? false
const onSkip = () => {
goToStep(maxStep)
}
const addChildRow = () => {
setChildren((prev) => [...prev, { ...EMPTY_CHILD_ROW }])
}
const removeChildRow = (idx: number) => {
setChildren((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)))
}
const updateChildRow = (idx: number, patch: Partial<ChildRow>) => {
setChildren((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
}
const onFinish = async () => {
if (isParent) {
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(),
)
if (validChildren.length === 0) {
toast.error(t("validation.needOneChild"))
return
}
}
setIsSubmitting(true)
try {
const formData = new FormData()
formData.set("name", name.trim())
formData.set("phone", phone.trim())
formData.set("address", address.trim())
// v3邀请码统一大写化后提交
formData.set("classCodes", classCodes.trim().toUpperCase())
formData.set("teacherSubjects", JSON.stringify(teacherSubjects))
const validChildren = children.filter(
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(),
)
formData.set("children", JSON.stringify(validChildren))
const result = await completeOnboardingAction(null, formData)
if (!result.success) {
toast.error(result.message ?? t("toast.submitFailed"))
return
}
if (result.message && result.message.includes("绑定失败")) {
toast.warning(result.message)
} else {
toast.success(t("toast.completeSuccess"))
}
await update?.()
const target = result.data?.defaultPath ?? "/dashboard"
router.push(target)
router.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
toast.error(msg)
} finally {
setIsSubmitting(false)
}
}
return {
// state
step,
isSubmitting,
name,
phone,
address,
classCodes,
teacherSubjects,
children,
// derived
primaryRole,
isAdmin,
isStudent,
isTeacher,
isParent,
// audit-P1-13暴露 stepConfigs + currentStepId组件按配置渲染而非数字下标
stepConfigs,
stepKeys,
currentStepId,
maxStep,
canNext,
canSkip,
// setters
setName,
setPhone,
setAddress,
setClassCodes,
toggleSubject,
// children handlers
addChildRow,
removeChildRow,
updateChildRow,
// navigation
onNext,
onBack,
onSkip,
onFinish,
}
}
export type UseOnboardingFormReturn = ReturnType<typeof useOnboardingForm>

View File

@@ -1,12 +1,94 @@
import type { NormalizedRole } from "@/shared/lib/role-utils"
import type { Role } from "@/shared/types/permissions"
// ---------------------------------------------------------------------------
// audit-P1-13Onboarding 步骤配置驱动设计
//
// 原先步骤列表与角色过滤逻辑硬编码在 use-onboarding-form.ts 中
// `STEPS_KEYS` 常量 + `isAdmin ? filter : identity` 三元表达式),
// 新增/调整步骤时必须修改 Hook 逻辑代码。
//
// 现抽取为配置常量 ONBOARDING_STEPS + 纯函数 getOnboardingStepsForRole
// onboarding-stepper 与 use-onboarding-form 仅消费配置,不再内联步骤定义。
// ---------------------------------------------------------------------------
/** Onboarding 流程的 4 个步骤标识,同时作为 i18n 键后缀(`onboarding.steps.${id}`)。 */
export type OnboardingStepId = "roleConfirm" | "basicInfo" | "roleInfo" | "complete"
/**
* 单个步骤的配置。
*
* - `applicableRoles`:该步骤对哪些角色显示。`undefined` 表示对所有角色显示。
* 角色不在列表中时,该步骤被完全跳过(例如 admin 与 grade_head/teaching_head
* 跳过 roleInfo 步骤,因为他们在该步骤无需填写任何内容)。
* - `canSkip`:返回 true 时显示"跳过"按钮。仅在该步骤对当前角色显示时才有意义。
* parent 必须绑定至少一个子女,故 roleInfo 步骤对 parent 不可跳过。
*/
export type OnboardingStepConfig = {
id: OnboardingStepId
applicableRoles?: readonly NormalizedRole[]
canSkip?: (role: NormalizedRole) => boolean
}
/**
* Onboarding 步骤配置数组(顺序即展示顺序,不可随意调整)。
*
* - roleConfirm角色确认只读所有角色
* - basicInfo基础信息姓名/手机/地址,所有角色)
* - roleInfo角色信息教师填班级码+科目、学生填班级码、家长绑定子女;
* 仅 teacher/student/parent 显示admin 与 grade_head/teaching_head 无需填写故跳过)
* - complete完成所有角色
*/
export const ONBOARDING_STEPS: readonly OnboardingStepConfig[] = [
{ id: "roleConfirm" },
{ id: "basicInfo" },
{
id: "roleInfo",
applicableRoles: ["teacher", "student", "parent"],
canSkip: (role) => role !== "parent",
},
{ id: "complete" },
]
/**
* 返回给定角色适用的步骤列表(保持 ONBOARDING_STEPS 中的顺序)。
*
* 用法:`const steps = getOnboardingStepsForRole(primaryRole)`
* `steps[stepIndex].id` 即当前步骤标识。
*/
export function getOnboardingStepsForRole(
role: NormalizedRole,
): readonly OnboardingStepConfig[] {
return ONBOARDING_STEPS.filter(
(step) => !step.applicableRoles || step.applicableRoles.includes(role),
)
}
/**
* 家长在 Onboarding 中填写的子女信息行P1-2 拆分时从 onboarding-stepper.tsx 提取)。
* 三因子验证:邮箱 + 生日 + 手机号后 4 位。
*/
export type ChildRow = {
childEmail: string
childBirthDate: string
childPhoneSuffix: string
childRelation: string
}
export const EMPTY_CHILD_ROW: ChildRow = {
childEmail: "",
childBirthDate: "",
childPhoneSuffix: "",
childRelation: "",
}
/**
* Onboarding 步骤中展示给用户的角色信息。
* 角色来源于 usersToRoles管理员预分配用户不可修改。
*/
export type OnboardingRoleInfo = {
/** 规范化后的主角色admin/teacher/student/parent */
primary: "admin" | "teacher" | "student" | "parent"
/** 规范化后的主角色admin/grade_head/teaching_head/teacher/student/parent */
primary: NormalizedRole
/** 用户拥有的全部角色名(含 grade_head/teaching_head 等) */
all: Role[]
}

View File

@@ -9,6 +9,7 @@ import {
Mail,
Stethoscope,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
@@ -26,7 +27,7 @@ export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "sch
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
typeof v === "string" && (VALID_TABS as string[]).includes(v)
typeof v === "string" && VALID_TABS.some((tab) => tab === v)
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
isTab(v) ? v : "overview"
@@ -40,40 +41,47 @@ export function ChildDetailPanel({
initialTab?: string
siblingSwitcher?: React.ReactNode
}) {
const t = useTranslations("parent")
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
const childName = basicInfo.name ?? "Child"
const childName = basicInfo.name ?? t("childDetail.defaultName")
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
const tabs = useMemo(
() => [
{ id: "overview" as const, label: "Overview", icon: ClipboardList },
{ id: "homework" as const, label: "Homework", icon: ClipboardList },
{ id: "grades" as const, label: "Grades", icon: BarChart3 },
{ id: "exams" as const, label: "Exams", icon: GraduationCap },
{ id: "schedule" as const, label: "Schedule", icon: CalendarDays },
{ id: "attendance" as const, label: "Attendance", icon: CalendarDays },
{ id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope },
{ id: "overview" as const, label: t("childDetail.tabs.overview"), icon: ClipboardList },
{ id: "homework" as const, label: t("childDetail.tabs.homework"), icon: ClipboardList },
{ id: "grades" as const, label: t("childDetail.tabs.grades"), icon: BarChart3 },
{ id: "exams" as const, label: t("childDetail.tabs.exams"), icon: GraduationCap },
{ id: "schedule" as const, label: t("childDetail.tabs.schedule"), icon: CalendarDays },
{ id: "attendance" as const, label: t("childDetail.tabs.attendance"), icon: CalendarDays },
{ id: "diagnostic" as const, label: t("childDetail.tabs.diagnostic"), icon: Stethoscope },
],
[],
[t],
)
return (
<div className="space-y-6">
{siblingSwitcher}
<Tabs value={tab} onValueChange={(v) => setTab(v as ChildDetailTab)} className="w-full">
<Tabs
value={tab}
onValueChange={(v) => {
if (isTab(v)) setTab(v)
}}
className="w-full"
>
<div className="overflow-x-auto">
<TabsList className="w-full justify-start">
{tabs.map((t) => (
{tabs.map((tab) => (
<TabsTrigger
key={t.id}
value={t.id}
key={tab.id}
value={tab.id}
className="gap-1.5"
aria-label={`${t.label} tab`}
aria-label={t("childDetail.tabAriaLabel", { label: tab.label })}
>
<t.icon className="h-3.5 w-3.5" />
{t.label}
<tab.icon className="h-3.5 w-3.5" />
{tab.label}
</TabsTrigger>
))}
</TabsList>
@@ -108,7 +116,7 @@ export function ChildDetailPanel({
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
<div>
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
Subject Analysis
{t("childDetail.subjectAnalysis")}
</h3>
<ChildGradeDetail grades={gradeTrend} />
</div>
@@ -134,14 +142,16 @@ export function ChildDetailPanel({
<TabsContent value="attendance" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground">
Attendance details are available on the{" "}
{t.rich("childDetail.attendanceHint", {
link: (chunks) => (
<a
href="/parent/attendance"
className="font-medium text-foreground underline-offset-4 hover:underline"
>
Attendance page
{chunks}
</a>
.
),
})}
</p>
</div>
</TabsContent>
@@ -149,7 +159,7 @@ export function ChildDetailPanel({
<TabsContent value="diagnostic" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground">
Diagnostic reports will be available here once published by the school.
{t("childDetail.diagnosticHint")}
</p>
</div>
</TabsContent>
@@ -157,9 +167,12 @@ export function ChildDetailPanel({
<div className="flex justify-end">
<Button asChild variant="ghost" size="sm" className="gap-2">
<a href={`/messages?studentId=${basicInfo.id}`} aria-label={`Contact teacher about ${childName}`}>
<a
href={`/messages?studentId=${basicInfo.id}`}
aria-label={t("childDetail.contactTeacherAria", { name: childName })}
>
<Mail className="h-4 w-4" />
Contact Teacher
{t("childDetail.contactTeacher")}
</a>
</Button>
</div>
@@ -175,21 +188,24 @@ export function SiblingSwitcher({
current: { id: string; name: string | null }
siblings: Array<{ id: string; name: string | null }>
}) {
const t = useTranslations("parent")
if (siblings.length <= 1) return null
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">Switch child</span>
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">
{t("childDetail.switchChild")}
</span>
<div className="flex flex-wrap gap-1">
{siblings.map((s) => {
const isActive = s.id === current.id
const label = s.name ?? "Child"
const label = s.name ?? t("childDetail.defaultName")
return (
<a
key={s.id}
href={`/parent/children/${s.id}`}
aria-current={isActive ? "page" : undefined}
aria-label={`View ${label}'s details`}
aria-label={t("childDetail.viewDetailsAria", { name: label })}
className={cn(
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",

View File

@@ -1,6 +1,7 @@
import type { JSX } from "react"
import Link from "next/link"
import { GraduationCap, TrendingUp, Award, BookOpen } from "lucide-react"
import { useTranslations } from "next-intl"
import { GraduationCap, TrendingUp, Award, BookOpen, ChevronRight } from "lucide-react"
import {
Card,
CardContent,
@@ -32,14 +33,19 @@ interface ChildExamDetailProps {
}
/**
* V3-11: 家长端子女考试详情视图
* V3-11 / P3-4: 家长端子女考试详情视图
*
* 对标智学网家长端,展示:
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
* - 成绩趋势可视化
*
* P3-4 改进:
* - 所有用户可见文本使用 i18n 翻译键next-intl
* - 触控目标 ≥ 44pxmin-h-[44px]
* - 语义化标签 + ARIA 属性
*/
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
const t = useTranslations("examHomework")
const hasResults = examResults.length > 0
const examCount = examResults.length
@@ -60,33 +66,39 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<GraduationCap className="h-5 w-5 text-primary" />
<GraduationCap className="h-5 w-5 text-primary" aria-hidden />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
<p className="text-xs text-muted-foreground">Exams Taken</p>
<p className="text-xs text-muted-foreground">
{t("homework.parentExam.examsTaken")}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
<TrendingUp className="h-5 w-5 text-blue-500" />
<TrendingUp className="h-5 w-5 text-blue-500" aria-hidden />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Average Score</p>
<p className="text-xs text-muted-foreground">
{t("homework.parentExam.averageScore")}
</p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 pt-6">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
<Award className="h-5 w-5 text-green-500" />
<Award className="h-5 w-5 text-green-500" aria-hidden />
</div>
<div>
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
<p className="text-xs text-muted-foreground">Best Score</p>
<p className="text-xs text-muted-foreground">
{t("homework.parentExam.bestScore")}
</p>
</div>
</CardContent>
</Card>
@@ -97,26 +109,28 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Exam Results
{t("homework.parentExam.examResults", { name: childName })}
</CardTitle>
<CardDescription>Recent exam scores and performance trends</CardDescription>
<CardDescription>
{t("homework.parentExam.examResultsDescription")}
</CardDescription>
</CardHeader>
<CardContent>
{!hasResults ? (
<EmptyState
icon={GraduationCap}
title="No exam results"
description="Exam results will appear here once available."
title={t("homework.parentExam.noResults")}
description={t("homework.parentExam.noResultsHint")}
className="border-none h-48"
/>
) : (
<div className="space-y-3">
<ul className="space-y-3" aria-label={t("homework.parentExam.examResults", { name: childName })}>
{examResults.map((r) => {
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
const isPass = scoreRate >= 60
return (
<li key={r.submissionId}>
<Link
key={r.submissionId}
href={`/parent/children/${childId}?tab=grades`}
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
@@ -124,7 +138,7 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
<div className="flex items-center gap-2">
<div className="font-medium text-sm truncate">{r.examTitle}</div>
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
{isPass ? "Pass" : "Below 60%"}
{isPass ? t("homework.parentExam.pass") : t("homework.parentExam.belowPass")}
</Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
@@ -138,13 +152,17 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
</div>
<Progress value={scoreRate} className="h-1.5 mt-1" />
</div>
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
<div className="flex items-center gap-2 shrink-0 ml-2">
<span className="text-sm font-semibold tabular-nums">
{scoreRate.toFixed(0)}%
</span>
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
</div>
</Link>
</li>
)
})}
</div>
</ul>
)}
</CardContent>
</Card>

View File

@@ -2,14 +2,14 @@
import { ChevronLeft, ChevronRight } from "lucide-react"
import { useState, useRef, type KeyboardEvent } from "react"
import { useTranslations } from "next-intl"
import { useTranslations, useLocale } from "next-intl"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
import {
ATTENDANCE_STATUS_DOT_COLORS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/modules/attendance/constants"
} from "@/shared/constants/attendance-status"
import type {
ParentAttendanceListItem,
ParentAttendanceStatus,
@@ -86,6 +86,7 @@ export function ParentAttendanceCalendar({
summary: ParentStudentAttendanceSummary
}) {
const t = useTranslations("attendance")
const locale = useLocale()
const now = new Date()
const [viewYear, setViewYear] = useState(now.getFullYear())
const [viewMonth, setViewMonth] = useState(now.getMonth())
@@ -99,7 +100,7 @@ export function ParentAttendanceCalendar({
}
const days = buildCalendarDays(viewYear, viewMonth)
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString(locale, {
year: "numeric",
month: "long",
})

View File

@@ -1,7 +1,5 @@
"use client"
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
import { useTranslations } from "next-intl"
import { getTranslations } from "next-intl/server"
import { Card } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
@@ -50,15 +48,17 @@ const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
}
/**
* 家长考勤页顶部的出勤率汇总卡片。
* 家长考勤页顶部的出勤率汇总卡片RSC
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
*
* P2-8 修复:从 client component 降级为 RSC使用 `getTranslations`。
*/
export function ParentAttendanceRateCard({
export async function ParentAttendanceRateCard({
summaries,
}: {
summaries: ParentStudentAttendanceSummary[]
}) {
const t = useTranslations("attendance")
const t = await getTranslations("attendance")
const stats = aggregateStats(summaries)
if (stats.totalStudents === 0) return null

View File

@@ -1,7 +1,5 @@
"use client"
import { AlertTriangle, Phone } from "lucide-react"
import { useTranslations } from "next-intl"
import { getTranslations } from "next-intl/server"
import { Card } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils"
@@ -14,8 +12,11 @@ type Warning = {
severity: "high" | "medium"
}
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
type Translator = ReturnType<typeof useTranslations>
/** 翻译函数类型(与 useTranslations / getTranslations 返回值兼容) */
type Translator = (
key: string,
values?: Record<string, string | number | Date>,
) => string
/**
* 构建考勤异常预警列表(纯函数,便于测试)。
@@ -67,15 +68,17 @@ export function buildWarnings(
}
/**
* 家长视角的考勤异常预警横幅。
* 家长视角的考勤异常预警横幅RSC
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
*
* P2-8 修复:从 client component 降级为 RSC使用 `getTranslations`。
*/
export function ParentAttendanceWarning({
export async function ParentAttendanceWarning({
summaries,
}: {
summaries: ParentStudentAttendanceSummary[]
}) {
const t = useTranslations("attendance")
const t = await getTranslations("attendance")
const warnings = buildWarnings(summaries, t)
if (warnings.length === 0) return null

View File

@@ -1,107 +0,0 @@
import Link from "next/link"
import { getTranslations } from "next-intl/server"
import {
CalendarCheck,
CalendarDays,
GraduationCap,
Megaphone,
Users,
} from "lucide-react"
import { Card, CardContent } from "@/shared/components/ui/card"
import { EmptyState } from "@/shared/components/ui/empty-state"
import type { ParentDashboardData } from "@/modules/parent/types"
import { getGreetingKey } from "@/modules/dashboard/lib/dashboard-utils"
import { ChildCard } from "./child-card"
import { ParentAttentionBanner } from "./parent-attention-banner"
export async function ParentDashboard({ data }: { data: ParentDashboardData }) {
const t = await getTranslations("dashboard")
const { parentName, children } = data
const hasChildren = children.length > 0
const greetingKey = getGreetingKey(new Date())
const QUICK_ENTRIES = [
{ href: "/parent/grades", label: t("quickActions.grades"), icon: GraduationCap },
{ href: "/parent/attendance", label: t("quickActions.attendance"), icon: CalendarCheck },
{ href: "/announcements", label: t("quickActions.announcements"), icon: Megaphone },
{ href: "/parent/leave", label: t("quickActions.leaveRequest"), icon: CalendarDays },
] as const
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">{t("title.parent")}</h1>
<div className="text-sm text-muted-foreground">
{t(`greeting.${greetingKey}`)}
{parentName ? `, ${parentName}` : ""}. {t("description.parent")}
</div>
</div>
{hasChildren ? (
<>
<ParentAttentionBanner data={data} />
<nav
aria-label={t("quickActions.announcements")}
className="grid grid-cols-2 gap-3 sm:grid-cols-4"
>
{QUICK_ENTRIES.map((entry) => (
<Link
key={entry.href}
href={entry.href}
className="group"
aria-label={entry.label}
>
<Card className="h-full transition-colors hover:bg-muted/50 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2">
<CardContent className="flex flex-col items-center justify-center gap-2 p-4 text-center">
<entry.icon
className="h-6 w-6 text-muted-foreground group-hover:text-foreground"
aria-hidden
/>
<span className="text-sm font-medium">{entry.label}</span>
</CardContent>
</Card>
</Link>
))}
</nav>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" aria-hidden />
<span>
{t("badge.childrenLinked", { count: children.length })}
</span>
</div>
{/* 移动端水平滑动卡片,桌面端网格布局 */}
<div
className="flex gap-4 overflow-x-auto pb-2 snap-x snap-mandatory sm:hidden"
aria-label={t("title.parent")}
>
{children.map((child) => (
<div key={child.basicInfo.id} className="snap-start shrink-0 w-[85%]">
<ChildCard child={child} />
</div>
))}
</div>
<div className="hidden sm:grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{children.map((child) => (
<ChildCard key={child.basicInfo.id} child={child} />
))}
</div>
</>
) : (
<EmptyState
icon={Users}
title={t("empty.noChildren")}
description={t("empty.noChildrenDesc")}
className="border-none shadow-none"
action={{
label: t("empty.contactSupport"),
href: "/messages",
}}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,163 @@
import { useTranslations } from "next-intl"
import {
CalendarCheck,
CheckCircle2,
Clock,
TrendingUp,
Users,
XCircle,
Inbox,
} from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { StatItem } from "@/shared/components/ui/stat-item"
import {
ATTENDANCE_STATUS_BADGE_VARIANTS,
ATTENDANCE_STATUS_LABEL_KEYS,
} from "@/shared/constants/attendance-status"
import type { ParentStudentAttendanceSummary } from "@/modules/parent/types"
/**
* 家长视角的单个子女考勤详情组件(统计卡片 + 最近记录表)。
*
* 解耦说明P1-2 修复):
* - 此组件替代原先从 `@/modules/attendance/components/student-attendance-view` 直接导入的 `StudentAttendanceView`。
* - parent 模块不再依赖 attendance 模块的 UI 组件,仅依赖自身类型与 shared 常量。
* - 数据通过 props 注入(由 parent page 通过 `AttendanceReadService` 接口获取后传入)。
*/
export function ParentStudentAttendanceDetail({
summary,
}: {
summary: ParentStudentAttendanceSummary
}) {
const t = useTranslations("attendance")
const { stats, recentRecords } = summary
return (
<div className="space-y-6">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("list.columns.student")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{summary.studentName}</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{t("stats.totalRecords")}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stats.total}</p>
</CardContent>
</Card>
</div>
{stats.total === 0 ? (
<EmptyState
title={t("stats.noData")}
description={t("stats.noDataDescription")}
icon={CalendarCheck}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>{t("title.teacherStats")}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<StatItem
label={t("stats.totalRecords")}
value={stats.total}
icon={<Users className="h-4 w-4" />}
/>
<StatItem
label={t("stats.present")}
value={stats.present}
icon={<CheckCircle2 className="h-4 w-4" />}
/>
<StatItem
label={t("stats.absent")}
value={stats.absent}
icon={<XCircle className="h-4 w-4" />}
/>
<StatItem
label={t("stats.late")}
value={stats.late}
icon={<Clock className="h-4 w-4" />}
/>
<StatItem
label={t("stats.attendanceRate")}
value={`${stats.presentRate.toFixed(1)}%`}
icon={<TrendingUp className="h-4 w-4" />}
/>
</div>
</CardContent>
</Card>
)}
{recentRecords.length === 0 ? (
<EmptyState
title={t("list.empty")}
description={t("list.emptyDescription")}
icon={Inbox}
className="border-none shadow-none"
/>
) : (
<Card>
<CardHeader>
<CardTitle>{t("stats.recentRecords")}</CardTitle>
</CardHeader>
<CardContent>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("list.columns.date")}</TableHead>
<TableHead>{t("list.columns.status")}</TableHead>
<TableHead>{t("list.columns.remark")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentRecords.map((r) => (
<TableRow key={r.id}>
<TableCell className="font-medium">{r.date}</TableCell>
<TableCell>
<Badge
variant={ATTENDANCE_STATUS_BADGE_VARIANTS[r.status]}
className="capitalize"
>
{t(ATTENDANCE_STATUS_LABEL_KEYS[r.status])}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{r.remark ?? "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -11,10 +11,10 @@ import {
getStudentSchedule,
} from "@/modules/classes/data-access"
import {
getStudentDashboardGrades,
getStudentHomeworkAssignments,
getStudentExamResults,
} from "@/modules/homework/data-access"
} from "@/modules/homework/data-access-student"
import { getStudentDashboardGrades } from "@/modules/homework/stats-service"
import { getStudentGradeSummary } from "@/modules/grades/data-access"
import { getGradeNameById } from "@/modules/school/data-access"
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/data-access"
@@ -283,3 +283,22 @@ export const getParentIdsByStudentIds = cache(
return Array.from(new Set(rows.map((r) => r.parentId)))
},
)
/**
* L-2: 批量查询学生→家长映射(保留关联关系)。
* 用于考勤通知等需要按家长-学生分组发送的场景,避免 N+1 查询。
* 返回数组形如 [{ parentId, studentId }],同一家长多个学生会出现多条。
*/
export const getParentStudentMapByStudentIds = cache(
async (studentIds: string[]): Promise<Array<{ parentId: string; studentId: string }>> => {
if (studentIds.length === 0) return []
const rows = await db
.select({
parentId: parentStudentRelations.parentId,
studentId: parentStudentRelations.studentId,
})
.from(parentStudentRelations)
.where(inArray(parentStudentRelations.studentId, studentIds))
return rows.map((r) => ({ parentId: r.parentId, studentId: r.studentId }))
},
)

View File

@@ -104,12 +104,14 @@ export type ParentAttendanceStatus =
| "late"
| "early_leave"
| "excused"
| "school_activity"
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
export type ParentAttendanceListItem = {
id: string
date: string
status: ParentAttendanceStatus
reason: string | null
remark: string | null
}