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 { revalidatePath } from "next/cache"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard" 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 type { ActionState } from "@/shared/types/action-state"
import { handleActionError } from "@/shared/lib/action-utils" import { handleActionError } from "@/shared/lib/action-utils"
import {
getStudentActiveClassId,
getStudentActiveGradeId,
verifyTeacherOwnsClass,
} from "@/modules/classes/data-access"
import { import {
CreatePracticeSessionSchema, CreatePracticeSessionSchema,
@@ -22,7 +27,98 @@ import {
getPracticeSessions, getPracticeSessions,
getPracticeStats, getPracticeStats,
} from "./data-access" } 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 // 查询 Actions
@@ -33,25 +129,12 @@ export async function getPracticeSessionsAction(
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>> { ): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>> {
try { try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
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 result = await getPracticeSessions(targetStudentId) const result = await getPracticeSessions(targetStudentId)
return { success: true, data: result } return { success: true, data: result }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) { return handleActionError(e)
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "获取练习列表失败"
return { success: false, message }
} }
} }
@@ -61,28 +144,16 @@ export async function getPracticeSessionDetailAction(
): Promise<ActionState<PracticeSessionDetail>> { ): Promise<ActionState<PracticeSessionDetail>> {
try { try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
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 data = await getPracticeSessionById(sessionId, targetStudentId) const data = await getPracticeSessionById(sessionId, targetStudentId)
if (!data) { if (!data) {
return { success: false, message: "练习会话不存在或无权访问" } // 通过 errorCode 返回,前端 t(`errors.session_not_found`) 查 i18n
throw practiceErrors.sessionNotFound()
} }
return { success: true, data } return { success: true, data }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) { return handleActionError(e)
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "获取练习详情失败"
return { success: false, message }
} }
} }
@@ -91,25 +162,12 @@ export async function getPracticeStatsAction(
): Promise<ActionState<PracticeStats>> { ): Promise<ActionState<PracticeStats>> {
try { try {
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ) const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
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 data = await getPracticeStats(targetStudentId) const data = await getPracticeStats(targetStudentId)
return { success: true, data } return { success: true, data }
} catch (e) { } catch (e) {
if (e instanceof PermissionDeniedError) { return handleActionError(e)
return { success: false, message: e.message }
}
const message = e instanceof Error ? e.message : "获取练习统计失败"
return { success: false, message }
} }
} }
@@ -126,7 +184,8 @@ export async function createPracticeSessionAction(
const jsonString = formData.get("json") const jsonString = formData.get("json")
if (typeof jsonString !== "string") { 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)) const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString))
@@ -134,31 +193,38 @@ export async function createPracticeSessionAction(
return { return {
success: false, success: false,
message: "输入验证失败", message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors, errors: parsed.error.flatten().fieldErrors,
} }
} }
// 从 JSON 解析的 sourceMeta 需要经过 unknown 中间转换 // 严格类型守卫:替代 `as unknown as PracticeSourceMeta` 断言
// 因为 Zod 的 z.record(z.string(), z.unknown()) 返回 Record<string, unknown> const sourceMeta = parseSourceMeta(parsed.data.practiceType, parsed.data.sourceMeta)
// 而实际运行时结构由前端按练习类型构建,此处做类型收窄 if (!sourceMeta) {
const sourceMeta = parsed.data.sourceMeta as unknown as PracticeSourceMeta 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, practiceType: parsed.data.practiceType,
subjectId: parsed.data.subjectId, subjectId: parsed.data.subjectId,
sourceMeta, sourceMeta,
questionCount: parsed.data.questionCount, questionCount: parsed.data.questionCount,
}) })
if (result.selectedCount === 0) {
return { success: false, message: "未找到符合条件的题目,请尝试其他筛选条件" }
}
revalidatePath("/student/practice") revalidatePath("/student/practice")
return { return {
success: true, success: true,
message: `已创建练习会话,共 ${result.selectedCount} 道题目`,
data: result, data: result,
} }
} catch (e) { } catch (e) {
@@ -175,7 +241,7 @@ export async function submitPracticeAnswerAction(
const jsonString = formData.get("json") const jsonString = formData.get("json")
if (typeof jsonString !== "string") { if (typeof jsonString !== "string") {
return { success: false, message: "提交格式错误" } return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
} }
const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString)) const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString))
@@ -183,15 +249,21 @@ export async function submitPracticeAnswerAction(
return { return {
success: false, success: false,
message: "输入验证失败", message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors, errors: parsed.error.flatten().fieldErrors,
} }
} }
const { sessionId, answerId, answer, skip } = parsed.data 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( const result = await submitPracticeAnswer(
sessionId, sessionId,
ctx.userId, targetStudentId,
answerId, answerId,
answer, answer,
skip ?? false, skip ?? false,
@@ -201,7 +273,6 @@ export async function submitPracticeAnswerAction(
return { return {
success: true, success: true,
message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"),
data: result, data: result,
} }
} catch (e) { } catch (e) {
@@ -223,16 +294,21 @@ export async function completePracticeSessionAction(
return { return {
success: false, success: false,
message: "输入验证失败", message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors, 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")
revalidatePath(`/student/practice/${parsed.data.sessionId}`) revalidatePath(`/student/practice/${parsed.data.sessionId}`)
return { success: true, message: "练习已完成" } return { success: true }
} catch (e) { } catch (e) {
return handleActionError(e) return handleActionError(e)
} }
@@ -252,15 +328,20 @@ export async function abandonPracticeSessionAction(
return { return {
success: false, success: false,
message: "输入验证失败", message: "输入验证失败",
errorCode: "validation_error",
errors: parsed.error.flatten().fieldErrors, 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") revalidatePath("/student/practice")
return { success: true, message: "练习已放弃" } return { success: true }
} catch (e) { } catch (e) {
return handleActionError(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 { interface PracticeHistoryProps {
sessions: PracticeSessionSummary[] sessions: PracticeSessionSummary[]
/**
* 点击某条练习记录跳转的路由前缀。
* - 学生端传 `/student/practice`,将跳转到 `/student/practice/{sessionId}`
* - 家长端不传(无会话详情页),仅渲染为只读卡片
*/
routePrefix?: string
} }
const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline"> = { 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") const t = useTranslations("practice")
if (sessions.length === 0) { if (sessions.length === 0) {
@@ -48,47 +57,55 @@ export function PracticeHistory({ sessions }: PracticeHistoryProps): React.React
? (session.answeredQuestions / session.totalQuestions) * 100 ? (session.answeredQuestions / session.totalQuestions) * 100
: 0 : 0
return ( const content = (
<Link key={session.id} href={`/student/practice/${session.id}`}> <Card className="transition-colors hover:bg-muted/30">
<Card className="transition-colors hover:bg-muted/30"> <CardContent className="p-4">
<CardContent className="p-4"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center justify-between gap-3"> <div className="flex-1 space-y-1">
<div className="flex-1 space-y-1"> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <Badge variant={STATUS_VARIANTS[session.status]}>
<Badge variant={STATUS_VARIANTS[session.status]}> {t(`status.${session.status}`)}
{t(`status.${session.status}`)} </Badge>
</Badge> <Badge variant="outline">
<Badge variant="outline"> {t(`types.${session.practiceType}`)}
{t(`types.${session.practiceType}`)} </Badge>
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(session.startedAt)}
</span>
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
{session.correctCount}
</span>
<span className="flex items-center gap-1">
<XCircle className="h-3 w-3 text-rose-500" />
{session.answeredQuestions - session.correctCount}
</span>
</div>
</div> </div>
<div className="text-right"> <div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="text-lg font-bold">{accuracy}%</div> <span className="flex items-center gap-1">
<div className="text-xs text-muted-foreground"> <Clock className="h-3 w-3" />
{session.answeredQuestions}/{session.totalQuestions} {formatDate(session.startedAt)}
</div> </span>
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
{session.correctCount}
</span>
<span className="flex items-center gap-1">
<XCircle className="h-3 w-3 text-rose-500" />
{session.answeredQuestions - session.correctCount}
</span>
</div> </div>
</div> </div>
<Progress value={progress} className="mt-2 h-1" /> <div className="text-right">
</CardContent> <div className="text-lg font-bold">{accuracy}%</div>
</Card> <div className="text-xs text-muted-foreground">
</Link> {session.answeredQuestions}/{session.totalQuestions}
</div>
</div>
</div>
<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> </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 { useState, useTransition, useMemo } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" 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 { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
import { Progress } from "@/shared/components/ui/progress" 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@@ -25,36 +21,55 @@ import {
AlertDialogTrigger, AlertDialogTrigger,
} from "@/shared/components/ui/alert-dialog" } from "@/shared/components/ui/alert-dialog"
import { submitPracticeAnswerAction, completePracticeSessionAction, abandonPracticeSessionAction } from "../actions" import { QuestionCard } from "./question-card"
import type { PracticeSessionDetail, PracticeAnswerRecord } from "../types" import { PracticeResultView } from "./practice-result-view"
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
import type { PracticeSessionDetail } from "../types"
interface PracticeSessionViewProps { interface PracticeSessionViewProps {
session: PracticeSessionDetail session: PracticeSessionDetail
/**
* 放弃练习后的跳转回调(由页面层注入路由跳转逻辑)。
*
* 解耦设计:组件本身不硬编码路由,由调用方决定跳转目标。
* 不传则使用默认行为 `router.push("/student/practice")`(向后兼容)。
*/
onAbandoned?: () => void
} }
/** /**
* 专项练习答题界面 * 专项练习答题界面(编排层)。
* *
* 功能 * 职责
* 1. 逐题作答,支持上一题/下一题导航 * 1. 管理 UI 状态(当前题目索引、答案暂存、判分结果缓存)
* 2. 自动判分(选择题/判断题 * 2. 通过 `usePracticeService()` 调用数据服务(不直接 import actions
* 3. 跳过题目 * 3. 组合 QuestionCard / PracticeResultView 等子组件
* 4. 完成练习后展示结果摘要 *
* 子组件:
* - 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 t = useTranslations("practice")
const router = useRouter() const router = useRouter()
const service = usePracticeService()
const analytics = usePracticeAnalytics()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [answers, setAnswers] = useState<Record<string, unknown>>({}) const [answers, setAnswers] = useState<Record<string, unknown>>({})
const [results, setResults] = useState<Record<string, { isCorrect: boolean | null; score: number | null }>>({}) 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 answersList = session.answers
const total = answersList.length const total = answersList.length
const current = answersList[currentIndex] const current = answersList[currentIndex]
const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0 const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0
// 已答题数和正确数
const answeredCount = useMemo( const answeredCount = useMemo(
() => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length, () => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length,
[answersList], [answersList],
@@ -64,6 +79,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
[answersList], [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") { if (session.status !== "in_progress") {
return <PracticeResultView session={session} /> return <PracticeResultView session={session} />
} }
@@ -92,16 +118,25 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
skip, skip,
}), }),
) )
const res = await submitPracticeAnswerAction(undefined, formData) const res = await service.submitAnswer(undefined, formData)
if (res.success && res.data) { 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")) toast.success(res.message ?? t("toasts.submitted"))
// 自动跳到下一题 // 自动跳到下一题
if (currentIndex < total - 1) { if (currentIndex < total - 1) {
setCurrentIndex(currentIndex + 1) setCurrentIndex(currentIndex + 1)
} }
} else { } 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 () => { startTransition(async () => {
const formData = new FormData() const formData = new FormData()
formData.append("sessionId", session.id) formData.append("sessionId", session.id)
const res = await completePracticeSessionAction(undefined, formData) const res = await service.completeSession(undefined, formData)
if (res.success) { if (res.success) {
analytics.trackSessionComplete(session.id, session.accuracy)
toast.success(res.message ?? t("toasts.completed")) toast.success(res.message ?? t("toasts.completed"))
router.refresh() router.refresh()
} else { } 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 () => { startTransition(async () => {
const formData = new FormData() const formData = new FormData()
formData.append("sessionId", session.id) formData.append("sessionId", session.id)
const res = await abandonPracticeSessionAction(undefined, formData) const res = await service.abandonSession(undefined, formData)
if (res.success) { if (res.success) {
analytics.trackSessionAbandon(session.id)
toast.success(res.message ?? t("toasts.abandoned")) toast.success(res.message ?? t("toasts.abandoned"))
router.push("/student/practice") // 优先使用注入的回调,否则回退到默认路由
if (onAbandoned) {
onAbandoned()
} else {
router.push("/student/practice")
}
} else { } else {
toast.error(res.message ?? t("toasts.abandonFailed")) 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 currentResult = current ? results[current.id] : undefined
const isAnswered = current?.status === "answered" || current?.status === "skipped" const isAnswered = current?.status === "answered" || current?.status === "skipped"
const submitFailed = current ? failedAnswerIds.has(current.id) : false
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -143,18 +193,27 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <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} {t("session.progress")}: {currentIndex + 1} / {total}
</CardTitle> </CardTitle>
<div className="flex items-center gap-3 text-sm text-muted-foreground"> <div className="flex items-center gap-3 text-sm text-muted-foreground">
<span className="flex items-center gap-1"> <span className="flex items-center gap-1" aria-label={`${t("result.correct")}: ${correctCount}`}>
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> <CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
{correctCount} {correctCount}
</span> </span>
<span>{answeredCount}/{total}</span> <span aria-label={`${t("result.answered")}: ${answeredCount}/${total}`}>
{answeredCount}/{total}
</span>
</div> </div>
</div> </div>
<Progress value={progress} className="mt-2" /> <Progress
value={progress}
className="mt-2"
aria-label={`${t("session.progress")}: ${Math.round(progress)}%`}
/>
</CardHeader> </CardHeader>
</Card> </Card>
@@ -168,9 +227,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))} onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))}
onSubmit={(ans) => handleSubmit(ans)} onSubmit={(ans) => handleSubmit(ans)}
onSkip={() => handleSubmit(null, true)} onSkip={() => handleSubmit(null, true)}
onRetry={handleRetry}
isAnswered={isAnswered} isAnswered={isAnswered}
result={currentResult} result={currentResult}
isPending={isPending} isPending={isPending}
submitFailed={submitFailed}
/> />
) : null} ) : null}
@@ -180,16 +241,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
variant="outline" variant="outline"
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))} onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
disabled={currentIndex === 0} 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")} {t("session.previous")}
</Button> </Button>
<div className="flex gap-2"> <div className="flex gap-2">
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm" aria-label={t("session.abandon")}>
<Flag className="h-4 w-4" /> <Flag className="h-4 w-4" aria-hidden="true" />
{t("session.abandon")} {t("session.abandon")}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -200,7 +262,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel> <AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel>
<AlertDialogAction onClick={handleAbandon} disabled={isPending}> <AlertDialogAction
onClick={handleAbandon}
disabled={isPending}
aria-label={t("session.confirmAbandon")}
>
{t("session.confirmAbandon")} {t("session.confirmAbandon")}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
@@ -211,13 +277,18 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
<Button <Button
variant="outline" variant="outline"
onClick={() => setCurrentIndex(currentIndex + 1)} onClick={() => setCurrentIndex(currentIndex + 1)}
aria-label={t("session.next")}
> >
{t("session.next")} {t("session.next")}
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" aria-hidden="true" />
</Button> </Button>
) : ( ) : (
<Button onClick={handleComplete} disabled={isPending || answeredCount < total}> <Button
<Trophy className="h-4 w-4" /> onClick={handleComplete}
disabled={isPending || answeredCount < total}
aria-label={t("session.complete")}
>
<Trophy className="h-4 w-4" aria-hidden="true" />
{t("session.complete")} {t("session.complete")}
</Button> </Button>
)} )}
@@ -226,325 +297,3 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
</div> </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" "use client"
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react" import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card" 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 { Label } from "@/shared/components/ui/label"
import { import {
Select, Select,
@@ -17,10 +17,11 @@ import {
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { createPracticeSessionAction } from "../actions" import { isPracticeType } from "../lib/type-guards"
import type { PracticeType } from "../types" import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
import type { AiRecommendedReason, PracticeType } from "../types"
interface PracticeStarterProps { export interface PracticeStarterProps {
/** 知识点选项列表 */ /** 知识点选项列表 */
knowledgePoints: Array<{ id: string; name: string }> knowledgePoints: Array<{ id: string; name: string }>
/** 预设模式(从错题本发起时传入) */ /** 预设模式(从错题本发起时传入) */
@@ -31,22 +32,50 @@ interface PracticeStarterProps {
} }
/** 是否禁用类型选择(预设模式时) */ /** 是否禁用类型选择(预设模式时) */
lockType?: boolean 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 const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const
/** /**
* 专项练习发起器 * 专项练习发起器
*
* 通过 `usePracticeService()` 获取数据服务,不直接 import actions
* 实现完全解耦:测试时可注入 mock 服务,不同角色可注入不同实现。
*
* 路由跳转通过 `onSessionCreated` 回调注入,组件本身不感知路由结构。
* *
* 支持四种练习模式: * 支持四种练习模式:
* 1. 错题变式练习:从错题本发起 * 1. 错题重做:从错题本发起(通过 presetMode
* 2. 知识点专项:选择知识点后抽题 * 2. 知识点专项:选择知识点后抽题
* 3. 薄弱章节:自动识别薄弱知识点 * 3. 薄弱章节:自动识别薄弱知识点(不传 chapterId 时跨章节识别)
* 4. AI 推荐:AI 根据学情推荐 * 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 t = useTranslations("practice")
const router = useRouter() const service = usePracticeService()
const analytics = usePracticeAnalytics()
const [isPending, startTransition] = useTransition() const [isPending, startTransition] = useTransition()
const [practiceType, setPracticeType] = useState<PracticeType>( 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 { function handleStart(): void {
if (presetMode) { if (presetMode) {
// 预设模式:直接使用传入的 sourceMeta // 预设模式:直接使用传入的 sourceMeta
@@ -76,73 +137,80 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
questionCount, questionCount,
}), }),
) )
const res = await createPracticeSessionAction(undefined, formData) const res = await service.createSession(undefined, formData)
if (res.success && res.data) { if (res.success && res.data) {
analytics.trackSessionStart(presetMode.type, questionCount)
toast.success(res.message ?? t("toasts.created")) toast.success(res.message ?? t("toasts.created"))
router.push(`/student/practice/${res.data.sessionId}`) onSessionCreated?.(res.data.sessionId)
} else { } else {
toast.error(res.message ?? t("toasts.createFailed")) toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
} }
}) })
return return
}
// 自定义模式:根据类型构建 sourceMeta
let sourceMeta: Record<string, unknown> = {}
if (practiceType === "knowledge_point") {
if (selectedKpIds.length === 0) {
toast.error(t("toasts.selectKnowledgePoint"))
return
}
sourceMeta = {
knowledgePointIds: selectedKpIds,
difficulty: difficulty > 0 ? difficulty : undefined,
}
} else if (practiceType === "weak_chapter") {
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
// 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点
if (selectedKpIds.length === 0) {
toast.error(t("toasts.selectWeakKnowledgePoint"))
return
}
sourceMeta = {
weakKnowledgePointIds: selectedKpIds,
}
} else if (practiceType === "ai_recommended") {
sourceMeta = {
recommendedKnowledgePointIds: selectedKpIds,
// 枚举值业务数据UI 层通过 t(`reasons.${reason}`) 查 i18n
reason: aiRecommendReason,
}
}
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({
practiceType,
sourceMeta,
questionCount,
}),
)
const res = await service.createSession(undefined, formData)
if (res.success && res.data) {
analytics.trackSessionStart(practiceType, questionCount)
toast.success(res.message ?? t("toasts.created"))
onSessionCreated?.(res.data.sessionId)
} else {
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
}
})
} }
// 自定义模式:根据类型构建 sourceMeta const showKpSelector =
let sourceMeta: Record<string, unknown> = {} !presetMode ||
practiceType === "knowledge_point" ||
if (practiceType === "knowledge_point") { practiceType === "weak_chapter" ||
if (selectedKpIds.length === 0) { practiceType === "ai_recommended"
toast.error(t("toasts.selectKnowledgePoint"))
return
}
sourceMeta = {
knowledgePointIds: selectedKpIds,
difficulty: difficulty > 0 ? difficulty : undefined,
}
} else if (practiceType === "weak_chapter") {
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
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"),
}
}
startTransition(async () => {
const formData = new FormData()
formData.append(
"json",
JSON.stringify({
practiceType,
sourceMeta,
questionCount,
}),
)
const res = await createPracticeSessionAction(undefined, formData)
if (res.success && res.data) {
toast.success(res.message ?? t("toasts.created"))
router.push(`/student/practice/${res.data.sessionId}`)
} else {
toast.error(res.message ?? t("toasts.createFailed"))
}
})
}
const showKpSelector = !presetMode || practiceType === "knowledge_point" || practiceType === "weak_chapter" || practiceType === "ai_recommended"
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <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")} {t("starter.title")}
</CardTitle> </CardTitle>
<CardDescription>{t("starter.description")}</CardDescription> <CardDescription>{t("starter.description")}</CardDescription>
@@ -151,30 +219,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{/* 练习类型选择 */} {/* 练习类型选择 */}
{!lockType ? ( {!lockType ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("starter.type")}</Label> <Label htmlFor="practice-type-select">{t("starter.type")}</Label>
<Select <Select value={practiceType} onValueChange={handleTypeChange}>
value={practiceType} <SelectTrigger id="practice-type-select" aria-label={t("starter.type")}>
onValueChange={(v: string) => setPracticeType(v as PracticeType)}
>
<SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="knowledge_point"> <SelectItem value="knowledge_point">
<span className="flex items-center gap-2"> <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")} {t("types.knowledge_point")}
</span> </span>
</SelectItem> </SelectItem>
<SelectItem value="weak_chapter"> <SelectItem value="weak_chapter">
<span className="flex items-center gap-2"> <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")} {t("types.weak_chapter")}
</span> </span>
</SelectItem> </SelectItem>
<SelectItem value="ai_recommended"> <SelectItem value="ai_recommended">
<span className="flex items-center gap-2"> <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")} {t("types.ai_recommended")}
</span> </span>
</SelectItem> </SelectItem>
@@ -187,21 +252,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{showKpSelector && knowledgePoints.length > 0 ? ( {showKpSelector && knowledgePoints.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("starter.knowledgePoints")}</Label> <Label>{t("starter.knowledgePoints")}</Label>
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2"> <div
{knowledgePoints.map((kp) => ( className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2"
<label role="group"
key={kp.id} aria-label={t("starter.knowledgePoints")}
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer" >
> {knowledgePoints.map((kp) => {
<input const checked = selectedKpIds.includes(kp.id)
type="checkbox" return (
checked={selectedKpIds.includes(kp.id)} <label
onChange={() => toggleKp(kp.id)} key={kp.id}
className="rounded border-input" className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
/> >
<span className="text-sm">{kp.name}</span> <Checkbox
</label> checked={checked}
))} onCheckedChange={() => toggleKp(kp.id)}
aria-label={kp.name}
/>
<span className="text-sm">{kp.name}</span>
</label>
)
})}
</div> </div>
</div> </div>
) : null} ) : null}
@@ -209,12 +280,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{/* 难度选择(仅知识点专项) */} {/* 难度选择(仅知识点专项) */}
{practiceType === "knowledge_point" && !presetMode ? ( {practiceType === "knowledge_point" && !presetMode ? (
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("starter.difficulty")}</Label> <Label htmlFor="difficulty-select">{t("starter.difficulty")}</Label>
<Select <Select value={String(difficulty)} onValueChange={handleDifficultyChange}>
value={String(difficulty)} <SelectTrigger id="difficulty-select" aria-label={t("starter.difficulty")}>
onValueChange={(v: string) => setDifficulty(Number(v))}
>
<SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -231,12 +299,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
{/* 题目数量 */} {/* 题目数量 */}
<div className="space-y-2"> <div className="space-y-2">
<Label>{t("starter.questionCount")}</Label> <Label htmlFor="question-count-select">{t("starter.questionCount")}</Label>
<Select <Select value={String(questionCount)} onValueChange={handleQuestionCountChange}>
value={String(questionCount)} <SelectTrigger id="question-count-select" aria-label={t("starter.questionCount")}>
onValueChange={(v: string) => setQuestionCount(Number(v))}
>
<SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -254,6 +319,7 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
onClick={handleStart} onClick={handleStart}
disabled={isPending} disabled={isPending}
className="w-full" className="w-full"
aria-label={isPending ? t("starter.creating") : t("starter.start")}
> >
{isPending ? t("starter.creating") : t("starter.start")} {isPending ? t("starter.creating") : t("starter.start")}
</Button> </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 { db } from "@/shared/db"
import { practiceAnswers, practiceSessions, questions, questionsToKnowledgePoints, knowledgePoints } from "@/shared/db/schema" 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" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -54,6 +59,8 @@ export interface PracticeTypeBreakdown {
* 获取班级专项练习统计。 * 获取班级专项练习统计。
* *
* 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。 * 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。
* 使用单条 SQL 同时计算汇总统计与活跃学生数COUNT(DISTINCT)
* 避免 N+1 查询。
* *
* @param classId 班级 ID * @param classId 班级 ID
*/ */
@@ -63,12 +70,14 @@ export const getClassPracticeStats = cache(async (
const studentIds = await getActiveStudentIdsByClassId(classId) const studentIds = await getActiveStudentIdsByClassId(classId)
if (studentIds.length === 0) return null if (studentIds.length === 0) return null
// 单条 SQL 同时获取汇总统计与活跃学生数(替代原先两条 SQL
const rows = await db const rows = await db
.select({ .select({
totalSessions: count(), totalSessions: count(),
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`), completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`, totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`, totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
}) })
.from(practiceSessions) .from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds)) .where(inArray(practiceSessions.studentId, studentIds))
@@ -78,14 +87,7 @@ export const getClassPracticeStats = cache(async (
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered) const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
const totalCorrect = Number(row.totalCorrect) const totalCorrect = Number(row.totalCorrect)
const activeStudents = Number(row.activeStudents)
// 统计参与练习的学生数
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 { return {
classId, classId,
@@ -141,6 +143,9 @@ export const getClassStudentPracticeSummaries = cache(async (
/** /**
* 获取年级专项练习统计。 * 获取年级专项练习统计。
* *
* 使用单条 SQL 同时计算汇总统计与活跃学生数COUNT(DISTINCT)
* 避免 N+1 查询。
*
* @param gradeId 年级 ID * @param gradeId 年级 ID
*/ */
export const getGradePracticeStats = cache(async ( 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`), completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`, totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`, totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
}) })
.from(practiceSessions) .from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds)) .where(inArray(practiceSessions.studentId, studentIds))
@@ -172,18 +178,13 @@ export const getGradePracticeStats = cache(async (
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered) const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
const totalCorrect = Number(row.totalCorrect) 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 { return {
totalSessions: Number(row.totalSessions), totalSessions: Number(row.totalSessions),
completedSessions: Number(row.completedSessions), completedSessions: Number(row.completedSessions),
totalQuestionsAnswered, totalQuestionsAnswered,
totalCorrect, totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, 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 列表 * @param classIds 教师/年级主任可访问的班级 ID 列表
*/ */
export const getTeacherClassPracticeOverviews = cache(async ( export const getTeacherClassPracticeOverviews = cache(async (
@@ -278,23 +283,30 @@ export const getTeacherClassPracticeOverviews = cache(async (
): Promise<TeacherClassPracticeOverview[]> => { ): Promise<TeacherClassPracticeOverview[]> => {
if (classIds.length === 0) return [] if (classIds.length === 0) return []
const results = await Promise.all( // 批量获取所有班级名称1 条 SQL
classIds.map(async (classId): Promise<TeacherClassPracticeOverview | null> => { const classNameMap = await getClassNamesByIds(classIds)
const className = await getClassNameById(classId)
const studentIds = await getActiveStudentIdsByClassId(classId) // 并行获取每个班级的学生 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 const totalStudents = studentIds.length
if (totalStudents === 0) { if (totalStudents === 0) {
return { return {
classId, classId,
className: className ?? "", totalStudents: 0,
totalSessions: 0, totalSessions: 0,
completedSessions: 0, completedSessions: 0,
totalQuestionsAnswered: 0, totalQuestionsAnswered: 0,
totalCorrect: 0, totalCorrect: 0,
averageAccuracy: 0,
activeStudents: 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`), completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`, totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`, totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
}) })
.from(practiceSessions) .from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds)) .where(inArray(practiceSessions.studentId, studentIds))
const row = rows[0] 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 { return {
classId, classId,
className: className ?? "",
totalSessions: Number(row.totalSessions),
completedSessions: Number(row.completedSessions),
totalQuestionsAnswered,
totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents,
totalStudents, 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 * @param gradeId 年级 ID
*/ */
export const getGradeClassPracticeComparison = cache(async ( export const getGradeClassPracticeComparison = cache(async (
@@ -435,23 +453,27 @@ export const getGradeClassPracticeComparison = cache(async (
const classList = await getClassesByGradeId(gradeId) const classList = await getClassesByGradeId(gradeId)
if (classList.length === 0) return [] if (classList.length === 0) return []
const results = await Promise.all( // 并行获取每个班级的学生 ID 列表
classList.map(async (cls): Promise<GradeClassPracticeComparison> => { const studentIdsPerClass = await Promise.all(
const studentIds = await getActiveStudentIdsByClassId(cls.id) classList.map(async (cls) => ({
const totalStudents = studentIds.length cls,
studentIds: await getActiveStudentIdsByClassId(cls.id),
})),
)
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
const statsPerClass = await Promise.all(
studentIdsPerClass.map(async ({ cls, studentIds }) => {
const totalStudents = studentIds.length
if (totalStudents === 0) { if (totalStudents === 0) {
return { return {
classId: cls.id, cls,
className: cls.name, totalStudents: 0,
totalSessions: 0, totalSessions: 0,
completedSessions: 0, completedSessions: 0,
totalQuestionsAnswered: 0, totalQuestionsAnswered: 0,
totalCorrect: 0, totalCorrect: 0,
averageAccuracy: 0,
activeStudents: 0, activeStudents: 0,
totalStudents: 0,
participationRate: 0,
} }
} }
@@ -461,36 +483,42 @@ export const getGradeClassPracticeComparison = cache(async (
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`), completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`, totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`, totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
}) })
.from(practiceSessions) .from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds)) .where(inArray(practiceSessions.studentId, studentIds))
const row = rows[0] 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 { return {
classId: cls.id, cls,
className: cls.name, totalStudents,
totalSessions: Number(row?.totalSessions ?? 0), totalSessions: Number(row?.totalSessions ?? 0),
completedSessions: Number(row?.completedSessions ?? 0), completedSessions: Number(row?.completedSessions ?? 0),
totalQuestionsAnswered, totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
totalCorrect, totalCorrect: Number(row?.totalCorrect ?? 0),
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, activeStudents: Number(row?.activeStudents ?? 0),
activeStudents,
totalStudents,
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 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: s.totalStudents,
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
}
})
// 按参与率降序排列 // 按参与率降序排列
return results.sort((a, b) => b.participationRate - a.participationRate) return results.sort((a, b) => b.participationRate - a.participationRate)
}) })

View File

@@ -10,6 +10,7 @@ import {
questionsToKnowledgePoints, questionsToKnowledgePoints,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { isWeakChapterSourceMeta, isAiRecommendedSourceMeta, isKnowledgePointSourceMeta, isErrorVariantSourceMeta } from "./lib/source-meta"
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types" import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -22,22 +23,25 @@ const DEFAULT_QUESTION_COUNT = 10
/** 薄弱知识点掌握度阈值(低于此值视为薄弱) */ /** 薄弱知识点掌握度阈值(低于此值视为薄弱) */
const WEAK_MASTERY_THRESHOLD = 60 const WEAK_MASTERY_THRESHOLD = 60
/** 自动识别薄弱知识点默认返回数量 */
const DEFAULT_WEAK_KP_LIMIT = 5
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 出题策略 // 出题策略
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* 错题变式练习出题策略 * 错题重做出题策略(原"错题变式"
* *
* 从错题本中选取错题,直接使用原题进行练习(不依赖 AI 生成变式题, * 从错题本中选取错题,直接使用原题进行练习
* 确保即使 AI 不可用也能练习 * 该策略不依赖 AI 生成变式题,确保即使 AI 不可用也能练习。
* *
* 策略: * 策略:
* 1. 从 sourceQuestionIds 中查询题库中存在的题目 * 1. 从 sourceQuestionIds 中查询题库中存在的题目
* 2. 排除已在该练习会话中的题目(由调用方保证) * 2. 排除已在该练习会话中的题目(由调用方保证)
* 3. 按难度升序排列(先易后难,建立信心) * 3. 按难度升序排列(先易后难,建立信心)
*/ */
async function selectForErrorVariant( export async function selectForErrorVariant(
sourceMeta: PracticeSourceMeta, sourceMeta: PracticeSourceMeta,
questionCount: number, questionCount: number,
): Promise<QuestionSelectionResult> { ): Promise<QuestionSelectionResult> {
@@ -79,7 +83,7 @@ async function selectForErrorVariant(
* 2. 按难度筛选(如指定) * 2. 按难度筛选(如指定)
* 3. 随机抽取指定数量 * 3. 随机抽取指定数量
*/ */
async function selectForKnowledgePoint( export async function selectForKnowledgePoint(
sourceMeta: PracticeSourceMeta, sourceMeta: PracticeSourceMeta,
questionCount: number, questionCount: number,
): Promise<QuestionSelectionResult> { ): 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, studentId: string,
sourceMeta: PracticeSourceMeta, sourceMeta: PracticeSourceMeta,
questionCount: number, questionCount: number,
@@ -141,8 +145,15 @@ async function selectForWeakChapter(
return { questionIds: [], variants: new Map() } return { questionIds: [], variants: new Map() }
} }
const { weakKnowledgePointIds } = sourceMeta const { chapterId, weakKnowledgePointIds } = sourceMeta
if (weakKnowledgePointIds.length === 0) {
// 自动识别薄弱知识点(若未传入)
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() } return { questionIds: [], variants: new Map() }
} }
@@ -151,7 +162,7 @@ async function selectForWeakChapter(
// 从薄弱知识点中抽题 // 从薄弱知识点中抽题
const conditions = [ const conditions = [
inArray(questionsToKnowledgePoints.knowledgePointId, weakKnowledgePointIds), inArray(questionsToKnowledgePoints.knowledgePointId, kpIds),
sql`${questions.parentId} IS NULL`, sql`${questions.parentId} IS NULL`,
] ]
@@ -185,7 +196,7 @@ async function selectForWeakChapter(
* AI 推荐的知识点列表由上层AI 分析)提供, * AI 推荐的知识点列表由上层AI 分析)提供,
* 此函数仅负责从推荐知识点中抽题。 * 此函数仅负责从推荐知识点中抽题。
*/ */
async function selectForAiRecommended( export async function selectForAiRecommended(
studentId: string, studentId: string,
sourceMeta: PracticeSourceMeta, sourceMeta: PracticeSourceMeta,
questionCount: number, questionCount: number,
@@ -274,7 +285,7 @@ export async function selectQuestionsForPractice(
* 从专项练习中汇总已答题目。 * 从专项练习中汇总已答题目。
* 为控制查询量,仅查询最近 1000 条记录。 * 为控制查询量,仅查询最近 1000 条记录。
*/ */
async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> { export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
const rows = await db const rows = await db
.select({ questionId: practiceAnswers.questionId }) .select({ questionId: practiceAnswers.questionId })
.from(practiceAnswers) .from(practiceAnswers)
@@ -298,7 +309,7 @@ async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[
export async function identifyWeakKnowledgePoints( export async function identifyWeakKnowledgePoints(
studentId: string, studentId: string,
chapterId?: string, chapterId?: string,
limit: number = 5, limit: number = DEFAULT_WEAK_KP_LIMIT,
): Promise<string[]> { ): Promise<string[]> {
const conditions = [ const conditions = [
eq(knowledgePointMastery.studentId, studentId), eq(knowledgePointMastery.studentId, studentId),
@@ -317,27 +328,3 @@ export async function identifyWeakKnowledgePoints(
return rows.map((r) => r.knowledgePointId) 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, questions,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import { selectQuestionsForPractice } from "./data-access-strategy" 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 { import type {
PracticeAnswerRecord, PracticeAnswerRecord,
PracticeAnswerStatus,
PracticeSessionDetail, PracticeSessionDetail,
PracticeSessionSummary, PracticeSessionSummary,
PracticeSourceMeta, PracticeSourceMeta,
@@ -34,8 +41,8 @@ function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessi
id: row.id, id: row.id,
studentId: row.studentId, studentId: row.studentId,
subjectId: row.subjectId, subjectId: row.subjectId,
practiceType: row.practiceType as PracticeType, practiceType: asPracticeType(row.practiceType),
status: row.status as PracticeStatus, status: asPracticeStatus(row.status),
totalQuestions: row.totalQuestions, totalQuestions: row.totalQuestions,
answeredQuestions, answeredQuestions,
correctCount, correctCount,
@@ -56,7 +63,7 @@ function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
variantContent: row.variantContent, variantContent: row.variantContent,
isVariant: row.isVariant, isVariant: row.isVariant,
orderIndex: row.orderIndex, orderIndex: row.orderIndex,
status: row.status as PracticeAnswerStatus, status: asPracticeAnswerStatus(row.status),
studentAnswer: row.studentAnswer, studentAnswer: row.studentAnswer,
isCorrect: row.isCorrect, isCorrect: row.isCorrect,
score: row.score, score: row.score,
@@ -170,7 +177,7 @@ export const getPracticeSessionById = cache(async (
return { return {
...summary, ...summary,
sourceMeta: session.sourceMeta as PracticeSourceMeta | null, sourceMeta: asPracticeSourceMeta(session.sourceMeta),
answers: mappedAnswers, answers: mappedAnswers,
} }
}) })
@@ -213,7 +220,7 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
} }
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({ const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
practiceType: type as PracticeType, practiceType: asPracticeType(type),
sessionCount: stat.sessionCount, sessionCount: stat.sessionCount,
totalQuestions: stat.totalQuestions, totalQuestions: stat.totalQuestions,
correctCount: stat.correctCount, correctCount: stat.correctCount,
@@ -241,6 +248,8 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
* 2. 创建会话记录 * 2. 创建会话记录
* 3. 创建答题记录(初始状态为 pending * 3. 创建答题记录(初始状态为 pending
* *
* @throws {PracticeError} 未找到题目时抛 no_questions_found
*
* @returns 会话 ID 和选中的题目数量 * @returns 会话 ID 和选中的题目数量
*/ */
export async function createPracticeSession( export async function createPracticeSession(
@@ -263,7 +272,7 @@ export async function createPracticeSession(
) )
if (selection.questionIds.length === 0) { if (selection.questionIds.length === 0) {
return { sessionId: "", selectedCount: 0 } throw practiceErrors.noQuestionsFound()
} }
const sessionId = createId() const sessionId = createId()
@@ -276,6 +285,7 @@ export async function createPracticeSession(
studentId, studentId,
subjectId: input.subjectId ?? null, subjectId: input.subjectId ?? null,
practiceType, practiceType,
// sourceMeta 已在上层通过 parseSourceMeta 校验,此处直接写入
sourceMeta: sourceMeta as unknown, sourceMeta: sourceMeta as unknown,
status: "in_progress", status: "in_progress",
totalQuestions: selection.questionIds.length, totalQuestions: selection.questionIds.length,
@@ -314,6 +324,12 @@ export async function createPracticeSession(
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案 * - 选择题/判断题:通过 extractCorrectAnswer 比对答案
* - 填空题暂不自动判分isCorrect = null * - 填空题暂不自动判分isCorrect = null
* *
* 并发安全:整个校验+判分+统计更新流程包裹在事务中,
* 对答题记录加行锁SELECT ... FOR UPDATE
* 防止同一答案被并发重复判分导致统计累加错误。
*
* @throws {PracticeError} 会话/答题记录不存在、已结束、已作答时抛对应错误码
*
* @returns 是否判分成功 * @returns 是否判分成功
*/ */
export async function submitPracticeAnswer( export async function submitPracticeAnswer(
@@ -323,95 +339,123 @@ export async function submitPracticeAnswer(
answer: unknown, answer: unknown,
skip: boolean = false, skip: boolean = false,
): Promise<{ isCorrect: boolean | null; score: number | null }> { ): Promise<{ isCorrect: boolean | null; score: number | null }> {
// 校验会话归属 // 事务:行锁 + 校验 + 判分 + 统计更新(防止并发重复判分)
const session = await db.query.practiceSessions.findFirst({ return await db.transaction(async (tx) => {
where: and( // 1. 校验会话归属(带行锁)
eq(practiceSessions.id, sessionId), const [session] = await tx
eq(practiceSessions.studentId, studentId), .select()
), .from(practiceSessions)
}) .where(and(
eq(practiceSessions.id, sessionId),
eq(practiceSessions.studentId, studentId),
))
.for("update")
if (!session) { if (!session) {
throw new Error("练习会话不存在或无权访问") throw practiceErrors.sessionNotFound()
} }
if (session.status !== "in_progress") { if (session.status !== "in_progress") {
throw new Error("练习会话已结束") throw practiceErrors.sessionEnded()
} }
// 查询答题记录 // 2. 查询答题记录(带行锁,防止并发重复提交)
const answerRecord = await db.query.practiceAnswers.findFirst({ const [answerRecord] = await tx
where: and( .select()
eq(practiceAnswers.id, answerId), .from(practiceAnswers)
eq(practiceAnswers.sessionId, sessionId), .where(and(
), eq(practiceAnswers.id, answerId),
}) eq(practiceAnswers.sessionId, sessionId),
))
.for("update")
if (!answerRecord) { if (!answerRecord) {
throw new Error("答题记录不存在") throw practiceErrors.answerNotFound()
} }
if (answerRecord.status === "answered") { if (answerRecord.status === "answered") {
throw new Error("此题已作答") throw practiceErrors.answerAlreadySubmitted()
} }
const now = new Date() const now = new Date()
if (skip) { if (skip) {
// 跳过此题 // 跳过此题:状态置为 skipped不累加已答题数与正确数
await db await tx
.update(practiceAnswers)
.set({
status: "skipped",
answeredAt: now,
})
.where(eq(practiceAnswers.id, answerId))
// 累加已答题数(不累加正确数)
await tx
.update(practiceSessions)
.set({
answeredQuestions: session.answeredQuestions + 1,
})
.where(eq(practiceSessions.id, sessionId))
return { isCorrect: null, score: null }
}
// 3. 自动判分:查询题目内容并提取正确答案
const [question] = await tx
.select()
.from(questions)
.where(eq(questions.id, answerRecord.questionId))
.limit(1)
if (!question) {
throw practiceErrors.questionNotFound()
}
// 如果是变式题,使用变式题内容
const contentToUse = answerRecord.variantContent ?? question.content
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
// 4. 更新答题记录
await tx
.update(practiceAnswers) .update(practiceAnswers)
.set({ .set({
status: "skipped", status: "answered",
studentAnswer: answer,
isCorrect,
score,
answeredAt: now, answeredAt: now,
}) })
.where(eq(practiceAnswers.id, answerId)) .where(eq(practiceAnswers.id, answerId))
// 更新会话统计 // 5. 累加会话统计(基于步骤 1 已加锁的 session 行)
await updateSessionStats(sessionId, 0, false) await tx
return { isCorrect: null, score: null } .update(practiceSessions)
} .set({
answeredQuestions: session.answeredQuestions + 1,
correctCount: session.correctCount + (isCorrect === true ? 1 : 0),
})
.where(eq(practiceSessions.id, sessionId))
// 自动判分:查询题目内容并提取正确答案 return { isCorrect, score }
const question = await db.query.questions.findFirst({
where: eq(questions.id, answerRecord.questionId),
}) })
if (!question) {
throw new Error("题目不存在")
}
// 如果是变式题,使用变式题内容
const contentToUse = answerRecord.variantContent ?? question.content
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
await db
.update(practiceAnswers)
.set({
status: "answered",
studentAnswer: answer,
isCorrect,
score,
answeredAt: now,
})
.where(eq(practiceAnswers.id, answerId))
// 更新会话统计
await updateSessionStats(
sessionId,
1,
isCorrect === true,
)
return { isCorrect, score }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 写入:完成/放弃练习会话 // 写入:完成/放弃练习会话
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* 完成练习会话。
*
* 完整性校验必须答完所有题目answeredQuestions === totalQuestions才能完成
* 防止学生提前完成导致统计失真。
*
* 注意:跳过的题目也算"已作答"status=skipped与 answeredQuestions 累加逻辑一致。
*
* @throws {PracticeError} 会话不存在 → session_not_found未答完 → session_not_complete
*/
export async function completePracticeSession( export async function completePracticeSession(
sessionId: string, sessionId: string,
studentId: string, studentId: string,
@@ -424,13 +468,19 @@ export async function completePracticeSession(
}) })
if (!session) { if (!session) {
throw new Error("练习会话不存在或无权访问") throw practiceErrors.sessionNotFound()
} }
if (session.status !== "in_progress") { if (session.status !== "in_progress") {
// 已完成或已放弃,幂等返回
return return
} }
// 完整性校验:必须答完所有题目
if (session.answeredQuestions !== session.totalQuestions) {
throw practiceErrors.sessionNotComplete()
}
await db await db
.update(practiceSessions) .update(practiceSessions)
.set({ .set({
@@ -440,6 +490,13 @@ export async function completePracticeSession(
.where(eq(practiceSessions.id, sessionId)) .where(eq(practiceSessions.id, sessionId))
} }
/**
* 放弃练习会话。
*
* 幂等:已完成或已放弃的会话再次调用不会报错。
*
* @throws {PracticeError} 会话不存在 → session_not_found
*/
export async function abandonPracticeSession( export async function abandonPracticeSession(
sessionId: string, sessionId: string,
studentId: string, studentId: string,
@@ -452,10 +509,11 @@ export async function abandonPracticeSession(
}) })
if (!session) { if (!session) {
throw new Error("练习会话不存在或无权访问") throw practiceErrors.sessionNotFound()
} }
if (session.status !== "in_progress") { if (session.status !== "in_progress") {
// 已完成或已放弃,幂等返回
return return
} }
@@ -472,148 +530,5 @@ export async function abandonPracticeSession(
// 内部辅助函数 // 内部辅助函数
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** // 自动判分、答案归一化等纯函数已抽取至 lib/grading.ts便于单测与复用。
* 更新会话统计(已答题数、正确数) // 会话统计累加逻辑已内联到 submitPracticeSession 事务中,确保原子性
*/
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
}

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"]) export const PracticeAnswerStatusSchema = z.enum(["pending", "answered", "skipped"])
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 来源元数据验证 // 来源元数据验证(判别式联合类型,替代 z.record(z.string(), z.unknown())
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/**
* 错题重做练习来源。
*
* practiceType="error_variant" 时使用。
*/
export const ErrorVariantSourceMetaSchema = z.object({ export const ErrorVariantSourceMetaSchema = z.object({
errorBookItemIds: z.array(z.string().min(1)).min(1), errorBookItemIds: z.array(z.string().min(1)).min(1),
sourceQuestionIds: 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({ export const KnowledgePointSourceMetaSchema = z.object({
knowledgePointIds: z.array(z.string().min(1)).min(1), knowledgePointIds: z.array(z.string().min(1)).min(1),
difficulty: z.number().int().min(1).max(5).optional(), difficulty: z.number().int().min(1).max(5).optional(),
}) })
/**
* 薄弱章节练习来源。
*
* practiceType="weak_chapter" 时使用。
* chapterId 可选——未传时跨所有章节自动识别薄弱知识点。
* weakKnowledgePointIds 可选——未传时由后端自动识别薄弱知识点。
*/
export const WeakChapterSourceMetaSchema = z.object({ export const WeakChapterSourceMetaSchema = z.object({
chapterId: z.string().min(1), chapterId: z.string().min(1).optional(),
weakKnowledgePointIds: z.array(z.string().min(1)).min(1), 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({ export const AiRecommendedSourceMetaSchema = z.object({
recommendedKnowledgePointIds: z.array(z.string().min(1)).min(1), 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 输入验证 // Action 输入验证
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -72,3 +126,4 @@ export type CreatePracticeSessionInput = z.infer<typeof CreatePracticeSessionSch
export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema> export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema>
export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema> export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema>
export type AbandonPracticeSessionInput = z.infer<typeof AbandonPracticeSessionSchema> 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 { export interface WeakChapterSourceMeta {
chapterId: string /** 章节 ID可选未传时跨所有章节自动识别薄弱知识点 */
/** 自动识别的薄弱知识点 */ chapterId?: string
weakKnowledgePointIds: string[] /** 自动识别的薄弱知识点(可选;未传时由 data-access 自动识别) */
weakKnowledgePointIds?: string[]
} }
/** AI 推荐练习来源 */ /** AI 推荐练习来源 */
export interface AiRecommendedSourceMeta { export interface AiRecommendedSourceMeta {
/** AI 推荐的知识点列表 */ /** AI 推荐的知识点列表 */
recommendedKnowledgePointIds: string[] recommendedKnowledgePointIds: string[]
/** 推荐理由 */ /** 推荐理由枚举值UI 层翻译) */
reason: string reason: AiRecommendedReason
} }
/**
* AI 推荐理由枚举。
*
* 业务数据存数据库UI 层通过 t(`reasons.${reason}`) 查 i18n。
* 严禁存翻译文本到数据库。
*/
export type AiRecommendedReason = "student_initiated" | "teacher_assigned" | "parent_suggested"
export type PracticeSourceMeta = export type PracticeSourceMeta =
| ErrorVariantSourceMeta | ErrorVariantSourceMeta
| KnowledgePointSourceMeta | KnowledgePointSourceMeta

View File

@@ -18,6 +18,7 @@ import {
WeaknessAnalysisInputSchema, WeaknessAnalysisInputSchema,
ChildSummaryInputSchema, ChildSummaryInputSchema,
StudyPathInputSchema, StudyPathInputSchema,
ExplainErrorInputSchema,
} from "./schema" } from "./schema"
import type { import type {
AiChatMessage, AiChatMessage,
@@ -37,6 +38,8 @@ import type {
StudyPathInput, StudyPathInput,
StudyPathResult, StudyPathResult,
AiUsageStats, AiUsageStats,
ExplainErrorInput,
ExplainErrorResult,
} from "./types" } from "./types"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -327,16 +330,15 @@ export async function recommendStudyPathAction(
// 同步填充 currentMastery若未传入 // 同步填充 currentMastery若未传入
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) { if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
serviceInput.currentMastery = kps serviceInput.currentMastery = kps.flatMap((kp) => {
.filter((kp) => masteryMap.has(kp.id)) const m = masteryMap.get(kp.id)
.map((kp) => { if (!m) return []
const m = masteryMap.get(kp.id)! return [{
return { knowledgePoint: kp.name,
knowledgePoint: kp.name, masteryLevel: Math.round((m.masteryLevel / 100) * 5),
masteryLevel: Math.round((m.masteryLevel / 100) * 5), errorCount: m.totalQuestions - m.correctQuestions,
errorCount: m.totalQuestions - m.correctQuestions, }]
} })
})
} }
} }
} catch { } catch {
@@ -376,6 +378,38 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
if (error instanceof PermissionDeniedError) { if (error instanceof PermissionDeniedError) {
return { success: false, message: error.message } 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 { return {
systemPrompt: 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.", "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: [ suggestedPrompts: [
t("chat.suggestedPrompts.teacher.0"), t("chat.suggestedPrompts.teacher.0"),
"What are common mistakes in this type of question?", t("chat.suggestedPrompts.context.teacherGrading.0"),
"How should I give constructive feedback?", t("chat.suggestedPrompts.context.teacherGrading.1"),
], ],
} }
} }
@@ -243,11 +243,11 @@ function inferContextFromPath(
return { return {
systemPrompt: systemPrompt:
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.", "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: [ suggestedPrompts: [
t("chat.suggestedPrompts.teacher.1"), t("chat.suggestedPrompts.teacher.1"),
"Suggest a hook for this lesson", t("chat.suggestedPrompts.context.teacherLesson.0"),
"What are some differentiation strategies?", t("chat.suggestedPrompts.context.teacherLesson.1"),
], ],
} }
} }
@@ -257,11 +257,11 @@ function inferContextFromPath(
return { return {
systemPrompt: systemPrompt:
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.", "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: [ suggestedPrompts: [
t("chat.suggestedPrompts.teacher.2"), t("chat.suggestedPrompts.teacher.2"),
"Generate a question on this topic", t("chat.suggestedPrompts.context.teacherExam.0"),
"Analyze the difficulty distribution", t("chat.suggestedPrompts.context.teacherExam.1"),
], ],
} }
} }
@@ -271,7 +271,7 @@ function inferContextFromPath(
return { return {
systemPrompt: 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.", "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: [ suggestedPrompts: [
t("chat.suggestedPrompts.student.0"), t("chat.suggestedPrompts.student.0"),
t("chat.suggestedPrompts.student.1"), t("chat.suggestedPrompts.student.1"),
@@ -285,11 +285,11 @@ function inferContextFromPath(
return { return {
systemPrompt: 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.", "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: [ suggestedPrompts: [
t("chat.suggestedPrompts.student.0"), t("chat.suggestedPrompts.student.0"),
"Give me a hint, not the answer", t("chat.suggestedPrompts.context.studentHomework.0"),
"Help me understand this concept", t("chat.suggestedPrompts.context.studentHomework.1"),
], ],
} }
} }
@@ -299,7 +299,7 @@ function inferContextFromPath(
return { return {
systemPrompt: systemPrompt:
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.", "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: [ suggestedPrompts: [
t("chat.suggestedPrompts.parent.0"), t("chat.suggestedPrompts.parent.0"),
t("chat.suggestedPrompts.parent.1"), t("chat.suggestedPrompts.parent.1"),
@@ -312,7 +312,7 @@ function inferContextFromPath(
return { return {
systemPrompt: systemPrompt:
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.", "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: [ suggestedPrompts: [
t("chat.suggestedPrompts.admin.0"), t("chat.suggestedPrompts.admin.0"),
t("chat.suggestedPrompts.admin.1"), t("chat.suggestedPrompts.admin.1"),

View File

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

View File

@@ -1,11 +1,14 @@
"use client" "use client"
import { Component, type ReactNode } from "react" /**
import { AlertCircle, RefreshCw } from "lucide-react" * AI 专用 Error Boundary
import { useTranslations } from "next-intl" *
* 薄包装:委托给共享 SectionErrorBoundary使用 ai 命名空间。
* 保留同名导出以兼容现有 import。
*/
import { Button } from "@/shared/components/ui/button" import type { ReactNode } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card" import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
type AiErrorBoundaryProps = { type AiErrorBoundaryProps = {
children: ReactNode children: ReactNode
@@ -15,74 +18,14 @@ type AiErrorBoundaryProps = {
onError?: (error: Error, info: unknown) => void onError?: (error: Error, info: unknown) => void
} }
type AiErrorBoundaryState = { export function AiErrorBoundary({
error: Error | null children,
} fallback,
onError,
/** }: AiErrorBoundaryProps): ReactNode {
* 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")
return ( return (
<Card className="border-destructive/30"> <SectionErrorBoundary namespace="ai" fallback={fallback} onError={onError}>
<CardHeader> {children}
<CardTitle className="flex items-center gap-2 text-destructive"> </SectionErrorBoundary>
<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>
) )
} }

View File

@@ -28,6 +28,28 @@ const CHART_TYPES: Record<string, AiChartType> = {
"chart:radar": "radar", "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 渲染器 * AI Markdown 渲染器
* *
@@ -87,12 +109,9 @@ function AiMarkdownRendererImpl({
// 检测图表代码块language-chart:bar / chart:line / chart:pie / chart:radar // 检测图表代码块language-chart:bar / chart:line / chart:pie / chart:radar
const lang = codeClass?.replace("language-", "").trim() ?? "" const lang = codeClass?.replace("language-", "").trim() ?? ""
const chartType = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`] const chartType = resolveChartType(lang)
?? (lang.startsWith(CHART_LANG_PREFIX)
? (lang.slice(CHART_LANG_PREFIX.length) as AiChartType)
: undefined)
if (chartType && (chartType === "bar" || chartType === "line" || chartType === "pie" || chartType === "radar")) { if (chartType) {
const raw = String(children).replace(/\n$/, "") const raw = String(children).replace(/\n$/, "")
return <AiChartRenderer type={chartType} spec={raw} /> return <AiChartRenderer type={chartType} spec={raw} />
} }

View File

@@ -63,7 +63,7 @@ export function AiProviderSelector({
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>{t("provider.label")}</FormLabel> <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> <FormControl>
<SelectTrigger> <SelectTrigger>
<SelectValue <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" "use client"
import { useState, useEffect } from "react" import { useState, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Activity, Users, AlertTriangle, Clock } from "lucide-react" import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
import { toast } from "sonner" import { toast } from "sonner"
@@ -33,7 +33,7 @@ export function AiUsageDashboard(): React.ReactNode {
const [stats, setStats] = useState<AiUsageStats | null>(null) const [stats, setStats] = useState<AiUsageStats | null>(null)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const loadStats = async (): Promise<void> => { const loadStats = useCallback(async (): Promise<void> => {
if (!aiClient.getAiUsageStats) return if (!aiClient.getAiUsageStats) return
setLoading(true) setLoading(true)
try { try {
@@ -41,19 +41,18 @@ export function AiUsageDashboard(): React.ReactNode {
if (result.success && result.data) { if (result.success && result.data) {
setStats(result.data) setStats(result.data)
} else { } else {
toast.error(result.message ?? t("error.chatFailed")) toast.error(result.message ?? t("error.statsFailed"))
} }
} catch { } catch {
toast.error(t("error.chatFailed")) toast.error(t("error.statsFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
} }, [aiClient, t])
useEffect(() => { useEffect(() => {
void loadStats() void loadStats()
// eslint-disable-next-line react-hooks/exhaustive-deps }, [loadStats])
}, [])
const statCards = stats 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" 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 * 计算吸附到最近边缘后的 X 坐标
const BALL_SIZE = 56 */
const MARGIN = 16 function snapToEdge(x: number): number {
if (typeof window === "undefined") return x
function clampPosition(pos: Position): Position { const w = window.innerWidth
if (typeof window === "undefined") return pos const centerX = x + BALL_SIZE / 2
const maxX = window.innerWidth - BALL_SIZE - MARGIN const distanceToLeft = centerX
const maxY = window.innerHeight - BALL_SIZE - MARGIN const distanceToRight = w - centerX
return { return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - MARGIN
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
}
} }
function loadPosition(): Position { /**
if (typeof window === "undefined") { * 计算半隐藏时的视觉偏移量
return { x: 9999, y: 9999 } */
} function calculateHiddenOffset(
try { position: Position,
const raw = localStorage.getItem(STORAGE_KEY) hidden: boolean,
if (raw) { hovered: boolean,
const parsed = JSON.parse(raw) as Partial<Position> dragging: boolean
if (typeof parsed.x === "number" && typeof parsed.y === "number") { ): number {
return clampPosition({ x: parsed.x, y: parsed.y }) if (!hidden || hovered || dragging) return 0
} return position.x <= MARGIN + 2
} ? -(BALL_SIZE * HIDE_THRESHOLD)
} catch { : BALL_SIZE * HIDE_THRESHOLD
// 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
}
} }
/** /**
@@ -57,135 +50,69 @@ function savePosition(pos: Position): void {
* - 单击(未发生拖动)触发 onClick * - 单击(未发生拖动)触发 onClick
* - 位置持久化到 localStorage * - 位置持久化到 localStorage
* - 窗口 resize 时自动校正位置 * - 窗口 resize 时自动校正位置
*
* V3拆分为 use-position-persistence + use-drag-position + 本 hook 组合
*/ */
export function useFloatingBall(onClick: () => void) { export function useFloatingBall(onClick: () => void): {
// 服务端与客户端首次渲染一致position 在屏幕外,不渲染按钮) position: Position
// 在 useEffect 中加载真实位置,避免 hydration mismatch hidden: boolean
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 }) 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 [hidden, setHidden] = useState(false)
const [dragging, setDragging] = useState(false)
const [hovered, setHovered] = useState(false) const [hovered, setHovered] = useState(false)
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开 // 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
const justHiddenRef = useRef(false) 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) const onClickRef = useRef(onClick)
useEffect(() => { useEffect(() => {
onClickRef.current = onClick onClickRef.current = onClick
}, [onClick]) }, [onClick])
// 初始化位置:在客户端 mount 后加载真实位置 const handleDragStart = useCallback((): void => {
// 避免 hydration mismatch服务端与客户端位置不同 setHidden(false)
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setPosition(loadPosition())
}, []) }, [])
// 窗口 resize 时校正 const handleRelease = useCallback(
useEffect(() => { (moved: boolean): void => {
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
}
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
}
// 未移动 → 视为点击 // 未移动 → 视为点击
if (!state.moved) { if (!moved) {
onClickRef.current() onClickRef.current()
return return
} }
// 移动了 → 吸附到最近边缘 // 移动了 → 吸附到最近边缘
const w = window.innerWidth const snappedX = snapToEdge(position.x)
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 finalPos = clampPosition({ x: snappedX, y: position.y }) const finalPos = clampPosition({ x: snappedX, y: position.y })
setPosition(finalPos) setPosition(finalPos)
savePosition(finalPos) savePosition(finalPos)
setHidden(shouldHide) setHidden(true)
// 标记刚隐藏,阻止后续 mouseEnter 立即展开 // 标记刚隐藏,阻止后续 mouseEnter 立即展开
justHiddenRef.current = shouldHide justHiddenRef.current = true
// 清除 hovered确保 hiddenOffset 生效 // 清除 hovered确保 hiddenOffset 生效
setHovered(false) setHovered(false)
}, },
[position] [position, setPosition]
) )
const handlePointerCancel = useCallback(() => { const { dragging, handlers: dragHandlers } = useDragPosition(
const state = dragStateRef.current position,
state.active = false setPosition,
setDragging(false) { onDragStart: handleDragStart, onRelease: handleRelease }
}, []) )
const handleMouseEnter = useCallback(() => { const handleMouseEnter = useCallback((): void => {
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开) // 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
if (justHiddenRef.current) { if (justHiddenRef.current) {
justHiddenRef.current = false justHiddenRef.current = false
@@ -195,33 +122,26 @@ export function useFloatingBall(onClick: () => void) {
if (hidden) setHidden(false) if (hidden) setHidden(false)
}, [hidden]) }, [hidden])
const handleMouseLeave = useCallback(() => { const handleMouseLeave = useCallback((): void => {
setHovered(false) setHovered(false)
// 离开后清除 justHidden 标记,下次进入可正常展开 // 离开后清除 justHidden 标记,下次进入可正常展开
justHiddenRef.current = false justHiddenRef.current = false
}, []) }, [])
const show = useCallback(() => { const show = useCallback((): void => {
justHiddenRef.current = false justHiddenRef.current = false
setHidden(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 resetPosition = useCallback((): void => {
const hiddenOffset = hidden && !hovered && !dragging justHiddenRef.current = false
? (position.x <= MARGIN + 2 ? -(BALL_SIZE * HIDE_THRESHOLD) : BALL_SIZE * HIDE_THRESHOLD) const fresh = getDefaultPosition()
: 0 setPosition(fresh)
savePosition(fresh)
setHidden(false)
}, [setPosition])
const hiddenOffset = calculateHiddenOffset(position, hidden, hovered, dragging)
return { return {
position, position,
@@ -230,10 +150,7 @@ export function useFloatingBall(onClick: () => void) {
hovered, hovered,
hiddenOffset, hiddenOffset,
handlers: { handlers: {
onPointerDown: handlePointerDown, ...dragHandlers,
onPointerMove: handlePointerMove,
onPointerUp: handlePointerUp,
onPointerCancel: handlePointerCancel,
onMouseEnter: handleMouseEnter, onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave, 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), summary: z.string().min(1),
motivation: 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, WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
CHILD_SUMMARY_SYSTEM_PROMPT, CHILD_SUMMARY_SYSTEM_PROMPT,
STUDY_PATH_SYSTEM_PROMPT, STUDY_PATH_SYSTEM_PROMPT,
EXPLAIN_ERROR_SYSTEM_PROMPT,
} from "./prompt-templates" } from "./prompt-templates"
import { withAiTracking } from "./usage-tracker" import { withAiTracking } from "./usage-tracker"
import { import {
@@ -21,6 +22,7 @@ import {
WeaknessAnalysisResultSchema, WeaknessAnalysisResultSchema,
ChildSummaryResultSchema, ChildSummaryResultSchema,
StudyPathResultSchema, StudyPathResultSchema,
ExplainErrorResultSchema,
} from "../schema" } from "../schema"
import type { import type {
AiChatMessage, AiChatMessage,
@@ -41,6 +43,8 @@ import type {
ChildSummaryResult, ChildSummaryResult,
StudyPathInput, StudyPathInput,
StudyPathResult, StudyPathResult,
ExplainErrorInput,
ExplainErrorResult,
} from "../types" } from "../types"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -144,9 +148,10 @@ const callAi = async (
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}), ...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
...(options?.providerId ? { providerId: options.providerId } : {}), ...(options?.providerId ? { providerId: options.providerId } : {}),
}) })
// 从 unknown 类型安全提取 total_tokens避免 as 断言)
const tokenUsage = const tokenUsage =
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage 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 : undefined
return { content: result.content, tokenUsage } return { content: result.content, tokenUsage }
} }
@@ -169,7 +174,11 @@ export class DefaultAiService implements AiService {
...options, ...options,
temperature: options?.temperature ?? 0.7, 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 } { temperature: 0.5, maxTokens: 3000 }
) )
const parsed = extractJson(content) const parsed = extractJson(content)
// 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
const list = const list =
parsed && typeof parsed === "object" && "questions" in parsed parsed && typeof parsed === "object" && "questions" in parsed
? (parsed as Record<string, unknown>).questions ? parsed.questions
: parsed : parsed
const validated = SimilarQuestionListSchema.safeParse(list) const validated = SimilarQuestionListSchema.safeParse(list)
if (!validated.success) return { result: [] } if (!validated.success) return { result: [] }
@@ -413,6 +423,39 @@ export class DefaultAiService implements AiService {
return { result: validated.data } 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.", "- motivation should be age-appropriate and encouraging.",
"Never output placeholders.", "Never output placeholders.",
].join("\n") ].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 = { export type AiUsageEvent = {
userId: string 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 providerId?: string
model?: string model?: string
success: boolean success: boolean
@@ -23,6 +23,7 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
weakness_analysis: "ai.weakness_analysis", weakness_analysis: "ai.weakness_analysis",
child_summary: "ai.child_summary", child_summary: "ai.child_summary",
study_path: "ai.study_path", 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 能力配置(角色驱动) // AI 能力配置(角色驱动)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -236,6 +266,7 @@ export type AiCapability =
| "study-path" | "study-path"
| "child-summary" | "child-summary"
| "usage-stats" | "usage-stats"
| "explain-error"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 服务接口 // 服务接口
@@ -257,6 +288,7 @@ export interface AiService {
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult> analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult> generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult> recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
explainError(input: ExplainErrorInput): Promise<ExplainErrorResult>
} }
/** /**
@@ -290,6 +322,9 @@ export interface AiClientService {
input: StudyPathInput input: StudyPathInput
) => Promise<ActionState<StudyPathResult>> ) => Promise<ActionState<StudyPathResult>>
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>> getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
explainError?: (
input: ExplainErrorInput
) => Promise<ActionState<ExplainErrorResult>>
/** 预留埋点接口 */ /** 预留埋点接口 */
trackEvent?: (event: string, payload?: Record<string, unknown>) => void 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 { GraduationCap } from "lucide-react"
import { DEFAULT_BRAND_CONFIG, type BrandConfig } from "@/modules/settings/brand-config"
interface AuthLayoutProps { interface AuthLayoutProps {
children: React.ReactNode 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 ( 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="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="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="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium"> <div className="relative z-20 flex items-center text-lg font-medium">
<GraduationCap className="mr-2 h-6 w-6" /> {logoUrl ? (
Next_Edu // 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" />
)}
{schoolName}
</div> </div>
<div className="relative z-20 mt-auto"> <div className="relative z-20 mt-auto">
<blockquote className="space-y-2"> <blockquote className="space-y-2">
<p className="text-lg"> <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> </p>
<footer className="text-sm">Sofia Davis</footer> <footer className="text-sm">{testimonialAuthor}</footer>
</blockquote> </blockquote>
</div> </div>
</div> </div>

View File

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

View File

@@ -3,6 +3,7 @@
import * as React from "react" import * as React from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { toast } from "sonner" import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
@@ -18,6 +19,7 @@ import {
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { Loader2 } from "lucide-react" import { Loader2 } from "lucide-react"
import type { ActionState } from "@/shared/types/action-state" import type { ActionState } from "@/shared/types/action-state"
import type { RegisterResult } from "@/modules/auth/types"
const ADULT_AGE = 18 const ADULT_AGE = 18
@@ -35,10 +37,11 @@ function calcAge(birth: string): number | null {
} }
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & { type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
registerAction: (formData: FormData) => Promise<ActionState> registerAction: (formData: FormData) => Promise<ActionState<RegisterResult>>
} }
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) { export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
const t = useTranslations("auth.register")
const [isLoading, setIsLoading] = React.useState<boolean>(false) const [isLoading, setIsLoading] = React.useState<boolean>(false)
const [birthDate, setBirthDate] = React.useState<string>("") const [birthDate, setBirthDate] = React.useState<string>("")
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false) const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
@@ -53,15 +56,15 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
event.preventDefault() event.preventDefault()
if (!agreedTerms) { if (!agreedTerms) {
toast.error("请阅读并同意《隐私政策》和《用户协议》后再注册") toast.error(t("toast.needAgreeTerms"))
return return
} }
if (isMinor && !agreedGuardian) { if (isMinor && !agreedGuardian) {
toast.error("未成年人注册须确认已获得监护人同意") toast.error(t("toast.needGuardianConsent"))
return return
} }
if (isMinor && !guardianRelation) { if (isMinor && !guardianRelation) {
toast.error("请选择监护人与您的关系") toast.error(t("toast.needGuardianRelation"))
return return
} }
@@ -72,14 +75,14 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
const res = await registerAction(formData) const res = await registerAction(formData)
if (res.success) { if (res.success) {
toast.success(res.message || "账户创建成功") toast.success(res.message || t("toast.createSuccess"))
router.push("/login") router.push("/login")
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || "注册失败") toast.error(res.message || t("toast.createFailed"))
} }
} catch { } catch {
toast.error("注册失败") toast.error(t("toast.createFailed"))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@@ -88,19 +91,19 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
return ( return (
<div className={cn("grid gap-6", className)} {...props}> <div className={cn("grid gap-6", className)} {...props}>
<div className="flex flex-col space-y-2 text-center"> <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"> <p className="text-sm text-muted-foreground">
{t("subtitleAlt")}
</p> </p>
</div> </div>
<form onSubmit={onSubmit}> <form onSubmit={onSubmit}>
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="name"></Label> <Label htmlFor="name">{t("name")}</Label>
<Input <Input
id="name" id="name"
name="name" name="name"
placeholder="请输入姓名" placeholder={t("namePlaceholder")}
type="text" type="text"
autoCapitalize="words" autoCapitalize="words"
autoComplete="name" autoComplete="name"
@@ -109,7 +112,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="email"></Label> <Label htmlFor="email">{t("email")}</Label>
<Input <Input
id="email" id="email"
name="email" name="email"
@@ -122,7 +125,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="password"></Label> <Label htmlFor="password">{t("password")}</Label>
<Input <Input
id="password" id="password"
name="password" name="password"
@@ -132,7 +135,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="birthDate"></Label> <Label htmlFor="birthDate">{t("birthDate")}</Label>
<Input <Input
id="birthDate" id="birthDate"
name="birthDate" name="birthDate"
@@ -142,47 +145,62 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
onChange={(e) => setBirthDate(e.target.value)} onChange={(e) => setBirthDate(e.target.value)}
/> />
{age !== null && ( {age !== null && (
<p className="text-xs text-muted-foreground">{age} </p> <p className="text-xs text-muted-foreground">{t("currentAge", { age })}</p>
)} )}
</div> </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 && ( {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"> <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"> <p className="text-sm font-medium text-amber-900 dark:text-amber-200">
{t("guardianSectionTitle")}
</p> </p>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="guardianName"></Label> <Label htmlFor="guardianName">{t("guardianName")}</Label>
<Input <Input
id="guardianName" id="guardianName"
name="guardianName" name="guardianName"
placeholder="请输入监护人姓名" placeholder={t("guardianNamePlaceholder")}
type="text" type="text"
disabled={isLoading} disabled={isLoading}
required={isMinor} required={isMinor}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="guardianPhone"></Label> <Label htmlFor="guardianPhone">{t("guardianPhone")}</Label>
<Input <Input
id="guardianPhone" id="guardianPhone"
name="guardianPhone" name="guardianPhone"
placeholder="请输入监护人手机号" placeholder={t("guardianPhonePlaceholder")}
type="tel" type="tel"
disabled={isLoading} disabled={isLoading}
required={isMinor} required={isMinor}
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="guardianRelation"></Label> <Label htmlFor="guardianRelation">{t("guardianRelation")}</Label>
<Select value={guardianRelation} onValueChange={setGuardianRelation}> <Select value={guardianRelation} onValueChange={setGuardianRelation}>
<SelectTrigger id="guardianRelation"> <SelectTrigger id="guardianRelation">
<SelectValue placeholder="请选择关系" /> <SelectValue placeholder={t("guardianRelationPlaceholder")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="父亲"></SelectItem> <SelectItem value="父亲">{t("guardianRelationFather")}</SelectItem>
<SelectItem value="母亲"></SelectItem> <SelectItem value="母亲">{t("guardianRelationMother")}</SelectItem>
<SelectItem value="其他法定监护人"></SelectItem> <SelectItem value="其他法定监护人">{t("guardianRelationOther")}</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<input <input
@@ -201,25 +219,30 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
onCheckedChange={(v) => setAgreedTerms(v === true)} onCheckedChange={(v) => setAgreedTerms(v === true)}
disabled={isLoading} disabled={isLoading}
/> />
<input type="hidden" name="agreedTerms" value={agreedTerms ? "true" : "false"} />
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal"> <Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
{t.rich("agreeTermsRich", {
<Link privacy: (chunks) => (
href="/privacy" <Link
target="_blank" href="/privacy"
rel="noopener noreferrer" target="_blank"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80" rel="noopener noreferrer"
> className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
>
</Link> {chunks}
</Link>
<Link ),
href="/terms" terms: (chunks) => (
target="_blank" <Link
rel="noopener noreferrer" href="/terms"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80" target="_blank"
> rel="noopener noreferrer"
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
</Link> >
{chunks}
</Link>
),
})}
</Label> </Label>
</div> </div>
@@ -231,8 +254,9 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
onCheckedChange={(v) => setAgreedGuardian(v === true)} onCheckedChange={(v) => setAgreedGuardian(v === true)}
disabled={isLoading} disabled={isLoading}
/> />
<input type="hidden" name="agreedGuardian" value={agreedGuardian ? "true" : "false"} />
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal"> <Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
使 {t("agreeGuardianLabel")}
</Label> </Label>
</div> </div>
)} )}
@@ -241,17 +265,17 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
{isLoading && ( {isLoading && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" /> <Loader2 className="mr-2 h-4 w-4 animate-spin" />
)} )}
{t("createAccount")}
</Button> </Button>
</div> </div>
</form> </form>
<p className="px-8 text-center text-sm text-muted-foreground"> <p className="px-8 text-center text-sm text-muted-foreground">
{" "} {t("alreadyHaveAccount")}{" "}
<Link <Link
href="/login" href="/login"
className="underline underline-offset-4 hover:text-primary" className="underline underline-offset-4 hover:text-primary"
> >
{t("loginNow")}
</Link> </Link>
</p> </p>
</div> </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" "use client"
import { useMemo, useState } from "react" import { useTranslations } from "next-intl"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react" 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 { Button } from "@/shared/components/ui/button"
import { Checkbox } from "@/shared/components/ui/checkbox" import { Checkbox } from "@/shared/components/ui/checkbox"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { Input } from "@/shared/components/ui/input" import { Input } from "@/shared/components/ui/input"
import { import {
Select, Select,
@@ -15,13 +16,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/components/ui/select" } from "@/shared/components/ui/select"
import { Badge } from "@/shared/components/ui/badge" import { WidgetBoundary } from "@/shared/components/widget-boundary"
import { EmptyState } from "@/shared/components/ui/empty-state"
import { formatDate } from "@/shared/lib/utils" import { formatDate } from "@/shared/lib/utils"
import { formatFileSize } from "@/shared/lib/file-storage" import { formatFileSize } from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon" import { FileIcon } from "./file-icon"
import { FileUpload } from "./file-upload"
import { FilePreviewDialog } from "./file-preview-dialog" 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" import type { FileAttachment, FileStats } from "../types"
interface AdminFilesViewProps { interface AdminFilesViewProps {
@@ -29,174 +31,159 @@ interface AdminFilesViewProps {
stats: FileStats stats: FileStats
} }
// 文件类型分组选项 interface TypeOption {
const TYPE_OPTIONS: Array<{ value: string; label: string }> = [ value: string
{ value: "all", label: "All Types" }, labelKey:
{ value: "image/", label: "Images" }, | "allTypes"
{ value: "application/pdf", label: "PDF" }, | "images"
{ value: "application/msword", label: "Word" }, | "pdf"
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", label: "Word (docx)" }, | "word"
{ value: "application/vnd.ms-excel", label: "Excel" }, | "wordDocx"
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", label: "Excel (xlsx)" }, | "excel"
{ value: "application/vnd.ms-powerpoint", label: "PowerPoint" }, | "excelXlsx"
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", label: "PowerPoint (pptx)" }, | "powerpoint"
{ value: "text/", label: "Text" }, | "powerpointPptx"
{ value: "application/zip", label: "ZIP" }, | "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 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 renderTypeLabel = (key: TypeLabelKey): string => {
const filteredFiles = useMemo(() => { switch (key) {
return files.filter((f) => { case "allTypes": return t("filter.allTypes")
if (typeFilter !== "all") { case "images": return t("filter.images")
if (typeFilter.endsWith("/")) { case "pdf": return t("filter.pdf")
if (!f.mimeType.startsWith(typeFilter)) return false case "word": return t("filter.word")
} else if (f.mimeType !== typeFilter) { case "wordDocx": return t("filter.wordDocx")
return false case "excel": return t("filter.excel")
} case "excelXlsx": return t("filter.excelXlsx")
} case "powerpoint": return t("filter.powerpoint")
if (search.trim()) { case "powerpointPptx": return t("filter.powerpointPptx")
const kw = search.trim().toLowerCase() case "text": return t("filter.text")
if ( case "zip": return t("filter.zip")
!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 = () => {
if (allSelected) {
setSelectedIds(new Set())
} else {
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
} }
} }
const toggleOne = (id: string) => { const {
setSelectedIds((prev) => { selectedIds,
const next = new Set(prev) typeFilter,
if (next.has(id)) next.delete(id) search,
else next.add(id) deleting,
return next setTypeFilter,
}) setSearch,
} filteredFiles,
allSelected,
const handleUploaded = () => { someSelected,
router.refresh() toggleAll,
} toggleOne,
handleBatchDelete,
const handleDeleted = () => { } = useFileBatchOperations({
router.refresh() files,
setSelectedIds(new Set()) onAfterDelete: () => router.refresh(),
} })
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 ( return (
<div className="flex h-full flex-col space-y-6 p-8"> <div className="flex h-full flex-col space-y-6 p-8">
<div className="space-y-1"> <div className="space-y-1">
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight"> <h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Files className="h-6 w-6" /> <Files className="h-6 w-6" aria-hidden="true" />
Files {t("title")}
</h2> </h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">{t("subtitle")}</p>
Upload and manage all files in the system.
</p>
</div> </div>
{/* 统计卡片 */} <WidgetBoundary title={t("stats.totalFiles")} skeletonHeight={120}>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-md border p-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
</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
</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">
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<FileIcon mimeType={t.mimeType} className="h-3.5 w-3.5" /> <Files className="h-3.5 w-3.5" aria-hidden="true" />
<span className="truncate" title={t.mimeType}>{t.mimeType}</span> {t("stats.totalFiles")}
</div> </div>
<p className="mt-1 text-2xl font-bold">{t.count}</p> <p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
</div> </div>
))} <div className="rounded-md border p-4">
</div> <div className="flex items-center gap-2 text-xs text-muted-foreground">
<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((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={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">{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-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"> <div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<Select value={typeFilter} onValueChange={setTypeFilter}> <Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-full sm:w-[200px]"> <SelectTrigger className="w-full sm:w-[200px]">
<SelectValue placeholder="Filter by type" /> <SelectValue placeholder={t("filter.byType")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{TYPE_OPTIONS.map((opt) => ( {TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
{opt.label} {renderTypeLabel(opt.labelKey)}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<div className="relative flex-1"> <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 <Input
placeholder="Search by file name..." placeholder={t("filter.search")}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="pl-8" className="pl-8"
aria-label={t("filter.search")}
/> />
</div> </div>
</div> </div>
{selectedIds.size > 0 ? ( {selectedIds.size > 0 ? (
<div className="flex items-center gap-2"> <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 <Button
type="button" type="button"
variant="destructive" variant="destructive"
@@ -204,86 +191,91 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
disabled={deleting} disabled={deleting}
onClick={() => void handleBatchDelete()} onClick={() => void handleBatchDelete()}
> >
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
{deleting ? "Deleting..." : "Delete Selected"} {deleting ? t("selection.deleting") : t("selection.deleteSelected")}
</Button> </Button>
</div> </div>
) : null} ) : null}
</div> </div>
{/* 文件列表 */} <WidgetBoundary title={t("title")} skeletonHeight={400}>
{filteredFiles.length === 0 ? ( {filteredFiles.length === 0 ? (
<EmptyState <EmptyState
title="No files found" title={t("empty.title")}
description="Try adjusting your filters or upload a new file." description={t("empty.description")}
icon={FileWarning} icon={FileWarning}
className="h-auto border-none shadow-none" className="h-auto border-none shadow-none"
/> />
) : ( ) : (
<div className="rounded-md border"> <div className="rounded-md border">
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground"> <div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
<Checkbox <Checkbox
checked={allSelected ? true : someSelected ? "indeterminate" : false} checked={allSelected ? true : someSelected ? "indeterminate" : false}
onCheckedChange={toggleAll} onCheckedChange={toggleAll}
aria-label="Select all" aria-label={t("columns.file")}
/> />
<span className="flex-1">File</span> <span className="flex-1">{t("columns.file")}</span>
<span className="hidden w-24 sm:block">Size</span> <span className="hidden w-24 sm:block">{t("columns.size")}</span>
<span className="hidden w-32 md:block">Type</span> <span className="hidden w-32 md:block">{t("columns.type")}</span>
<span className="hidden w-32 md:block">Uploaded</span> <span className="hidden w-32 md:block">{t("columns.uploaded")}</span>
<span className="w-24 text-right">Actions</span> <span className="w-24 text-right">{t("columns.actions")}</span>
</div> </div>
<ul className="divide-y"> <ul className="divide-y">
{filteredFiles.map((file) => { {filteredFiles.map((file) => {
const checked = selectedIds.has(file.id) const checked = selectedIds.has(file.id)
return ( return (
<li <li
key={file.id} key={file.id}
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40" className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
> >
<Checkbox <Checkbox
checked={checked} checked={checked}
onCheckedChange={() => toggleOne(file.id)} onCheckedChange={() => toggleOne(file.id)}
aria-label={`Select ${file.originalName}`} aria-label={`Select ${file.originalName}`}
/>
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
<div className="min-w-0 flex-1">
<a
href={file.url ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="truncate text-sm font-medium hover:underline"
title={file.originalName}
>
{file.originalName}
</a>
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
</p>
</div>
<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}>
{file.mimeType}
</span>
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
{formatDate(file.createdAt, "zh-CN")}
</span>
<div className="flex w-24 shrink-0 justify-end gap-1">
<FilePreviewDialog
file={file}
triggerLabel=""
triggerVariant="ghost"
triggerSize="icon"
/> />
</div> <FileIcon mimeType={file.mimeType} className="h-6 w-6" />
</li> <div className="min-w-0 flex-1">
) <a
})} href={file.url ?? "#"}
</ul> target="_blank"
</div> rel="noopener noreferrer"
)} className="truncate text-sm font-medium hover:underline"
title={file.originalName}
aria-label={`${t("columns.file")}: ${file.originalName}`}
>
{file.originalName}
</a>
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
</p>
</div>
<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}
>
{file.mimeType}
</span>
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
{formatDate(file.createdAt, "zh-CN")}
</span>
<div className="flex w-24 shrink-0 justify-end gap-1">
<FilePreviewDialog
file={file}
triggerLabel=""
triggerVariant="ghost"
triggerSize="icon"
/>
</div>
</li>
)
})}
</ul>
</div>
)}
</WidgetBoundary>
</div> </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" "use client"
import * as React from "react" import * as React from "react"
import { useTranslations } from "next-intl"
import { Eye } from "lucide-react" import { Eye } from "lucide-react"
import { import {
@@ -26,17 +27,26 @@ interface FilePreviewDialogProps {
export function FilePreviewDialog({ export function FilePreviewDialog({
file, file,
trigger, trigger,
triggerLabel = "Preview", triggerLabel,
triggerVariant = "outline", triggerVariant = "outline",
triggerSize = "sm", triggerSize = "sm",
}: FilePreviewDialogProps) { }: FilePreviewDialogProps): React.ReactElement {
const t = useTranslations("files.preview")
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger ?? ( {trigger ?? (
<Button type="button" variant={triggerVariant} size={triggerSize}> <Button type="button" variant={triggerVariant} size={triggerSize}>
<Eye className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"} /> <Eye
{triggerLabel ? <span>{triggerLabel}</span> : <span className="sr-only">Preview</span>} 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> </Button>
)} )}
</DialogTrigger> </DialogTrigger>
@@ -44,7 +54,7 @@ export function FilePreviewDialog({
<DialogHeader> <DialogHeader>
<DialogTitle className="truncate">{file.originalName}</DialogTitle> <DialogTitle className="truncate">{file.originalName}</DialogTitle>
<DialogDescription> <DialogDescription>
File preview · {file.mimeType} {t("title")} · {file.mimeType}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[75vh] overflow-auto"> <div className="max-h-[75vh] overflow-auto">

View File

@@ -1,11 +1,12 @@
"use client" "use client"
import { useState } from "react" import { useTranslations } from "next-intl"
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react" import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { FileIcon } from "./file-icon" import { FileIcon } from "./file-icon"
import { formatFileSize } from "@/shared/lib/file-storage" import { formatFileSize } from "@/shared/lib/file-storage"
import { useFilePreview, useImageZoom } from "../hooks/use-file-preview"
import type { FileAttachment } from "../types" import type { FileAttachment } from "../types"
interface FilePreviewProps { interface FilePreviewProps {
@@ -42,7 +43,8 @@ function classify(mimeType: string): PreviewKind {
return "other" 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 kind = classify(file.mimeType)
const url = file.url ?? "#" const url = file.url ?? "#"
@@ -66,9 +68,10 @@ export function FilePreview({ file, className }: FilePreviewProps) {
download={file.originalName} download={file.originalName}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
aria-label={`${t("download")} ${file.originalName}`}
> >
<Download className="mr-2 h-4 w-4" /> <Download className="mr-2 h-4 w-4" aria-hidden="true" />
Download {t("download")}
</a> </a>
</Button> </Button>
</div> </div>
@@ -86,7 +89,7 @@ function PreviewBody({
kind: PreviewKind kind: PreviewKind
file: FileAttachment file: FileAttachment
url: string url: string
}) { }): React.ReactElement {
if (kind === "image") { if (kind === "image") {
return <ImagePreview url={url} alt={file.originalName} /> return <ImagePreview url={url} alt={file.originalName} />
} }
@@ -96,6 +99,7 @@ function PreviewBody({
<iframe <iframe
src={url} src={url}
title={file.originalName} title={file.originalName}
aria-label={`PDF preview: ${file.originalName}`}
className="h-[70vh] w-full rounded-md border" className="h-[70vh] w-full rounded-md border"
/> />
) )
@@ -105,35 +109,12 @@ function PreviewBody({
return <TextPreview url={url} /> return <TextPreview url={url} />
} }
// Office / other: show info card + download button return <OtherPreview kind={kind} file={file} url={url} />
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>
)
} }
function ImagePreview({ url, alt }: { url: string; alt: string }) { function ImagePreview({ url, alt }: { url: string; alt: string }): React.ReactElement {
const [zoom, setZoom] = useState(1) const t = useTranslations("files.preview")
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useImageZoom()
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -143,13 +124,13 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))} onClick={zoomOut}
disabled={zoom <= 0.25} disabled={!canZoomOut}
aria-label={t("zoomOut")}
> >
<ZoomOut className="h-4 w-4" /> <ZoomOut className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Zoom out</span>
</Button> </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)}% {Math.round(zoom * 100)}%
</span> </span>
<Button <Button
@@ -157,14 +138,17 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
variant="outline" variant="outline"
size="icon" size="icon"
className="h-8 w-8" className="h-8 w-8"
onClick={() => setZoom((z) => Math.min(4, z + 0.25))} onClick={zoomIn}
disabled={zoom >= 4} disabled={!canZoomIn}
aria-label={t("zoomIn")}
> >
<ZoomIn className="h-4 w-4" /> <ZoomIn className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">Zoom in</span>
</Button> </Button>
</div> </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 */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={url} src={url}
@@ -177,34 +161,23 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
) )
} }
function TextPreview({ url }: { url: string }) { function TextPreview({ url }: { url: string }): React.ReactElement {
const [content, setContent] = useState<string | null>(null) const t = useTranslations("files.preview.text")
const [error, setError] = useState<string | null>(null) const { content, error, loading, load } = useFilePreview()
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)
}
}
if (content === null && !error && !loading) { if (content === null && !error && !loading) {
return ( return (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center"> <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" /> <FileText className="h-12 w-12 text-muted-foreground" aria-hidden="true" />
<p className="mt-3 text-sm font-medium">Text file</p> <p className="mt-3 text-sm font-medium">{t("title")}</p>
<p className="mt-1 text-xs text-muted-foreground">Click below to load the content.</p> <p className="mt-1 text-xs text-muted-foreground">{t("hint")}</p>
<Button variant="outline" size="sm" className="mt-4" onClick={() => void load()}> <Button
Load preview variant="outline"
size="sm"
className="mt-4"
onClick={() => void load(url)}
>
{t("load")}
</Button> </Button>
</div> </div>
) )
@@ -212,16 +185,29 @@ function TextPreview({ url }: { url: string }) {
if (loading) { if (loading) {
return ( return (
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"> <div
Loading... role="status"
className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"
>
{t("loading")}
</div> </div>
) )
} }
if (error) { if (error) {
return ( return (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"> <div
Failed to load text: {error} 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> </div>
) )
} }
@@ -232,3 +218,38 @@ function TextPreview({ url }: { url: string }) {
</pre> </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" "use client"
import { useCallback, useRef, useState } from "react" import { useTranslations } from "next-intl"
import { UploadCloud, X } from "lucide-react" import { UploadCloud, X } from "lucide-react"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Progress } from "@/shared/components/ui/progress" import { Progress } from "@/shared/components/ui/progress"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { import { formatFileSize } from "@/shared/lib/file-storage"
ALLOWED_MIME_TYPES,
formatFileSize,
MAX_FILE_SIZE,
} from "@/shared/lib/file-storage"
import { FileIcon } from "./file-icon" import { FileIcon } from "./file-icon"
import { useFileUpload } from "../hooks/use-file-upload"
import type { FileTargetType, FileUploadResult } from "../types" import type { FileTargetType, FileUploadResult } from "../types"
interface FileUploadProps { interface FileUploadProps {
@@ -23,146 +20,31 @@ interface FileUploadProps {
className?: string 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({ export function FileUpload({
targetType, targetType,
targetId, targetId,
onUploaded, onUploaded,
multiple = true, multiple = true,
className, className,
}: FileUploadProps) { }: FileUploadProps): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null) const t = useTranslations("files.upload")
const [isDragging, setIsDragging] = useState(false) const {
const [tasks, setTasks] = useState<UploadTask[]>([]) tasks,
isDragging,
const validateFile = (file: File): string | null => { inputRef,
if (file.size === 0) return "File is empty" setIsDragging,
if (file.size > MAX_FILE_SIZE) return "File size exceeds 10MB limit" handleFiles,
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) { removeTask,
return `File type ${file.type || "unknown"} is not allowed` acceptAttr,
} maxFileSize,
return null } = useFileUpload({ targetType, targetId, multiple, onUploaded })
}
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))
}
return ( return (
<div className={cn("space-y-4", className)}> <div className={cn("space-y-4", className)}>
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
aria-describedby="file-upload-hint"
onClick={() => inputRef.current?.click()} onClick={() => inputRef.current?.click()}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") { if (e.key === "Enter" || e.key === " ") {
@@ -175,7 +57,11 @@ export function FileUpload({
setIsDragging(true) setIsDragging(true)
}} }}
onDragLeave={() => setIsDragging(false)} onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop} onDrop={(e) => {
e.preventDefault()
setIsDragging(false)
handleFiles(e.dataTransfer.files)
}}
className={cn( className={cn(
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors", "flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
isDragging isDragging
@@ -183,19 +69,18 @@ export function FileUpload({
: "border-input hover:border-primary/50 hover:bg-accent/50" : "border-input hover:border-primary/50 hover:bg-accent/50"
)} )}
> >
<UploadCloud className="h-10 w-10 text-muted-foreground" /> <UploadCloud className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
<p className="mt-2 text-sm font-medium"> <p className="mt-2 text-sm font-medium">{t("title")}</p>
Click to upload or drag and drop <p id="file-upload-hint" className="mt-1 text-xs text-muted-foreground">
</p> {t("hint", { size: formatFileSize(maxFileSize) })}
<p className="mt-1 text-xs text-muted-foreground">
Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {formatFileSize(MAX_FILE_SIZE)}
</p> </p>
<input <input
ref={inputRef} ref={inputRef}
type="file" type="file"
className="hidden" className="hidden"
accept={ACCEPT_ATTR} accept={acceptAttr}
multiple={multiple} multiple={multiple}
aria-label={t("title")}
onChange={(e) => { onChange={(e) => {
handleFiles(e.target.files) handleFiles(e.target.files)
e.target.value = "" e.target.value = ""
@@ -204,7 +89,7 @@ export function FileUpload({
</div> </div>
{tasks.length > 0 ? ( {tasks.length > 0 ? (
<ul className="space-y-2"> <ul className="space-y-2" aria-live="polite">
{tasks.map((task, idx) => ( {tasks.map((task, idx) => (
<li <li
key={`${task.file.name}-${idx}`} key={`${task.file.name}-${idx}`}
@@ -221,13 +106,17 @@ export function FileUpload({
</span> </span>
</div> </div>
{task.status === "uploading" ? ( {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} ) : null}
{task.status === "error" ? ( {task.status === "error" ? (
<p className="text-xs text-destructive">{task.message}</p> <p className="text-xs text-destructive">{task.message}</p>
) : null} ) : null}
{task.status === "success" ? ( {task.status === "success" ? (
<p className="text-xs text-green-600">Uploaded</p> <p className="text-xs text-green-600">{t("uploaded")}</p>
) : null} ) : null}
</div> </div>
<Button <Button
@@ -236,9 +125,9 @@ export function FileUpload({
size="icon" size="icon"
className="h-7 w-7" className="h-7 w-7"
onClick={() => removeTask(task)} 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> </Button>
</li> </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 表示) // 文件附件记录DB 行的 TypeScript 表示)
export interface FileAttachment { export interface FileAttachment {

View File

@@ -30,7 +30,12 @@ import {
getUnreadNotificationCount, getUnreadNotificationCount,
archiveNotification, archiveNotification,
} from "./data-access" } 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 入参) * Zod 校验通知负载sendNotificationAction 入参)
@@ -298,3 +303,81 @@ export async function archiveNotificationAction(
return { success: false, message: "Unexpected error" } 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 accessKeySecret: string | undefined
signName: string | undefined signName: string | undefined
templateCode: string | undefined templateCode: string | undefined
smsSdkAppId: string | undefined
} { } {
const rawProvider = process.env.SMS_PROVIDER ?? "mock" const rawProvider = process.env.SMS_PROVIDER ?? "mock"
return { return {
@@ -46,6 +47,7 @@ function getSmsConfig(): {
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET, accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
signName: process.env.SMS_SIGN_NAME, signName: process.env.SMS_SIGN_NAME,
templateCode: process.env.SMS_TEMPLATE_CODE, 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 params = buildTemplateParams(payload)
const response = await client.SendSms({ const response = await client.SendSms({
PhoneNumberSet: [`+86${recipient.phone}`], PhoneNumberSet: [`+86${recipient.phone}`],
SmsSdkAppId: this.config.templateCode ?? "", SmsSdkAppId: this.config.smsSdkAppId ?? "",
SignName: this.config.signName ?? "", SignName: this.config.signName ?? "",
TemplateId: this.config.templateCode ?? "", TemplateId: this.config.templateCode ?? "",
TemplateParamSet: [params.title, params.content], TemplateParamSet: [params.title, params.content],

View File

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

View File

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

View File

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

View File

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

View File

@@ -48,7 +48,11 @@ export {
markNotificationAsReadAction, markNotificationAsReadAction,
markAllNotificationsAsReadAction, markAllNotificationsAsReadAction,
archiveNotificationAction, archiveNotificationAction,
getNotificationPreferencesAction,
updateNotificationPreferencesAction,
} from "./actions" } from "./actions"
export { UpdateNotificationPreferencesSchema } from "./schema"
export type { UpdateNotificationPreferencesFormInput } from "./schema"
export { NotificationList, NotificationDropdown } from "./components" export { NotificationList, NotificationDropdown } from "./components"
export type { export type {
NotificationChannel, 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" export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
/** 站内通知类型message_notifications.type 列) */ /** 站内通知类型message_notifications.type 列) */
export type NotificationType = "message" | "announcement" | "homework" | "grade" export type NotificationType = "message" | "announcement" | "homework" | "grade" | "diagnostic"
/** 通知优先级message_notifications.priority 列) */ /** 通知优先级message_notifications.priority 列) */
export type NotificationPriority = "low" | "normal" | "high" | "urgent" 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 type { ActionState } from "@/shared/types/action-state"
import { requireAuth } from "@/shared/lib/auth-guard" import { requireAuth } from "@/shared/lib/auth-guard"
import { logAudit } from "@/shared/lib/audit-logger" 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 { db } from "@/shared/db"
import { users } from "@/shared/db/schema" import { users } from "@/shared/db/schema"
import { import {
@@ -18,7 +24,6 @@ import {
getOnboardingStatus, getOnboardingStatus,
updateUserProfile, updateUserProfile,
bindParentToChild, bindParentToChild,
resolveDefaultPathByRoles,
} from "./data-access" } from "./data-access"
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types" import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
@@ -74,7 +79,7 @@ export async function completeOnboardingAction(
const roleNames = ctx.roles const roleNames = ctx.roles
return { return {
success: true, 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) (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班级码/子女绑定失败不回滚整个事务 // 收集局部失败项P1-2班级码/子女绑定失败不回滚整个事务
const failures: OnboardingFailureItem[] = [] const failures: OnboardingFailureItem[] = []
@@ -190,7 +225,7 @@ export async function completeOnboardingAction(
.set({ onboardedAt: new Date() }) .set({ onboardedAt: new Date() })
.where(eq(users.id, userId)) .where(eq(users.id, userId))
return { defaultPath: resolveDefaultPathByRoles(normalizedRoles) } return { defaultPath: resolveDefaultPath(normalizedRoles) }
}) })
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross // P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross

View File

@@ -1,10 +1,6 @@
"use client" "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 { useTranslations } from "next-intl"
import { toast } from "sonner"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Input } from "@/shared/components/ui/input" 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 { Textarea } from "@/shared/components/ui/textarea"
import { Checkbox } from "@/shared/components/ui/checkbox" import { Checkbox } from "@/shared/components/ui/checkbox"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types" import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
import { completeOnboardingAction } from "@/modules/onboarding/actions"
import type { OnboardingStatus } from "@/modules/onboarding/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 { interface OnboardingStepperProps {
initialStatus: OnboardingStatus initialStatus: OnboardingStatus
@@ -22,169 +19,56 @@ interface OnboardingStepperProps {
/** /**
* v3 i18n所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。 * 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) { export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
const router = useRouter()
const searchParams = useSearchParams()
const { update } = useSession()
const t = useTranslations("onboarding") const t = useTranslations("onboarding")
const tCommon = useTranslations("common.actions") const tCommon = useTranslations("common.actions")
// P1-1URL query 参数持久化当前步骤 const form = useOnboardingForm(initialStatus)
const initialStep = clampStep(Number(searchParams.get("step") ?? "0")) const {
const [step, setStep] = React.useState(initialStep) step,
const [isSubmitting, setIsSubmitting] = React.useState(false) 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 ?? "") // audit-P1-13标题/描述/内容均按 currentStepId 渲染,不再依赖数字下标。
const [phone, setPhone] = React.useState("") const title = t(`steps.${currentStepId}`)
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]}`)
const description = const description =
step === 0 currentStepId === "roleConfirm"
? t("role.adminAssigned") ? t("role.adminAssigned")
: step === 1 : currentStepId === "basicInfo"
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address") ? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
: step === 2 : currentStepId === "roleInfo"
? isParent ? isParent
? t("parent.bindHint") ? t("parent.bindHint")
: t("steps.roleInfo") : t("steps.roleInfo")
@@ -216,8 +100,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
))} ))}
</div> </div>
{/* Step 0: 角色确认(只读) */} {/* Step: 角色确认(只读) */}
{step === 0 ? ( {currentStepId === "roleConfirm" ? (
<div className="grid gap-2"> <div className="grid gap-2">
<Label>{t("role.yourRole")}</Label> <Label>{t("role.yourRole")}</Label>
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm"> <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> </div>
) : null} ) : null}
{/* Step 1: 基础信息 */} {/* Step: 基础信息 */}
{step === 1 ? ( {currentStepId === "basicInfo" ? (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="onb_name">{t("form.name")} *</Label> <Label htmlFor="onb_name">{t("form.name")} *</Label>
@@ -268,8 +152,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
</div> </div>
) : null} ) : null}
{/* Step 2: 角色信息 */} {/* Step: 角色信息 */}
{step === 2 ? ( {currentStepId === "roleInfo" ? (
<div className="grid gap-4"> <div className="grid gap-4">
{isTeacher ? ( {isTeacher ? (
<> <>
@@ -315,91 +199,19 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
) : null} ) : null}
{isParent ? ( {isParent ? (
<div className="grid gap-4"> <ParentChildrenForm
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground"> childRows={children}
{t("parent.bindHint")} isSubmitting={isSubmitting}
</div> onUpdate={updateChildRow}
{children.map((row, idx) => ( onAdd={addChildRow}
<div key={idx} className="grid gap-3 rounded-md border p-3"> onRemove={removeChildRow}
<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"
/>
</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} ) : null}
</div> </div>
) : null} ) : null}
{/* Step 3: 完成 */} {/* Step: 完成 */}
{step === (isAdmin ? 2 : 3) ? ( {currentStepId === "complete" ? (
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm"> <div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
<div className="font-medium">{t("complete.ready")}</div> <div className="font-medium">{t("complete.ready")}</div>
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div> <div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
@@ -417,7 +229,7 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
> >
{tCommon("previous")} {tCommon("previous")}
</Button> </Button>
{step === 2 && canSkipStep2 ? ( {canSkip ? (
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
@@ -444,8 +256,3 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
</div> </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, parentStudentRelations,
} from "@/shared/db/schema" } from "@/shared/db/schema"
import type { Role } from "@/shared/types/permissions" 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" import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } from "./types"
/** /**
@@ -125,15 +125,3 @@ export async function bindParentToChild(
return { studentId: child.id } 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" 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 步骤中展示给用户的角色信息。 * Onboarding 步骤中展示给用户的角色信息。
* 角色来源于 usersToRoles管理员预分配用户不可修改。 * 角色来源于 usersToRoles管理员预分配用户不可修改。
*/ */
export type OnboardingRoleInfo = { export type OnboardingRoleInfo = {
/** 规范化后的主角色admin/teacher/student/parent */ /** 规范化后的主角色admin/grade_head/teaching_head/teacher/student/parent */
primary: "admin" | "teacher" | "student" | "parent" primary: NormalizedRole
/** 用户拥有的全部角色名(含 grade_head/teaching_head 等) */ /** 用户拥有的全部角色名(含 grade_head/teaching_head 等) */
all: Role[] all: Role[]
} }

View File

@@ -9,6 +9,7 @@ import {
Mail, Mail,
Stethoscope, Stethoscope,
} from "lucide-react" } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs" 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 VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
const isTab = (v: string | undefined | null): v is ChildDetailTab => 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 => const resolveTab = (v: string | undefined | null): ChildDetailTab =>
isTab(v) ? v : "overview" isTab(v) ? v : "overview"
@@ -40,40 +41,47 @@ export function ChildDetailPanel({
initialTab?: string initialTab?: string
siblingSwitcher?: React.ReactNode siblingSwitcher?: React.ReactNode
}) { }) {
const t = useTranslations("parent")
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child 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 [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
const tabs = useMemo( const tabs = useMemo(
() => [ () => [
{ id: "overview" as const, label: "Overview", icon: ClipboardList }, { id: "overview" as const, label: t("childDetail.tabs.overview"), icon: ClipboardList },
{ id: "homework" as const, label: "Homework", icon: ClipboardList }, { id: "homework" as const, label: t("childDetail.tabs.homework"), icon: ClipboardList },
{ id: "grades" as const, label: "Grades", icon: BarChart3 }, { id: "grades" as const, label: t("childDetail.tabs.grades"), icon: BarChart3 },
{ id: "exams" as const, label: "Exams", icon: GraduationCap }, { id: "exams" as const, label: t("childDetail.tabs.exams"), icon: GraduationCap },
{ id: "schedule" as const, label: "Schedule", icon: CalendarDays }, { id: "schedule" as const, label: t("childDetail.tabs.schedule"), icon: CalendarDays },
{ id: "attendance" as const, label: "Attendance", icon: CalendarDays }, { id: "attendance" as const, label: t("childDetail.tabs.attendance"), icon: CalendarDays },
{ id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope }, { id: "diagnostic" as const, label: t("childDetail.tabs.diagnostic"), icon: Stethoscope },
], ],
[], [t],
) )
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{siblingSwitcher} {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"> <div className="overflow-x-auto">
<TabsList className="w-full justify-start"> <TabsList className="w-full justify-start">
{tabs.map((t) => ( {tabs.map((tab) => (
<TabsTrigger <TabsTrigger
key={t.id} key={tab.id}
value={t.id} value={tab.id}
className="gap-1.5" 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" /> <tab.icon className="h-3.5 w-3.5" />
{t.label} {tab.label}
</TabsTrigger> </TabsTrigger>
))} ))}
</TabsList> </TabsList>
@@ -108,7 +116,7 @@ export function ChildDetailPanel({
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} /> <ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
<div> <div>
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3"> <h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
Subject Analysis {t("childDetail.subjectAnalysis")}
</h3> </h3>
<ChildGradeDetail grades={gradeTrend} /> <ChildGradeDetail grades={gradeTrend} />
</div> </div>
@@ -134,14 +142,16 @@ export function ChildDetailPanel({
<TabsContent value="attendance" className="mt-6"> <TabsContent value="attendance" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center"> <div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Attendance details are available on the{" "} {t.rich("childDetail.attendanceHint", {
<a link: (chunks) => (
href="/parent/attendance" <a
className="font-medium text-foreground underline-offset-4 hover:underline" href="/parent/attendance"
> className="font-medium text-foreground underline-offset-4 hover:underline"
Attendance page >
</a> {chunks}
. </a>
),
})}
</p> </p>
</div> </div>
</TabsContent> </TabsContent>
@@ -149,7 +159,7 @@ export function ChildDetailPanel({
<TabsContent value="diagnostic" className="mt-6"> <TabsContent value="diagnostic" className="mt-6">
<div className="rounded-md border bg-muted/30 p-6 text-center"> <div className="rounded-md border bg-muted/30 p-6 text-center">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Diagnostic reports will be available here once published by the school. {t("childDetail.diagnosticHint")}
</p> </p>
</div> </div>
</TabsContent> </TabsContent>
@@ -157,9 +167,12 @@ export function ChildDetailPanel({
<div className="flex justify-end"> <div className="flex justify-end">
<Button asChild variant="ghost" size="sm" className="gap-2"> <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" /> <Mail className="h-4 w-4" />
Contact Teacher {t("childDetail.contactTeacher")}
</a> </a>
</Button> </Button>
</div> </div>
@@ -175,21 +188,24 @@ export function SiblingSwitcher({
current: { id: string; name: string | null } current: { id: string; name: string | null }
siblings: Array<{ id: string; name: string | null }> siblings: Array<{ id: string; name: string | null }>
}) { }) {
const t = useTranslations("parent")
if (siblings.length <= 1) return null if (siblings.length <= 1) return null
return ( return (
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2"> <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"> <div className="flex flex-wrap gap-1">
{siblings.map((s) => { {siblings.map((s) => {
const isActive = s.id === current.id const isActive = s.id === current.id
const label = s.name ?? "Child" const label = s.name ?? t("childDetail.defaultName")
return ( return (
<a <a
key={s.id} key={s.id}
href={`/parent/children/${s.id}`} href={`/parent/children/${s.id}`}
aria-current={isActive ? "page" : undefined} aria-current={isActive ? "page" : undefined}
aria-label={`View ${label}'s details`} aria-label={t("childDetail.viewDetailsAria", { name: label })}
className={cn( className={cn(
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors", "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", "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 type { JSX } from "react"
import Link from "next/link" 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 { import {
Card, Card,
CardContent, 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 { export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
const t = useTranslations("examHomework")
const hasResults = examResults.length > 0 const hasResults = examResults.length > 0
const examCount = examResults.length const examCount = examResults.length
@@ -60,33 +66,39 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
<Card> <Card>
<CardContent className="flex items-center gap-3 pt-6"> <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"> <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>
<div> <div>
<p className="text-2xl font-bold tabular-nums">{examCount}</p> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="flex items-center gap-3 pt-6"> <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"> <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>
<div> <div>
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card> <Card>
<CardContent className="flex items-center gap-3 pt-6"> <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"> <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>
<div> <div>
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p> <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> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -97,54 +109,60 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden /> <BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
{childName}&apos;s Exam Results {t("homework.parentExam.examResults", { name: childName })}
</CardTitle> </CardTitle>
<CardDescription>Recent exam scores and performance trends</CardDescription> <CardDescription>
{t("homework.parentExam.examResultsDescription")}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{!hasResults ? ( {!hasResults ? (
<EmptyState <EmptyState
icon={GraduationCap} icon={GraduationCap}
title="No exam results" title={t("homework.parentExam.noResults")}
description="Exam results will appear here once available." description={t("homework.parentExam.noResultsHint")}
className="border-none h-48" 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) => { {examResults.map((r) => {
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0 const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
const isPass = scoreRate >= 60 const isPass = scoreRate >= 60
return ( return (
<Link <li key={r.submissionId}>
key={r.submissionId} <Link
href={`/parent/children/${childId}?tab=grades`} 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" 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"
> >
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="font-medium text-sm truncate">{r.examTitle}</div> <div className="font-medium text-sm truncate">{r.examTitle}</div>
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0"> <Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
{isPass ? "Pass" : "Below 60%"} {isPass ? t("homework.parentExam.pass") : t("homework.parentExam.belowPass")}
</Badge> </Badge>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{r.submittedAt ? (
<span>{formatDate(r.submittedAt)}</span>
) : null}
<span aria-hidden="true"></span>
<span className="tabular-nums">
{r.score} / {r.maxScore}
</span>
</div>
<Progress value={scoreRate} className="h-1.5 mt-1" />
</div> </div>
<div className="flex items-center gap-2 text-xs text-muted-foreground"> <div className="flex items-center gap-2 shrink-0 ml-2">
{r.submittedAt ? ( <span className="text-sm font-semibold tabular-nums">
<span>{formatDate(r.submittedAt)}</span> {scoreRate.toFixed(0)}%
) : null}
<span aria-hidden="true"></span>
<span className="tabular-nums">
{r.score} / {r.maxScore}
</span> </span>
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
</div> </div>
<Progress value={scoreRate} className="h-1.5 mt-1" /> </Link>
</div> </li>
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
{scoreRate.toFixed(0)}%
</div>
</Link>
) )
})} })}
</div> </ul>
)} )}
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

@@ -1,7 +1,5 @@
"use client"
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react" 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 { Card } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils" 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,
}: { }: {
summaries: ParentStudentAttendanceSummary[] summaries: ParentStudentAttendanceSummary[]
}) { }) {
const t = useTranslations("attendance") const t = await getTranslations("attendance")
const stats = aggregateStats(summaries) const stats = aggregateStats(summaries)
if (stats.totalStudents === 0) return null if (stats.totalStudents === 0) return null

View File

@@ -1,7 +1,5 @@
"use client"
import { AlertTriangle, Phone } from "lucide-react" 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 { Card } from "@/shared/components/ui/card"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
@@ -14,8 +12,11 @@ type Warning = {
severity: "high" | "medium" severity: "high" | "medium"
} }
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */ /** 翻译函数类型(与 useTranslations / getTranslations 返回值兼容) */
type Translator = ReturnType<typeof useTranslations> 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,
}: { }: {
summaries: ParentStudentAttendanceSummary[] summaries: ParentStudentAttendanceSummary[]
}) { }) {
const t = useTranslations("attendance") const t = await getTranslations("attendance")
const warnings = buildWarnings(summaries, t) const warnings = buildWarnings(summaries, t)
if (warnings.length === 0) return null 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, getStudentSchedule,
} from "@/modules/classes/data-access" } from "@/modules/classes/data-access"
import { import {
getStudentDashboardGrades,
getStudentHomeworkAssignments, getStudentHomeworkAssignments,
getStudentExamResults, 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 { getStudentGradeSummary } from "@/modules/grades/data-access"
import { getGradeNameById } from "@/modules/school/data-access" import { getGradeNameById } from "@/modules/school/data-access"
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/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))) 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" | "late"
| "early_leave" | "early_leave"
| "excused" | "excused"
| "school_activity"
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */ /** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
export type ParentAttendanceListItem = { export type ParentAttendanceListItem = {
id: string id: string
date: string date: string
status: ParentAttendanceStatus status: ParentAttendanceStatus
reason: string | null
remark: string | null remark: string | null
} }