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:
@@ -3,9 +3,14 @@
|
||||
import { revalidatePath } from "next/cache"
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import { Permissions, type AuthContext } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { handleActionError } from "@/shared/lib/action-utils"
|
||||
import {
|
||||
getStudentActiveClassId,
|
||||
getStudentActiveGradeId,
|
||||
verifyTeacherOwnsClass,
|
||||
} from "@/modules/classes/data-access"
|
||||
|
||||
import {
|
||||
CreatePracticeSessionSchema,
|
||||
@@ -22,7 +27,98 @@ import {
|
||||
getPracticeSessions,
|
||||
getPracticeStats,
|
||||
} from "./data-access"
|
||||
import type { PracticeSessionDetail, PracticeSessionSummary, PracticeStats, PracticeSourceMeta } from "./types"
|
||||
import { parseSourceMeta } from "./lib/source-meta"
|
||||
import { practiceErrors } from "./lib/errors"
|
||||
import type {
|
||||
PracticeSessionDetail,
|
||||
PracticeSessionSummary,
|
||||
PracticeStats,
|
||||
} from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限辅助:dataScope 下 studentId 归属校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 根据 dataScope 解析并校验目标学生 ID。
|
||||
*
|
||||
* 行级权限校验(P0 安全修复):
|
||||
* - `all`:admin 可访问任意 studentId(不传则用 ctx.userId)
|
||||
* - `owned`:仅可访问 ctx.userId(学生本人)
|
||||
* - `class_members`:仅可访问 ctx.userId(学生本人)
|
||||
* - `children`:仅可访问 childrenIds 中的学生(家长)
|
||||
* - `class_taught`:校验学生属于教师所教班级(教师)
|
||||
* - `grade_managed`:校验学生属于年级主任所辖年级(年级主任)
|
||||
*
|
||||
* @throws {PermissionDeniedError} studentId 不在 dataScope 范围内
|
||||
* @returns 校验通过的目标 studentId
|
||||
*/
|
||||
async function resolveTargetStudentId(
|
||||
ctx: AuthContext,
|
||||
studentId?: string,
|
||||
): Promise<string> {
|
||||
// 未传 studentId:根据 scope 选取默认值
|
||||
if (!studentId || studentId === ctx.userId) {
|
||||
if (ctx.dataScope.type === "children") {
|
||||
// 家长默认查第一个孩子
|
||||
return ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
return ctx.userId
|
||||
}
|
||||
|
||||
// 传入了 studentId:按 scope 校验归属
|
||||
switch (ctx.dataScope.type) {
|
||||
case "all":
|
||||
// admin 无限制
|
||||
return studentId
|
||||
|
||||
case "owned":
|
||||
// 学生仅能访问自己
|
||||
if (studentId !== ctx.userId) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
|
||||
case "class_members":
|
||||
// 学生仅能访问自己
|
||||
if (studentId !== ctx.userId) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
|
||||
case "children":
|
||||
// 家长仅能访问自己的子女
|
||||
if (!ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
|
||||
case "class_taught": {
|
||||
// 教师:校验学生属于其所教班级
|
||||
const classId = await getStudentActiveClassId(studentId)
|
||||
if (!classId) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||
if (!owns) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
}
|
||||
|
||||
case "grade_managed": {
|
||||
// 年级主任:校验学生属于其所辖年级
|
||||
const gradeId = await getStudentActiveGradeId(studentId)
|
||||
if (!gradeId || !ctx.dataScope.gradeIds.includes(gradeId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
return studentId
|
||||
}
|
||||
|
||||
default:
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 查询 Actions
|
||||
@@ -33,25 +129,12 @@ export async function getPracticeSessionsAction(
|
||||
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
|
||||
let targetStudentId = ctx.userId
|
||||
if (studentId && studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
targetStudentId = studentId
|
||||
} else if (ctx.dataScope.type === "children") {
|
||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||
|
||||
const result = await getPracticeSessions(targetStudentId)
|
||||
return { success: true, data: result }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "获取练习列表失败"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,28 +144,16 @@ export async function getPracticeSessionDetailAction(
|
||||
): Promise<ActionState<PracticeSessionDetail>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
|
||||
let targetStudentId = ctx.userId
|
||||
if (studentId && studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
targetStudentId = studentId
|
||||
} else if (ctx.dataScope.type === "children") {
|
||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||
|
||||
const data = await getPracticeSessionById(sessionId, targetStudentId)
|
||||
if (!data) {
|
||||
return { success: false, message: "练习会话不存在或无权访问" }
|
||||
// 通过 errorCode 返回,前端 t(`errors.session_not_found`) 查 i18n
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "获取练习详情失败"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,25 +162,12 @@ export async function getPracticeStatsAction(
|
||||
): Promise<ActionState<PracticeStats>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
|
||||
let targetStudentId = ctx.userId
|
||||
if (studentId && studentId !== ctx.userId) {
|
||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||
}
|
||||
targetStudentId = studentId
|
||||
} else if (ctx.dataScope.type === "children") {
|
||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
}
|
||||
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||
|
||||
const data = await getPracticeStats(targetStudentId)
|
||||
return { success: true, data }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) {
|
||||
return { success: false, message: e.message }
|
||||
}
|
||||
const message = e instanceof Error ? e.message : "获取练习统计失败"
|
||||
return { success: false, message }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,7 +184,8 @@ export async function createPracticeSessionAction(
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误,需要 JSON 字段" }
|
||||
// errorCode: invalid_input → 前端 t(`errors.invalid_input`)
|
||||
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
|
||||
}
|
||||
|
||||
const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString))
|
||||
@@ -134,31 +193,38 @@ export async function createPracticeSessionAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
// 从 JSON 解析的 sourceMeta 需要经过 unknown 中间转换
|
||||
// 因为 Zod 的 z.record(z.string(), z.unknown()) 返回 Record<string, unknown>
|
||||
// 而实际运行时结构由前端按练习类型构建,此处做类型收窄
|
||||
const sourceMeta = parsed.data.sourceMeta as unknown as PracticeSourceMeta
|
||||
// 严格类型守卫:替代 `as unknown as PracticeSourceMeta` 断言
|
||||
const sourceMeta = parseSourceMeta(parsed.data.practiceType, parsed.data.sourceMeta)
|
||||
if (!sourceMeta) {
|
||||
return {
|
||||
success: false,
|
||||
message: "来源元数据结构与练习类型不匹配",
|
||||
errorCode: "invalid_source_meta",
|
||||
}
|
||||
}
|
||||
|
||||
const result = await createPracticeSession(ctx.userId, {
|
||||
// 写入操作仅允许学生本人或家长代子女发起
|
||||
// 家长 dataScope.type === "children" 时,使用第一个子女 ID
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
const result = await createPracticeSession(targetStudentId, {
|
||||
practiceType: parsed.data.practiceType,
|
||||
subjectId: parsed.data.subjectId,
|
||||
sourceMeta,
|
||||
questionCount: parsed.data.questionCount,
|
||||
})
|
||||
|
||||
if (result.selectedCount === 0) {
|
||||
return { success: false, message: "未找到符合条件的题目,请尝试其他筛选条件" }
|
||||
}
|
||||
|
||||
revalidatePath("/student/practice")
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `已创建练习会话,共 ${result.selectedCount} 道题目`,
|
||||
data: result,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -175,7 +241,7 @@ export async function submitPracticeAnswerAction(
|
||||
|
||||
const jsonString = formData.get("json")
|
||||
if (typeof jsonString !== "string") {
|
||||
return { success: false, message: "提交格式错误" }
|
||||
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
|
||||
}
|
||||
|
||||
const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString))
|
||||
@@ -183,15 +249,21 @@ export async function submitPracticeAnswerAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
const { sessionId, answerId, answer, skip } = parsed.data
|
||||
|
||||
// 学生本人或家长代子女提交
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
const result = await submitPracticeAnswer(
|
||||
sessionId,
|
||||
ctx.userId,
|
||||
targetStudentId,
|
||||
answerId,
|
||||
answer,
|
||||
skip ?? false,
|
||||
@@ -201,7 +273,6 @@ export async function submitPracticeAnswerAction(
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"),
|
||||
data: result,
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -223,16 +294,21 @@ export async function completePracticeSessionAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await completePracticeSession(parsed.data.sessionId, ctx.userId)
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
await completePracticeSession(parsed.data.sessionId, targetStudentId)
|
||||
|
||||
revalidatePath("/student/practice")
|
||||
revalidatePath(`/student/practice/${parsed.data.sessionId}`)
|
||||
|
||||
return { success: true, message: "练习已完成" }
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
@@ -252,15 +328,20 @@ export async function abandonPracticeSessionAction(
|
||||
return {
|
||||
success: false,
|
||||
message: "输入验证失败",
|
||||
errorCode: "validation_error",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
}
|
||||
}
|
||||
|
||||
await abandonPracticeSession(parsed.data.sessionId, ctx.userId)
|
||||
const targetStudentId = ctx.dataScope.type === "children"
|
||||
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||
: ctx.userId
|
||||
|
||||
await abandonPracticeSession(parsed.data.sessionId, targetStudentId)
|
||||
|
||||
revalidatePath("/student/practice")
|
||||
|
||||
return { success: true, message: "练习已放弃" }
|
||||
return { success: true }
|
||||
} catch (e) {
|
||||
return handleActionError(e)
|
||||
}
|
||||
|
||||
145
src/modules/adaptive-practice/components/answer-input.tsx
Normal file
145
src/modules/adaptive-practice/components/answer-input.tsx
Normal 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")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
76
src/modules/adaptive-practice/components/answer-result.tsx
Normal file
76
src/modules/adaptive-practice/components/answer-result.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,12 @@ import type { PracticeSessionSummary, PracticeStatus } from "../types"
|
||||
|
||||
interface PracticeHistoryProps {
|
||||
sessions: PracticeSessionSummary[]
|
||||
/**
|
||||
* 点击某条练习记录跳转的路由前缀。
|
||||
* - 学生端传 `/student/practice`,将跳转到 `/student/practice/{sessionId}`
|
||||
* - 家长端不传(无会话详情页),仅渲染为只读卡片
|
||||
*/
|
||||
routePrefix?: string
|
||||
}
|
||||
|
||||
const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline"> = {
|
||||
@@ -24,7 +30,10 @@ const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline
|
||||
/**
|
||||
* 专项练习历史列表
|
||||
*/
|
||||
export function PracticeHistory({ sessions }: PracticeHistoryProps): React.ReactNode {
|
||||
export function PracticeHistory({
|
||||
sessions,
|
||||
routePrefix,
|
||||
}: PracticeHistoryProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
if (sessions.length === 0) {
|
||||
@@ -48,8 +57,7 @@ export function PracticeHistory({ sessions }: PracticeHistoryProps): React.React
|
||||
? (session.answeredQuestions / session.totalQuestions) * 100
|
||||
: 0
|
||||
|
||||
return (
|
||||
<Link key={session.id} href={`/student/practice/${session.id}`}>
|
||||
const content = (
|
||||
<Card className="transition-colors hover:bg-muted/30">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -87,8 +95,17 @@ export function PracticeHistory({ sessions }: PracticeHistoryProps): React.React
|
||||
<Progress value={progress} className="mt-2 h-1" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
if (routePrefix) {
|
||||
return (
|
||||
<Link key={session.id} href={`${routePrefix}/${session.id}`}>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <div key={session.id}>{content}</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -3,16 +3,12 @@
|
||||
import { useState, useTransition, useMemo } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CheckCircle2, XCircle, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
|
||||
import { CheckCircle2, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -25,36 +21,55 @@ import {
|
||||
AlertDialogTrigger,
|
||||
} from "@/shared/components/ui/alert-dialog"
|
||||
|
||||
import { submitPracticeAnswerAction, completePracticeSessionAction, abandonPracticeSessionAction } from "../actions"
|
||||
import type { PracticeSessionDetail, PracticeAnswerRecord } from "../types"
|
||||
import { QuestionCard } from "./question-card"
|
||||
import { PracticeResultView } from "./practice-result-view"
|
||||
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
|
||||
import type { PracticeSessionDetail } from "../types"
|
||||
|
||||
interface PracticeSessionViewProps {
|
||||
session: PracticeSessionDetail
|
||||
/**
|
||||
* 放弃练习后的跳转回调(由页面层注入路由跳转逻辑)。
|
||||
*
|
||||
* 解耦设计:组件本身不硬编码路由,由调用方决定跳转目标。
|
||||
* 不传则使用默认行为 `router.push("/student/practice")`(向后兼容)。
|
||||
*/
|
||||
onAbandoned?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 专项练习答题界面
|
||||
* 专项练习答题界面(编排层)。
|
||||
*
|
||||
* 功能:
|
||||
* 1. 逐题作答,支持上一题/下一题导航
|
||||
* 2. 自动判分(选择题/判断题)
|
||||
* 3. 跳过题目
|
||||
* 4. 完成练习后展示结果摘要
|
||||
* 职责:
|
||||
* 1. 管理 UI 状态(当前题目索引、答案暂存、判分结果缓存)
|
||||
* 2. 通过 `usePracticeService()` 调用数据服务(不直接 import actions)
|
||||
* 3. 组合 QuestionCard / PracticeResultView 等子组件
|
||||
*
|
||||
* 子组件:
|
||||
* - QuestionCard: 题目卡片(含内容渲染、答题输入、结果展示)
|
||||
* - PracticeResultView: 完成后的结果视图
|
||||
*
|
||||
* 提交失败支持重试(通过 submitFailed + onRetry 传递给 QuestionCard)。
|
||||
*/
|
||||
export function PracticeSessionView({ session }: PracticeSessionViewProps): React.ReactNode {
|
||||
export function PracticeSessionView({
|
||||
session,
|
||||
onAbandoned,
|
||||
}: PracticeSessionViewProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
const router = useRouter()
|
||||
const service = usePracticeService()
|
||||
const analytics = usePracticeAnalytics()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [answers, setAnswers] = useState<Record<string, unknown>>({})
|
||||
const [results, setResults] = useState<Record<string, { isCorrect: boolean | null; score: number | null }>>({})
|
||||
const [failedAnswerIds, setFailedAnswerIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const answersList = session.answers
|
||||
const total = answersList.length
|
||||
const current = answersList[currentIndex]
|
||||
const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0
|
||||
|
||||
// 已答题数和正确数
|
||||
const answeredCount = useMemo(
|
||||
() => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length,
|
||||
[answersList],
|
||||
@@ -64,6 +79,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
[answersList],
|
||||
)
|
||||
|
||||
function resolveErrorMessage(
|
||||
errorCode: string | undefined,
|
||||
message: string | undefined,
|
||||
fallbackKey: string,
|
||||
): string {
|
||||
if (errorCode) {
|
||||
return t(`errors.${errorCode}`)
|
||||
}
|
||||
return message ?? t(fallbackKey)
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
return <PracticeResultView session={session} />
|
||||
}
|
||||
@@ -92,16 +118,25 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
skip,
|
||||
}),
|
||||
)
|
||||
const res = await submitPracticeAnswerAction(undefined, formData)
|
||||
const res = await service.submitAnswer(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
setResults((prev) => ({ ...prev, [current.id]: res.data! }))
|
||||
const data = res.data
|
||||
setResults((prev) => ({ ...prev, [current.id]: data }))
|
||||
setFailedAnswerIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(current.id)
|
||||
return next
|
||||
})
|
||||
analytics.trackAnswerSubmit(session.id, current.id, data.isCorrect)
|
||||
toast.success(res.message ?? t("toasts.submitted"))
|
||||
// 自动跳到下一题
|
||||
if (currentIndex < total - 1) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
}
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.submitFailed"))
|
||||
// 标记提交失败,显示重试按钮
|
||||
setFailedAnswerIds((prev) => new Set(prev).add(current.id))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -110,12 +145,13 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("sessionId", session.id)
|
||||
const res = await completePracticeSessionAction(undefined, formData)
|
||||
const res = await service.completeSession(undefined, formData)
|
||||
if (res.success) {
|
||||
analytics.trackSessionComplete(session.id, session.accuracy)
|
||||
toast.success(res.message ?? t("toasts.completed"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.completeFailed"))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -124,18 +160,32 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
startTransition(async () => {
|
||||
const formData = new FormData()
|
||||
formData.append("sessionId", session.id)
|
||||
const res = await abandonPracticeSessionAction(undefined, formData)
|
||||
const res = await service.abandonSession(undefined, formData)
|
||||
if (res.success) {
|
||||
analytics.trackSessionAbandon(session.id)
|
||||
toast.success(res.message ?? t("toasts.abandoned"))
|
||||
router.push("/student/practice")
|
||||
// 优先使用注入的回调,否则回退到默认路由
|
||||
if (onAbandoned) {
|
||||
onAbandoned()
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.abandonFailed"))
|
||||
router.push("/student/practice")
|
||||
}
|
||||
} else {
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function handleRetry(): void {
|
||||
if (current) {
|
||||
analytics.trackErrorRetry(current.id)
|
||||
}
|
||||
handleSubmit(answers[current?.id ?? ""] ?? null)
|
||||
}
|
||||
|
||||
const currentResult = current ? results[current.id] : undefined
|
||||
const isAnswered = current?.status === "answered" || current?.status === "skipped"
|
||||
const submitFailed = current ? failedAnswerIds.has(current.id) : false
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -143,18 +193,27 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">
|
||||
<CardTitle
|
||||
className="text-lg"
|
||||
aria-label={`${t("session.progress")}: ${currentIndex + 1} / ${total}`}
|
||||
>
|
||||
{t("session.progress")}: {currentIndex + 1} / {total}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
<span className="flex items-center gap-1" aria-label={`${t("result.correct")}: ${correctCount}`}>
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
|
||||
{correctCount}
|
||||
</span>
|
||||
<span>{answeredCount}/{total}</span>
|
||||
<span aria-label={`${t("result.answered")}: ${answeredCount}/${total}`}>
|
||||
{answeredCount}/{total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Progress value={progress} className="mt-2" />
|
||||
<Progress
|
||||
value={progress}
|
||||
className="mt-2"
|
||||
aria-label={`${t("session.progress")}: ${Math.round(progress)}%`}
|
||||
/>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
@@ -168,9 +227,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))}
|
||||
onSubmit={(ans) => handleSubmit(ans)}
|
||||
onSkip={() => handleSubmit(null, true)}
|
||||
onRetry={handleRetry}
|
||||
isAnswered={isAnswered}
|
||||
result={currentResult}
|
||||
isPending={isPending}
|
||||
submitFailed={submitFailed}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
@@ -180,16 +241,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
variant="outline"
|
||||
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
|
||||
disabled={currentIndex === 0}
|
||||
aria-label={t("session.previous")}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||
{t("session.previous")}
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Flag className="h-4 w-4" />
|
||||
<Button variant="ghost" size="sm" aria-label={t("session.abandon")}>
|
||||
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||
{t("session.abandon")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
@@ -200,7 +262,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleAbandon} disabled={isPending}>
|
||||
<AlertDialogAction
|
||||
onClick={handleAbandon}
|
||||
disabled={isPending}
|
||||
aria-label={t("session.confirmAbandon")}
|
||||
>
|
||||
{t("session.confirmAbandon")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
@@ -211,13 +277,18 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCurrentIndex(currentIndex + 1)}
|
||||
aria-label={t("session.next")}
|
||||
>
|
||||
{t("session.next")}
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleComplete} disabled={isPending || answeredCount < total}>
|
||||
<Trophy className="h-4 w-4" />
|
||||
<Button
|
||||
onClick={handleComplete}
|
||||
disabled={isPending || answeredCount < total}
|
||||
aria-label={t("session.complete")}
|
||||
>
|
||||
<Trophy className="h-4 w-4" aria-hidden="true" />
|
||||
{t("session.complete")}
|
||||
</Button>
|
||||
)}
|
||||
@@ -226,325 +297,3 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 题目卡片
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface QuestionCardProps {
|
||||
answer: PracticeAnswerRecord
|
||||
index: number
|
||||
total: number
|
||||
userAnswer: unknown
|
||||
onAnswerChange: (answer: unknown) => void
|
||||
onSubmit: (answer: unknown) => void
|
||||
onSkip: () => void
|
||||
isAnswered: boolean
|
||||
result?: { isCorrect: boolean | null; score: number | null }
|
||||
isPending: boolean
|
||||
}
|
||||
|
||||
function QuestionCard({
|
||||
answer,
|
||||
index,
|
||||
total,
|
||||
userAnswer,
|
||||
onAnswerChange,
|
||||
onSubmit,
|
||||
onSkip,
|
||||
isAnswered,
|
||||
result,
|
||||
isPending,
|
||||
}: QuestionCardProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
const question = answer.question
|
||||
const content = answer.variantContent ?? question?.content
|
||||
const questionType = question?.type ?? "unknown"
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
{t("session.question")} {index + 1}/{total}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">{questionType}</Badge>
|
||||
{question?.difficulty ? (
|
||||
<Badge variant="secondary">
|
||||
{t("session.difficulty")}: {question.difficulty}
|
||||
</Badge>
|
||||
) : null}
|
||||
{answer.isVariant ? (
|
||||
<Badge variant="default">{t("session.variant")}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 题目内容 */}
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
||||
{typeof content === "string"
|
||||
? content
|
||||
: JSON.stringify(content, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{/* 作答区域 */}
|
||||
{!isAnswered ? (
|
||||
<AnswerInput
|
||||
questionType={questionType}
|
||||
content={content}
|
||||
userAnswer={userAnswer}
|
||||
onAnswerChange={onAnswerChange}
|
||||
/>
|
||||
) : (
|
||||
<AnswerResult
|
||||
answer={answer}
|
||||
result={result}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
{!isAnswered ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onSkip} disabled={isPending}>
|
||||
{t("session.skip")}
|
||||
</Button>
|
||||
<Button onClick={() => onSubmit(userAnswer)} disabled={isPending || userAnswer === undefined}>
|
||||
{isPending ? t("session.submitting") : t("session.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 答题输入组件
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnswerInputProps {
|
||||
questionType: string
|
||||
content: unknown
|
||||
userAnswer: unknown
|
||||
onAnswerChange: (answer: unknown) => void
|
||||
}
|
||||
|
||||
function AnswerInput({ questionType, content, userAnswer, onAnswerChange }: AnswerInputProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
const options = extractOptions(content)
|
||||
const selectedId = typeof userAnswer === "string" ? userAnswer : ""
|
||||
|
||||
return (
|
||||
<RadioGroup value={selectedId} onValueChange={onAnswerChange}>
|
||||
<div className="space-y-2">
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt.id} id={opt.id} />
|
||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "multiple_choice") {
|
||||
const options = extractOptions(content)
|
||||
const selectedIds = Array.isArray(userAnswer) ? userAnswer as string[] : []
|
||||
|
||||
function toggle(id: string): void {
|
||||
const newIds = selectedIds.includes(id)
|
||||
? selectedIds.filter((v) => v !== id)
|
||||
: [...selectedIds, id]
|
||||
onAnswerChange(newIds)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{options.map((opt) => (
|
||||
<div key={opt.id} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(opt.id)}
|
||||
onCheckedChange={() => toggle(opt.id)}
|
||||
id={opt.id}
|
||||
/>
|
||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const value = typeof userAnswer === "string" ? userAnswer : ""
|
||||
|
||||
return (
|
||||
<RadioGroup value={value} onValueChange={onAnswerChange}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="true" id="judgment-true" />
|
||||
<Label htmlFor="judgment-true">{t("session.true")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="false" id="judgment-false" />
|
||||
<Label htmlFor="judgment-false">{t("session.false")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
)
|
||||
}
|
||||
|
||||
// text 题型
|
||||
return (
|
||||
<textarea
|
||||
value={typeof userAnswer === "string" ? userAnswer : ""}
|
||||
onChange={(e) => onAnswerChange(e.target.value)}
|
||||
placeholder={t("session.textPlaceholder")}
|
||||
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 答题结果展示
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AnswerResultProps {
|
||||
answer: PracticeAnswerRecord
|
||||
result?: { isCorrect: boolean | null; score: number | null }
|
||||
}
|
||||
|
||||
function AnswerResult({ answer, result }: AnswerResultProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
const isCorrect = result?.isCorrect ?? answer.isCorrect
|
||||
const isSkipped = answer.status === "skipped"
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 判分结果 */}
|
||||
{isSkipped ? (
|
||||
<div className="rounded-md border border-muted bg-muted/30 p-3 text-sm text-muted-foreground">
|
||||
{t("session.skipped")}
|
||||
</div>
|
||||
) : isCorrect === true ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/20 dark:text-emerald-400">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
{t("session.correct")}
|
||||
</div>
|
||||
) : isCorrect === false ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400">
|
||||
<XCircle className="h-5 w-5" />
|
||||
{t("session.incorrect")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/50 p-3 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-400">
|
||||
{t("session.pendingReview")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 学生答案 */}
|
||||
{answer.studentAnswer !== null && answer.studentAnswer !== undefined ? (
|
||||
<div>
|
||||
<h4 className="mb-1 text-sm font-medium">{t("session.yourAnswer")}</h4>
|
||||
<pre className="whitespace-pre-wrap break-words rounded-md border bg-muted/30 p-2 text-xs font-sans">
|
||||
{typeof answer.studentAnswer === "string"
|
||||
? answer.studentAnswer
|
||||
: JSON.stringify(answer.studentAnswer, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 练习结果视图
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function PracticeResultView({ session }: { session: PracticeSessionDetail }): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
|
||||
const total = session.totalQuestions
|
||||
const answered = session.answeredQuestions
|
||||
const correct = session.correctCount
|
||||
const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-amber-500" />
|
||||
{t("result.title")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div className="text-2xl font-bold">{answered}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.answered")}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div className="text-2xl font-bold text-emerald-600">{correct}</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.correct")}</div>
|
||||
</div>
|
||||
<div className="rounded-md border p-4 text-center">
|
||||
<div className="text-2xl font-bold">{accuracy}%</div>
|
||||
<div className="text-xs text-muted-foreground">{t("result.accuracy")}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 逐题回顾 */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t("result.review")}</h4>
|
||||
{session.answers.map((a, idx) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{a.isCorrect === true ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
||||
) : a.isCorrect === false ? (
|
||||
<XCircle className="h-4 w-4 text-rose-500" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
{t("session.question")} {idx + 1}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{a.status === "answered" ? (a.isCorrect === true ? t("session.correct") : a.isCorrect === false ? t("session.incorrect") : t("session.pendingReview")) : t("session.skipped")}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function extractOptions(content: unknown): Array<{ id: string; text: string }> {
|
||||
if (typeof content !== "object" || content === null) return []
|
||||
const record = content as Record<string, unknown>
|
||||
const options = record.options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
return options
|
||||
.filter((opt): opt is Record<string, unknown> =>
|
||||
typeof opt === "object" && opt !== null && typeof opt.id === "string",
|
||||
)
|
||||
.map((opt) => ({
|
||||
id: opt.id as string,
|
||||
text: typeof opt.text === "string" ? opt.text : String(opt.text ?? ""),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useTransition } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
@@ -17,10 +17,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
|
||||
import { createPracticeSessionAction } from "../actions"
|
||||
import type { PracticeType } from "../types"
|
||||
import { isPracticeType } from "../lib/type-guards"
|
||||
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
|
||||
import type { AiRecommendedReason, PracticeType } from "../types"
|
||||
|
||||
interface PracticeStarterProps {
|
||||
export interface PracticeStarterProps {
|
||||
/** 知识点选项列表 */
|
||||
knowledgePoints: Array<{ id: string; name: string }>
|
||||
/** 预设模式(从错题本发起时传入) */
|
||||
@@ -31,22 +32,50 @@ interface PracticeStarterProps {
|
||||
}
|
||||
/** 是否禁用类型选择(预设模式时) */
|
||||
lockType?: boolean
|
||||
/**
|
||||
* 会话创建成功后的回调(由页面层注入路由跳转逻辑)。
|
||||
*
|
||||
* 解耦设计:组件本身不感知路由,由调用方决定跳转目标
|
||||
* (学生跳 `/student/practice/[id]`,家长可能跳 `/parent/practice/[id]`)。
|
||||
* 不传则不跳转(仅 toast 提示)。
|
||||
*/
|
||||
onSessionCreated?: (sessionId: string) => void
|
||||
/**
|
||||
* AI 推荐练习的发起角色(决定 sourceMeta.reason 枚举值)。
|
||||
*
|
||||
* - 学生自主发起(默认):`"student_initiated"`
|
||||
* - 家长建议子女:`"parent_suggested"`
|
||||
* - 教师布置:`"teacher_assigned"`
|
||||
*/
|
||||
aiRecommendReason?: AiRecommendedReason
|
||||
}
|
||||
|
||||
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const
|
||||
|
||||
/**
|
||||
* 专项练习发起器
|
||||
* 专项练习发起器。
|
||||
*
|
||||
* 通过 `usePracticeService()` 获取数据服务,不直接 import actions,
|
||||
* 实现完全解耦:测试时可注入 mock 服务,不同角色可注入不同实现。
|
||||
*
|
||||
* 路由跳转通过 `onSessionCreated` 回调注入,组件本身不感知路由结构。
|
||||
*
|
||||
* 支持四种练习模式:
|
||||
* 1. 错题变式练习:从错题本发起
|
||||
* 1. 错题重做:从错题本发起(通过 presetMode)
|
||||
* 2. 知识点专项:选择知识点后抽题
|
||||
* 3. 薄弱章节:自动识别薄弱知识点
|
||||
* 4. AI 推荐:AI 根据学情推荐
|
||||
* 3. 薄弱章节:自动识别薄弱知识点(不传 chapterId 时跨章节识别)
|
||||
* 4. AI 推荐:根据学情推荐
|
||||
*/
|
||||
export function PracticeStarter({ knowledgePoints, presetMode, lockType }: PracticeStarterProps): React.ReactNode {
|
||||
export function PracticeStarter({
|
||||
knowledgePoints,
|
||||
presetMode,
|
||||
lockType,
|
||||
onSessionCreated,
|
||||
aiRecommendReason = "student_initiated",
|
||||
}: PracticeStarterProps): React.ReactNode {
|
||||
const t = useTranslations("practice")
|
||||
const router = useRouter()
|
||||
const service = usePracticeService()
|
||||
const analytics = usePracticeAnalytics()
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const [practiceType, setPracticeType] = useState<PracticeType>(
|
||||
@@ -62,6 +91,38 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
)
|
||||
}
|
||||
|
||||
function handleTypeChange(value: string): void {
|
||||
// 严格类型守卫替代 `value as PracticeType` 断言
|
||||
if (isPracticeType(value)) {
|
||||
setPracticeType(value)
|
||||
}
|
||||
}
|
||||
|
||||
function handleDifficultyChange(value: string): void {
|
||||
const num = Number(value)
|
||||
if (Number.isInteger(num)) {
|
||||
setDifficulty(num)
|
||||
}
|
||||
}
|
||||
|
||||
function handleQuestionCountChange(value: string): void {
|
||||
const num = Number(value)
|
||||
if (Number.isInteger(num)) {
|
||||
setQuestionCount(num)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveErrorMessage(
|
||||
errorCode: string | undefined,
|
||||
message: string | undefined,
|
||||
fallbackKey: string,
|
||||
): string {
|
||||
if (errorCode) {
|
||||
return t(`errors.${errorCode}`)
|
||||
}
|
||||
return message ?? t(fallbackKey)
|
||||
}
|
||||
|
||||
function handleStart(): void {
|
||||
if (presetMode) {
|
||||
// 预设模式:直接使用传入的 sourceMeta
|
||||
@@ -76,12 +137,13 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
questionCount,
|
||||
}),
|
||||
)
|
||||
const res = await createPracticeSessionAction(undefined, formData)
|
||||
const res = await service.createSession(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
analytics.trackSessionStart(presetMode.type, questionCount)
|
||||
toast.success(res.message ?? t("toasts.created"))
|
||||
router.push(`/student/practice/${res.data.sessionId}`)
|
||||
onSessionCreated?.(res.data.sessionId)
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.createFailed"))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -101,18 +163,19 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
}
|
||||
} else if (practiceType === "weak_chapter") {
|
||||
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
|
||||
// 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点
|
||||
if (selectedKpIds.length === 0) {
|
||||
toast.error(t("toasts.selectWeakKnowledgePoint"))
|
||||
return
|
||||
}
|
||||
sourceMeta = {
|
||||
chapterId: "",
|
||||
weakKnowledgePointIds: selectedKpIds,
|
||||
}
|
||||
} else if (practiceType === "ai_recommended") {
|
||||
sourceMeta = {
|
||||
recommendedKnowledgePointIds: selectedKpIds,
|
||||
reason: t("toasts.aiRecommendedReason"),
|
||||
// 枚举值(业务数据),UI 层通过 t(`reasons.${reason}`) 查 i18n
|
||||
reason: aiRecommendReason,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,23 +189,28 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
questionCount,
|
||||
}),
|
||||
)
|
||||
const res = await createPracticeSessionAction(undefined, formData)
|
||||
const res = await service.createSession(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
analytics.trackSessionStart(practiceType, questionCount)
|
||||
toast.success(res.message ?? t("toasts.created"))
|
||||
router.push(`/student/practice/${res.data.sessionId}`)
|
||||
onSessionCreated?.(res.data.sessionId)
|
||||
} else {
|
||||
toast.error(res.message ?? t("toasts.createFailed"))
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const showKpSelector = !presetMode || practiceType === "knowledge_point" || practiceType === "weak_chapter" || practiceType === "ai_recommended"
|
||||
const showKpSelector =
|
||||
!presetMode ||
|
||||
practiceType === "knowledge_point" ||
|
||||
practiceType === "weak_chapter" ||
|
||||
practiceType === "ai_recommended"
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
<Target className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||
{t("starter.title")}
|
||||
</CardTitle>
|
||||
<CardDescription>{t("starter.description")}</CardDescription>
|
||||
@@ -151,30 +219,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
{/* 练习类型选择 */}
|
||||
{!lockType ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.type")}</Label>
|
||||
<Select
|
||||
value={practiceType}
|
||||
onValueChange={(v: string) => setPracticeType(v as PracticeType)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<Label htmlFor="practice-type-select">{t("starter.type")}</Label>
|
||||
<Select value={practiceType} onValueChange={handleTypeChange}>
|
||||
<SelectTrigger id="practice-type-select" aria-label={t("starter.type")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="knowledge_point">
|
||||
<span className="flex items-center gap-2">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||
{t("types.knowledge_point")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="weak_chapter">
|
||||
<span className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertCircle className="h-4 w-4" aria-hidden="true" />
|
||||
{t("types.weak_chapter")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="ai_recommended">
|
||||
<span className="flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||
{t("types.ai_recommended")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
@@ -187,21 +252,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
{showKpSelector && knowledgePoints.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.knowledgePoints")}</Label>
|
||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2">
|
||||
{knowledgePoints.map((kp) => (
|
||||
<div
|
||||
className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2"
|
||||
role="group"
|
||||
aria-label={t("starter.knowledgePoints")}
|
||||
>
|
||||
{knowledgePoints.map((kp) => {
|
||||
const checked = selectedKpIds.includes(kp.id)
|
||||
return (
|
||||
<label
|
||||
key={kp.id}
|
||||
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedKpIds.includes(kp.id)}
|
||||
onChange={() => toggleKp(kp.id)}
|
||||
className="rounded border-input"
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onCheckedChange={() => toggleKp(kp.id)}
|
||||
aria-label={kp.name}
|
||||
/>
|
||||
<span className="text-sm">{kp.name}</span>
|
||||
</label>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -209,12 +280,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
{/* 难度选择(仅知识点专项) */}
|
||||
{practiceType === "knowledge_point" && !presetMode ? (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.difficulty")}</Label>
|
||||
<Select
|
||||
value={String(difficulty)}
|
||||
onValueChange={(v: string) => setDifficulty(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<Label htmlFor="difficulty-select">{t("starter.difficulty")}</Label>
|
||||
<Select value={String(difficulty)} onValueChange={handleDifficultyChange}>
|
||||
<SelectTrigger id="difficulty-select" aria-label={t("starter.difficulty")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -231,12 +299,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
|
||||
{/* 题目数量 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{t("starter.questionCount")}</Label>
|
||||
<Select
|
||||
value={String(questionCount)}
|
||||
onValueChange={(v: string) => setQuestionCount(Number(v))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<Label htmlFor="question-count-select">{t("starter.questionCount")}</Label>
|
||||
<Select value={String(questionCount)} onValueChange={handleQuestionCountChange}>
|
||||
<SelectTrigger id="question-count-select" aria-label={t("starter.questionCount")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -254,6 +319,7 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
||||
onClick={handleStart}
|
||||
disabled={isPending}
|
||||
className="w-full"
|
||||
aria-label={isPending ? t("starter.creating") : t("starter.start")}
|
||||
>
|
||||
{isPending ? t("starter.creating") : t("starter.start")}
|
||||
</Button>
|
||||
|
||||
149
src/modules/adaptive-practice/components/question-card.tsx
Normal file
149
src/modules/adaptive-practice/components/question-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -5,7 +5,12 @@ import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { practiceAnswers, practiceSessions, questions, questionsToKnowledgePoints, knowledgePoints } from "@/shared/db/schema"
|
||||
import { getActiveStudentIdsByClassId, getClassNameById, getClassesByGradeId } from "@/modules/classes/data-access"
|
||||
import {
|
||||
getActiveStudentIdsByClassId,
|
||||
getClassNamesByIds,
|
||||
getClassNameById,
|
||||
getClassesByGradeId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -54,6 +59,8 @@ export interface PracticeTypeBreakdown {
|
||||
* 获取班级专项练习统计。
|
||||
*
|
||||
* 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。
|
||||
* 使用单条 SQL 同时计算汇总统计与活跃学生数(COUNT(DISTINCT)),
|
||||
* 避免 N+1 查询。
|
||||
*
|
||||
* @param classId 班级 ID
|
||||
*/
|
||||
@@ -63,12 +70,14 @@ export const getClassPracticeStats = cache(async (
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
if (studentIds.length === 0) return null
|
||||
|
||||
// 单条 SQL 同时获取汇总统计与活跃学生数(替代原先两条 SQL)
|
||||
const rows = await db
|
||||
.select({
|
||||
totalSessions: count(),
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
@@ -78,14 +87,7 @@ export const getClassPracticeStats = cache(async (
|
||||
|
||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||
const totalCorrect = Number(row.totalCorrect)
|
||||
|
||||
// 统计参与练习的学生数
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
||||
const activeStudents = Number(row.activeStudents)
|
||||
|
||||
return {
|
||||
classId,
|
||||
@@ -141,6 +143,9 @@ export const getClassStudentPracticeSummaries = cache(async (
|
||||
/**
|
||||
* 获取年级专项练习统计。
|
||||
*
|
||||
* 使用单条 SQL 同时计算汇总统计与活跃学生数(COUNT(DISTINCT)),
|
||||
* 避免 N+1 查询。
|
||||
*
|
||||
* @param gradeId 年级 ID
|
||||
*/
|
||||
export const getGradePracticeStats = cache(async (
|
||||
@@ -162,6 +167,7 @@ export const getGradePracticeStats = cache(async (
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
@@ -172,18 +178,13 @@ export const getGradePracticeStats = cache(async (
|
||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||
const totalCorrect = Number(row.totalCorrect)
|
||||
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
return {
|
||||
totalSessions: Number(row.totalSessions),
|
||||
completedSessions: Number(row.completedSessions),
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents: Number(activeStudentsResult[0]?.count ?? 0),
|
||||
activeStudents: Number(row.activeStudents),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -271,6 +272,10 @@ export interface TeacherClassPracticeOverview {
|
||||
*
|
||||
* 用于教师端分析页面顶部展示每个班级的练习情况。
|
||||
*
|
||||
* N+1 优化:
|
||||
* - 批量获取所有班级名称(1 条 SQL 替代 N 条)
|
||||
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL)
|
||||
*
|
||||
* @param classIds 教师/年级主任可访问的班级 ID 列表
|
||||
*/
|
||||
export const getTeacherClassPracticeOverviews = cache(async (
|
||||
@@ -278,23 +283,30 @@ export const getTeacherClassPracticeOverviews = cache(async (
|
||||
): Promise<TeacherClassPracticeOverview[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
|
||||
const results = await Promise.all(
|
||||
classIds.map(async (classId): Promise<TeacherClassPracticeOverview | null> => {
|
||||
const className = await getClassNameById(classId)
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
// 批量获取所有班级名称(1 条 SQL)
|
||||
const classNameMap = await getClassNamesByIds(classIds)
|
||||
|
||||
// 并行获取每个班级的学生 ID 列表
|
||||
const studentIdsPerClass = await Promise.all(
|
||||
classIds.map(async (classId) => ({
|
||||
classId,
|
||||
studentIds: await getActiveStudentIdsByClassId(classId),
|
||||
})),
|
||||
)
|
||||
|
||||
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
|
||||
const statsPerClass = await Promise.all(
|
||||
studentIdsPerClass.map(async ({ classId, studentIds }) => {
|
||||
const totalStudents = studentIds.length
|
||||
if (totalStudents === 0) {
|
||||
return {
|
||||
classId,
|
||||
className: className ?? "",
|
||||
totalStudents: 0,
|
||||
totalSessions: 0,
|
||||
completedSessions: 0,
|
||||
totalQuestionsAnswered: 0,
|
||||
totalCorrect: 0,
|
||||
averageAccuracy: 0,
|
||||
activeStudents: 0,
|
||||
totalStudents: 0,
|
||||
participationRate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,39 +316,41 @@ export const getTeacherClassPracticeOverviews = cache(async (
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
|
||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||
const totalCorrect = Number(row.totalCorrect)
|
||||
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
||||
|
||||
return {
|
||||
classId,
|
||||
className: className ?? "",
|
||||
totalSessions: Number(row.totalSessions),
|
||||
completedSessions: Number(row.completedSessions),
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents,
|
||||
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
|
||||
totalSessions: Number(row?.totalSessions ?? 0),
|
||||
completedSessions: Number(row?.completedSessions ?? 0),
|
||||
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
|
||||
totalCorrect: Number(row?.totalCorrect ?? 0),
|
||||
activeStudents: Number(row?.activeStudents ?? 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return results.filter((r): r is TeacherClassPracticeOverview => r !== null)
|
||||
return statsPerClass.map((s) => {
|
||||
const totalQuestionsAnswered = s.totalQuestionsAnswered
|
||||
const totalCorrect = s.totalCorrect
|
||||
const activeStudents = s.activeStudents
|
||||
return {
|
||||
classId: s.classId,
|
||||
className: classNameMap.get(s.classId) ?? "",
|
||||
totalSessions: s.totalSessions,
|
||||
completedSessions: s.completedSessions,
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents: s.totalStudents,
|
||||
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -427,6 +441,10 @@ export interface GradeClassPracticeComparison {
|
||||
/**
|
||||
* 获取年级各班级专项练习对比数据。
|
||||
*
|
||||
* N+1 优化:
|
||||
* - 班级列表已含名称,无需额外查询
|
||||
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL)
|
||||
*
|
||||
* @param gradeId 年级 ID
|
||||
*/
|
||||
export const getGradeClassPracticeComparison = cache(async (
|
||||
@@ -435,23 +453,27 @@ export const getGradeClassPracticeComparison = cache(async (
|
||||
const classList = await getClassesByGradeId(gradeId)
|
||||
if (classList.length === 0) return []
|
||||
|
||||
const results = await Promise.all(
|
||||
classList.map(async (cls): Promise<GradeClassPracticeComparison> => {
|
||||
const studentIds = await getActiveStudentIdsByClassId(cls.id)
|
||||
const totalStudents = studentIds.length
|
||||
// 并行获取每个班级的学生 ID 列表
|
||||
const studentIdsPerClass = await Promise.all(
|
||||
classList.map(async (cls) => ({
|
||||
cls,
|
||||
studentIds: await getActiveStudentIdsByClassId(cls.id),
|
||||
})),
|
||||
)
|
||||
|
||||
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
|
||||
const statsPerClass = await Promise.all(
|
||||
studentIdsPerClass.map(async ({ cls, studentIds }) => {
|
||||
const totalStudents = studentIds.length
|
||||
if (totalStudents === 0) {
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
cls,
|
||||
totalStudents: 0,
|
||||
totalSessions: 0,
|
||||
completedSessions: 0,
|
||||
totalQuestionsAnswered: 0,
|
||||
totalCorrect: 0,
|
||||
averageAccuracy: 0,
|
||||
activeStudents: 0,
|
||||
totalStudents: 0,
|
||||
participationRate: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,35 +483,41 @@ export const getGradeClassPracticeComparison = cache(async (
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const row = rows[0]
|
||||
const totalQuestionsAnswered = Number(row?.totalQuestionsAnswered ?? 0)
|
||||
const totalCorrect = Number(row?.totalCorrect ?? 0)
|
||||
|
||||
const activeStudentsResult = await db
|
||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
|
||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
||||
|
||||
return {
|
||||
classId: cls.id,
|
||||
className: cls.name,
|
||||
cls,
|
||||
totalStudents,
|
||||
totalSessions: Number(row?.totalSessions ?? 0),
|
||||
completedSessions: Number(row?.completedSessions ?? 0),
|
||||
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
|
||||
totalCorrect: Number(row?.totalCorrect ?? 0),
|
||||
activeStudents: Number(row?.activeStudents ?? 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const results = statsPerClass.map((s) => {
|
||||
const totalQuestionsAnswered = s.totalQuestionsAnswered
|
||||
const totalCorrect = s.totalCorrect
|
||||
const activeStudents = s.activeStudents
|
||||
return {
|
||||
classId: s.cls.id,
|
||||
className: s.cls.name,
|
||||
totalSessions: s.totalSessions,
|
||||
completedSessions: s.completedSessions,
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents,
|
||||
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
|
||||
totalStudents: s.totalStudents,
|
||||
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// 按参与率降序排列
|
||||
return results.sort((a, b) => b.participationRate - a.participationRate)
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
questionsToKnowledgePoints,
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import { isWeakChapterSourceMeta, isAiRecommendedSourceMeta, isKnowledgePointSourceMeta, isErrorVariantSourceMeta } from "./lib/source-meta"
|
||||
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -22,22 +23,25 @@ const DEFAULT_QUESTION_COUNT = 10
|
||||
/** 薄弱知识点掌握度阈值(低于此值视为薄弱) */
|
||||
const WEAK_MASTERY_THRESHOLD = 60
|
||||
|
||||
/** 自动识别薄弱知识点默认返回数量 */
|
||||
const DEFAULT_WEAK_KP_LIMIT = 5
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 出题策略
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 错题变式练习出题策略。
|
||||
* 错题重做出题策略(原"错题变式")。
|
||||
*
|
||||
* 从错题本中选取错题,直接使用原题进行练习(不依赖 AI 生成变式题,
|
||||
* 确保即使 AI 不可用也能练习)。
|
||||
* 从错题本中选取错题,直接使用原题进行练习。
|
||||
* 该策略不依赖 AI 生成变式题,确保即使 AI 不可用也能练习。
|
||||
*
|
||||
* 策略:
|
||||
* 1. 从 sourceQuestionIds 中查询题库中存在的题目
|
||||
* 2. 排除已在该练习会话中的题目(由调用方保证)
|
||||
* 3. 按难度升序排列(先易后难,建立信心)
|
||||
*/
|
||||
async function selectForErrorVariant(
|
||||
export async function selectForErrorVariant(
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
): Promise<QuestionSelectionResult> {
|
||||
@@ -79,7 +83,7 @@ async function selectForErrorVariant(
|
||||
* 2. 按难度筛选(如指定)
|
||||
* 3. 随机抽取指定数量
|
||||
*/
|
||||
async function selectForKnowledgePoint(
|
||||
export async function selectForKnowledgePoint(
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
): Promise<QuestionSelectionResult> {
|
||||
@@ -125,14 +129,14 @@ async function selectForKnowledgePoint(
|
||||
/**
|
||||
* 薄弱章节练习出题策略。
|
||||
*
|
||||
* 根据学生掌握度自动识别薄弱知识点,从这些知识点中抽题。
|
||||
* 行为:
|
||||
* - 若 sourceMeta.weakKnowledgePointIds 已传入,直接使用
|
||||
* - 若未传入,调用 identifyWeakKnowledgePoints(studentId, chapterId) 自动识别
|
||||
*
|
||||
* 策略:
|
||||
* 1. 查询学生在指定章节知识点上的掌握度
|
||||
* 2. 筛选掌握度低于阈值的知识点
|
||||
* 3. 从薄弱知识点中抽题
|
||||
* 然后从(自动识别或显式传入的)薄弱知识点中抽题,
|
||||
* 排除学生已做过的题目避免重复。
|
||||
*/
|
||||
async function selectForWeakChapter(
|
||||
export async function selectForWeakChapter(
|
||||
studentId: string,
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
@@ -141,8 +145,15 @@ async function selectForWeakChapter(
|
||||
return { questionIds: [], variants: new Map() }
|
||||
}
|
||||
|
||||
const { weakKnowledgePointIds } = sourceMeta
|
||||
if (weakKnowledgePointIds.length === 0) {
|
||||
const { chapterId, weakKnowledgePointIds } = sourceMeta
|
||||
|
||||
// 自动识别薄弱知识点(若未传入)
|
||||
let kpIds = weakKnowledgePointIds
|
||||
if (!kpIds || kpIds.length === 0) {
|
||||
kpIds = await identifyWeakKnowledgePoints(studentId, chapterId, DEFAULT_WEAK_KP_LIMIT)
|
||||
}
|
||||
|
||||
if (kpIds.length === 0) {
|
||||
return { questionIds: [], variants: new Map() }
|
||||
}
|
||||
|
||||
@@ -151,7 +162,7 @@ async function selectForWeakChapter(
|
||||
|
||||
// 从薄弱知识点中抽题
|
||||
const conditions = [
|
||||
inArray(questionsToKnowledgePoints.knowledgePointId, weakKnowledgePointIds),
|
||||
inArray(questionsToKnowledgePoints.knowledgePointId, kpIds),
|
||||
sql`${questions.parentId} IS NULL`,
|
||||
]
|
||||
|
||||
@@ -185,7 +196,7 @@ async function selectForWeakChapter(
|
||||
* AI 推荐的知识点列表由上层(AI 分析)提供,
|
||||
* 此函数仅负责从推荐知识点中抽题。
|
||||
*/
|
||||
async function selectForAiRecommended(
|
||||
export async function selectForAiRecommended(
|
||||
studentId: string,
|
||||
sourceMeta: PracticeSourceMeta,
|
||||
questionCount: number,
|
||||
@@ -274,7 +285,7 @@ export async function selectQuestionsForPractice(
|
||||
* 从专项练习中汇总已答题目。
|
||||
* 为控制查询量,仅查询最近 1000 条记录。
|
||||
*/
|
||||
async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
|
||||
export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
|
||||
const rows = await db
|
||||
.select({ questionId: practiceAnswers.questionId })
|
||||
.from(practiceAnswers)
|
||||
@@ -298,7 +309,7 @@ async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[
|
||||
export async function identifyWeakKnowledgePoints(
|
||||
studentId: string,
|
||||
chapterId?: string,
|
||||
limit: number = 5,
|
||||
limit: number = DEFAULT_WEAK_KP_LIMIT,
|
||||
): Promise<string[]> {
|
||||
const conditions = [
|
||||
eq(knowledgePointMastery.studentId, studentId),
|
||||
@@ -317,27 +328,3 @@ export async function identifyWeakKnowledgePoints(
|
||||
|
||||
return rows.map((r) => r.knowledgePointId)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 类型守卫
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isErrorVariantSourceMeta(meta: PracticeSourceMeta): meta is { errorBookItemIds: string[]; sourceQuestionIds: string[] } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"errorBookItemIds" in meta && "sourceQuestionIds" in meta
|
||||
}
|
||||
|
||||
function isKnowledgePointSourceMeta(meta: PracticeSourceMeta): meta is { knowledgePointIds: string[]; difficulty?: number } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"knowledgePointIds" in meta
|
||||
}
|
||||
|
||||
function isWeakChapterSourceMeta(meta: PracticeSourceMeta): meta is { chapterId: string; weakKnowledgePointIds: string[] } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"chapterId" in meta && "weakKnowledgePointIds" in meta
|
||||
}
|
||||
|
||||
function isAiRecommendedSourceMeta(meta: PracticeSourceMeta): meta is { recommendedKnowledgePointIds: string[]; reason: string } {
|
||||
return typeof meta === "object" && meta !== null &&
|
||||
"recommendedKnowledgePointIds" in meta && "reason" in meta
|
||||
}
|
||||
|
||||
@@ -11,10 +11,17 @@ import {
|
||||
questions,
|
||||
} from "@/shared/db/schema"
|
||||
import { selectQuestionsForPractice } from "./data-access-strategy"
|
||||
import { autoGradeAnswer } from "./lib/grading"
|
||||
import {
|
||||
asPracticeAnswerStatus,
|
||||
asPracticeSourceMeta,
|
||||
asPracticeStatus,
|
||||
asPracticeType,
|
||||
} from "./lib/type-guards"
|
||||
import { practiceErrors } from "./lib/errors"
|
||||
|
||||
import type {
|
||||
PracticeAnswerRecord,
|
||||
PracticeAnswerStatus,
|
||||
PracticeSessionDetail,
|
||||
PracticeSessionSummary,
|
||||
PracticeSourceMeta,
|
||||
@@ -34,8 +41,8 @@ function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessi
|
||||
id: row.id,
|
||||
studentId: row.studentId,
|
||||
subjectId: row.subjectId,
|
||||
practiceType: row.practiceType as PracticeType,
|
||||
status: row.status as PracticeStatus,
|
||||
practiceType: asPracticeType(row.practiceType),
|
||||
status: asPracticeStatus(row.status),
|
||||
totalQuestions: row.totalQuestions,
|
||||
answeredQuestions,
|
||||
correctCount,
|
||||
@@ -56,7 +63,7 @@ function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
|
||||
variantContent: row.variantContent,
|
||||
isVariant: row.isVariant,
|
||||
orderIndex: row.orderIndex,
|
||||
status: row.status as PracticeAnswerStatus,
|
||||
status: asPracticeAnswerStatus(row.status),
|
||||
studentAnswer: row.studentAnswer,
|
||||
isCorrect: row.isCorrect,
|
||||
score: row.score,
|
||||
@@ -170,7 +177,7 @@ export const getPracticeSessionById = cache(async (
|
||||
|
||||
return {
|
||||
...summary,
|
||||
sourceMeta: session.sourceMeta as PracticeSourceMeta | null,
|
||||
sourceMeta: asPracticeSourceMeta(session.sourceMeta),
|
||||
answers: mappedAnswers,
|
||||
}
|
||||
})
|
||||
@@ -213,7 +220,7 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
||||
}
|
||||
|
||||
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
|
||||
practiceType: type as PracticeType,
|
||||
practiceType: asPracticeType(type),
|
||||
sessionCount: stat.sessionCount,
|
||||
totalQuestions: stat.totalQuestions,
|
||||
correctCount: stat.correctCount,
|
||||
@@ -241,6 +248,8 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
||||
* 2. 创建会话记录
|
||||
* 3. 创建答题记录(初始状态为 pending)
|
||||
*
|
||||
* @throws {PracticeError} 未找到题目时抛 no_questions_found
|
||||
*
|
||||
* @returns 会话 ID 和选中的题目数量
|
||||
*/
|
||||
export async function createPracticeSession(
|
||||
@@ -263,7 +272,7 @@ export async function createPracticeSession(
|
||||
)
|
||||
|
||||
if (selection.questionIds.length === 0) {
|
||||
return { sessionId: "", selectedCount: 0 }
|
||||
throw practiceErrors.noQuestionsFound()
|
||||
}
|
||||
|
||||
const sessionId = createId()
|
||||
@@ -276,6 +285,7 @@ export async function createPracticeSession(
|
||||
studentId,
|
||||
subjectId: input.subjectId ?? null,
|
||||
practiceType,
|
||||
// sourceMeta 已在上层通过 parseSourceMeta 校验,此处直接写入
|
||||
sourceMeta: sourceMeta as unknown,
|
||||
status: "in_progress",
|
||||
totalQuestions: selection.questionIds.length,
|
||||
@@ -314,6 +324,12 @@ export async function createPracticeSession(
|
||||
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
|
||||
* - 填空题:暂不自动判分(isCorrect = null)
|
||||
*
|
||||
* 并发安全:整个校验+判分+统计更新流程包裹在事务中,
|
||||
* 对答题记录加行锁(SELECT ... FOR UPDATE),
|
||||
* 防止同一答案被并发重复判分导致统计累加错误。
|
||||
*
|
||||
* @throws {PracticeError} 会话/答题记录不存在、已结束、已作答时抛对应错误码
|
||||
*
|
||||
* @returns 是否判分成功
|
||||
*/
|
||||
export async function submitPracticeAnswer(
|
||||
@@ -323,43 +339,49 @@ export async function submitPracticeAnswer(
|
||||
answer: unknown,
|
||||
skip: boolean = false,
|
||||
): Promise<{ isCorrect: boolean | null; score: number | null }> {
|
||||
// 校验会话归属
|
||||
const session = await db.query.practiceSessions.findFirst({
|
||||
where: and(
|
||||
// 事务:行锁 + 校验 + 判分 + 统计更新(防止并发重复判分)
|
||||
return await db.transaction(async (tx) => {
|
||||
// 1. 校验会话归属(带行锁)
|
||||
const [session] = await tx
|
||||
.select()
|
||||
.from(practiceSessions)
|
||||
.where(and(
|
||||
eq(practiceSessions.id, sessionId),
|
||||
eq(practiceSessions.studentId, studentId),
|
||||
),
|
||||
})
|
||||
))
|
||||
.for("update")
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
throw new Error("练习会话已结束")
|
||||
throw practiceErrors.sessionEnded()
|
||||
}
|
||||
|
||||
// 查询答题记录
|
||||
const answerRecord = await db.query.practiceAnswers.findFirst({
|
||||
where: and(
|
||||
// 2. 查询答题记录(带行锁,防止并发重复提交)
|
||||
const [answerRecord] = await tx
|
||||
.select()
|
||||
.from(practiceAnswers)
|
||||
.where(and(
|
||||
eq(practiceAnswers.id, answerId),
|
||||
eq(practiceAnswers.sessionId, sessionId),
|
||||
),
|
||||
})
|
||||
))
|
||||
.for("update")
|
||||
|
||||
if (!answerRecord) {
|
||||
throw new Error("答题记录不存在")
|
||||
throw practiceErrors.answerNotFound()
|
||||
}
|
||||
|
||||
if (answerRecord.status === "answered") {
|
||||
throw new Error("此题已作答")
|
||||
throw practiceErrors.answerAlreadySubmitted()
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
|
||||
if (skip) {
|
||||
// 跳过此题
|
||||
await db
|
||||
// 跳过此题:状态置为 skipped,不累加已答题数与正确数
|
||||
await tx
|
||||
.update(practiceAnswers)
|
||||
.set({
|
||||
status: "skipped",
|
||||
@@ -367,18 +389,26 @@ export async function submitPracticeAnswer(
|
||||
})
|
||||
.where(eq(practiceAnswers.id, answerId))
|
||||
|
||||
// 更新会话统计
|
||||
await updateSessionStats(sessionId, 0, false)
|
||||
// 累加已答题数(不累加正确数)
|
||||
await tx
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + 1,
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
|
||||
return { isCorrect: null, score: null }
|
||||
}
|
||||
|
||||
// 自动判分:查询题目内容并提取正确答案
|
||||
const question = await db.query.questions.findFirst({
|
||||
where: eq(questions.id, answerRecord.questionId),
|
||||
})
|
||||
// 3. 自动判分:查询题目内容并提取正确答案
|
||||
const [question] = await tx
|
||||
.select()
|
||||
.from(questions)
|
||||
.where(eq(questions.id, answerRecord.questionId))
|
||||
.limit(1)
|
||||
|
||||
if (!question) {
|
||||
throw new Error("题目不存在")
|
||||
throw practiceErrors.questionNotFound()
|
||||
}
|
||||
|
||||
// 如果是变式题,使用变式题内容
|
||||
@@ -387,7 +417,8 @@ export async function submitPracticeAnswer(
|
||||
|
||||
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
|
||||
|
||||
await db
|
||||
// 4. 更新答题记录
|
||||
await tx
|
||||
.update(practiceAnswers)
|
||||
.set({
|
||||
status: "answered",
|
||||
@@ -398,20 +429,33 @@ export async function submitPracticeAnswer(
|
||||
})
|
||||
.where(eq(practiceAnswers.id, answerId))
|
||||
|
||||
// 更新会话统计
|
||||
await updateSessionStats(
|
||||
sessionId,
|
||||
1,
|
||||
isCorrect === true,
|
||||
)
|
||||
// 5. 累加会话统计(基于步骤 1 已加锁的 session 行)
|
||||
await tx
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + 1,
|
||||
correctCount: session.correctCount + (isCorrect === true ? 1 : 0),
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
|
||||
return { isCorrect, score }
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 写入:完成/放弃练习会话
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 完成练习会话。
|
||||
*
|
||||
* 完整性校验:必须答完所有题目(answeredQuestions === totalQuestions)才能完成,
|
||||
* 防止学生提前完成导致统计失真。
|
||||
*
|
||||
* 注意:跳过的题目也算"已作答"(status=skipped),与 answeredQuestions 累加逻辑一致。
|
||||
*
|
||||
* @throws {PracticeError} 会话不存在 → session_not_found;未答完 → session_not_complete
|
||||
*/
|
||||
export async function completePracticeSession(
|
||||
sessionId: string,
|
||||
studentId: string,
|
||||
@@ -424,13 +468,19 @@ export async function completePracticeSession(
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
// 已完成或已放弃,幂等返回
|
||||
return
|
||||
}
|
||||
|
||||
// 完整性校验:必须答完所有题目
|
||||
if (session.answeredQuestions !== session.totalQuestions) {
|
||||
throw practiceErrors.sessionNotComplete()
|
||||
}
|
||||
|
||||
await db
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
@@ -440,6 +490,13 @@ export async function completePracticeSession(
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 放弃练习会话。
|
||||
*
|
||||
* 幂等:已完成或已放弃的会话再次调用不会报错。
|
||||
*
|
||||
* @throws {PracticeError} 会话不存在 → session_not_found
|
||||
*/
|
||||
export async function abandonPracticeSession(
|
||||
sessionId: string,
|
||||
studentId: string,
|
||||
@@ -452,10 +509,11 @@ export async function abandonPracticeSession(
|
||||
})
|
||||
|
||||
if (!session) {
|
||||
throw new Error("练习会话不存在或无权访问")
|
||||
throw practiceErrors.sessionNotFound()
|
||||
}
|
||||
|
||||
if (session.status !== "in_progress") {
|
||||
// 已完成或已放弃,幂等返回
|
||||
return
|
||||
}
|
||||
|
||||
@@ -472,148 +530,5 @@ export async function abandonPracticeSession(
|
||||
// 内部辅助函数
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 更新会话统计(已答题数、正确数)。
|
||||
*/
|
||||
async function updateSessionStats(
|
||||
sessionId: string,
|
||||
newlyAnswered: number,
|
||||
newlyCorrect: boolean,
|
||||
): Promise<void> {
|
||||
const session = await db.query.practiceSessions.findFirst({
|
||||
where: eq(practiceSessions.id, sessionId),
|
||||
columns: {
|
||||
answeredQuestions: true,
|
||||
correctCount: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!session) return
|
||||
|
||||
await db
|
||||
.update(practiceSessions)
|
||||
.set({
|
||||
answeredQuestions: session.answeredQuestions + newlyAnswered,
|
||||
correctCount: session.correctCount + (newlyCorrect ? 1 : 0),
|
||||
})
|
||||
.where(eq(practiceSessions.id, sessionId))
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动判分:比对学生答案与正确答案。
|
||||
*
|
||||
* 支持题型:
|
||||
* - single_choice: 比对选中选项 ID
|
||||
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
|
||||
* - judgment: 比对布尔值
|
||||
* - text: 不自动判分(返回 null)
|
||||
*
|
||||
* @param questionType 题目类型
|
||||
* @param content 题目内容(或变式题内容)
|
||||
* @param studentAnswer 学生答案
|
||||
* @returns 是否正确(null 表示无法自动判分)
|
||||
*/
|
||||
function autoGradeAnswer(
|
||||
questionType: string,
|
||||
content: unknown,
|
||||
studentAnswer: unknown,
|
||||
): boolean | null {
|
||||
if (questionType === "single_choice" || questionType === "multiple_choice") {
|
||||
const correctIds = extractChoiceCorrectIds(content)
|
||||
if (correctIds.length === 0) return null
|
||||
|
||||
const studentIds = normalizeAnswerToIds(studentAnswer)
|
||||
if (studentIds.length === 0) return false
|
||||
|
||||
if (questionType === "single_choice") {
|
||||
return studentIds.length === 1 && studentIds[0] === correctIds[0]
|
||||
}
|
||||
|
||||
// multiple_choice: 集合比对
|
||||
if (studentIds.length !== correctIds.length) return false
|
||||
const correctSet = new Set(correctIds)
|
||||
return studentIds.every((id) => correctSet.has(id))
|
||||
}
|
||||
|
||||
if (questionType === "judgment") {
|
||||
const correctAnswer = extractJudgmentCorrectAnswer(content)
|
||||
if (correctAnswer === null) return null
|
||||
|
||||
const studentBool = normalizeAnswerToBool(studentAnswer)
|
||||
if (studentBool === null) return null
|
||||
|
||||
return studentBool === correctAnswer
|
||||
}
|
||||
|
||||
// text 题型不自动判分
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取选择题正确选项 ID 列表。
|
||||
*/
|
||||
function extractChoiceCorrectIds(content: unknown): string[] {
|
||||
if (!isRecord(content)) return []
|
||||
|
||||
const options = content.options
|
||||
if (!Array.isArray(options)) return []
|
||||
|
||||
return options
|
||||
.filter((opt: unknown) => isRecord(opt) && opt.isCorrect === true)
|
||||
.map((opt: unknown) => {
|
||||
const record = opt as Record<string, unknown>
|
||||
return typeof record.id === "string" ? record.id : ""
|
||||
})
|
||||
.filter((id: string) => id.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* 从题目内容中提取判断题正确答案。
|
||||
*/
|
||||
function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
|
||||
if (!isRecord(content)) return null
|
||||
|
||||
const answer = content.answer
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为选项 ID 列表。
|
||||
*/
|
||||
function normalizeAnswerToIds(answer: unknown): string[] {
|
||||
if (typeof answer === "string") return [answer]
|
||||
if (Array.isArray(answer)) {
|
||||
return answer.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (isRecord(answer)) {
|
||||
const ids = answer.selectedIds
|
||||
if (Array.isArray(ids)) {
|
||||
return ids.filter((v): v is string => typeof v === "string")
|
||||
}
|
||||
if (typeof answer.id === "string") return [answer.id]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 将学生答案归一化为布尔值。
|
||||
*/
|
||||
function normalizeAnswerToBool(answer: unknown): boolean | null {
|
||||
if (typeof answer === "boolean") return answer
|
||||
if (typeof answer === "string") {
|
||||
const lower = answer.toLowerCase()
|
||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
// 自动判分、答案归一化等纯函数已抽取至 lib/grading.ts,便于单测与复用。
|
||||
// 会话统计累加逻辑已内联到 submitPracticeSession 事务中,确保原子性。
|
||||
|
||||
13
src/modules/adaptive-practice/lib/answer-utils.ts
Normal file
13
src/modules/adaptive-practice/lib/answer-utils.ts
Normal 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")
|
||||
}
|
||||
58
src/modules/adaptive-practice/lib/errors.ts
Normal file
58
src/modules/adaptive-practice/lib/errors.ts
Normal 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"),
|
||||
}
|
||||
142
src/modules/adaptive-practice/lib/grading.ts
Normal file
142
src/modules/adaptive-practice/lib/grading.ts
Normal 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 ?? ""),
|
||||
}))
|
||||
}
|
||||
114
src/modules/adaptive-practice/lib/source-meta.ts
Normal file
114
src/modules/adaptive-practice/lib/source-meta.ts
Normal 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
|
||||
}
|
||||
}
|
||||
105
src/modules/adaptive-practice/lib/type-guards.ts
Normal file
105
src/modules/adaptive-practice/lib/type-guards.ts
Normal 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
|
||||
}
|
||||
@@ -14,29 +14,83 @@ export const PracticeTypeSchema = z.enum([
|
||||
export const PracticeAnswerStatusSchema = z.enum(["pending", "answered", "skipped"])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 来源元数据验证
|
||||
// 来源元数据验证(判别式联合类型,替代 z.record(z.string(), z.unknown()))
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 错题重做练习来源。
|
||||
*
|
||||
* practiceType="error_variant" 时使用。
|
||||
*/
|
||||
export const ErrorVariantSourceMetaSchema = z.object({
|
||||
errorBookItemIds: z.array(z.string().min(1)).min(1),
|
||||
sourceQuestionIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* 知识点专项练习来源。
|
||||
*
|
||||
* practiceType="knowledge_point" 时使用。
|
||||
*/
|
||||
export const KnowledgePointSourceMetaSchema = z.object({
|
||||
knowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||
difficulty: z.number().int().min(1).max(5).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 薄弱章节练习来源。
|
||||
*
|
||||
* practiceType="weak_chapter" 时使用。
|
||||
* chapterId 可选——未传时跨所有章节自动识别薄弱知识点。
|
||||
* weakKnowledgePointIds 可选——未传时由后端自动识别薄弱知识点。
|
||||
*/
|
||||
export const WeakChapterSourceMetaSchema = z.object({
|
||||
chapterId: z.string().min(1),
|
||||
weakKnowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||
chapterId: z.string().min(1).optional(),
|
||||
weakKnowledgePointIds: z.array(z.string().min(1)).min(1).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* AI 推荐练习来源。
|
||||
*
|
||||
* practiceType="ai_recommended" 时使用。
|
||||
* reason 为枚举字符串(业务数据,不应直接存翻译文本)。
|
||||
*/
|
||||
export const AiRecommendedReasonSchema = z.enum([
|
||||
"student_initiated",
|
||||
"teacher_assigned",
|
||||
"parent_suggested",
|
||||
])
|
||||
|
||||
export const AiRecommendedSourceMetaSchema = z.object({
|
||||
recommendedKnowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||
reason: z.string().min(1),
|
||||
reason: AiRecommendedReasonSchema,
|
||||
})
|
||||
|
||||
/**
|
||||
* 判别式联合类型 schema。
|
||||
*
|
||||
* 通过 practiceType 字段判别,确保 sourceMeta 结构与练习类型匹配。
|
||||
* 替代原先的 z.record(z.string(), z.unknown()) 弱类型校验。
|
||||
*/
|
||||
export const PracticeSourceMetaSchema = z.discriminatedUnion("practiceType", [
|
||||
z.object({
|
||||
practiceType: z.literal("error_variant"),
|
||||
sourceMeta: ErrorVariantSourceMetaSchema,
|
||||
}),
|
||||
z.object({
|
||||
practiceType: z.literal("knowledge_point"),
|
||||
sourceMeta: KnowledgePointSourceMetaSchema,
|
||||
}),
|
||||
z.object({
|
||||
practiceType: z.literal("weak_chapter"),
|
||||
sourceMeta: WeakChapterSourceMetaSchema,
|
||||
}),
|
||||
z.object({
|
||||
practiceType: z.literal("ai_recommended"),
|
||||
sourceMeta: AiRecommendedSourceMetaSchema,
|
||||
}),
|
||||
])
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Action 输入验证
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -72,3 +126,4 @@ export type CreatePracticeSessionInput = z.infer<typeof CreatePracticeSessionSch
|
||||
export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema>
|
||||
export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema>
|
||||
export type AbandonPracticeSessionInput = z.infer<typeof AbandonPracticeSessionSchema>
|
||||
export type AiRecommendedReason = z.infer<typeof AiRecommendedReasonSchema>
|
||||
|
||||
160
src/modules/adaptive-practice/services/practice-service.tsx
Normal file
160
src/modules/adaptive-practice/services/practice-service.tsx
Normal 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)
|
||||
}
|
||||
@@ -33,19 +33,28 @@ export interface KnowledgePointSourceMeta {
|
||||
|
||||
/** 薄弱章节练习来源 */
|
||||
export interface WeakChapterSourceMeta {
|
||||
chapterId: string
|
||||
/** 自动识别的薄弱知识点 */
|
||||
weakKnowledgePointIds: string[]
|
||||
/** 章节 ID(可选;未传时跨所有章节自动识别薄弱知识点) */
|
||||
chapterId?: string
|
||||
/** 自动识别的薄弱知识点(可选;未传时由 data-access 自动识别) */
|
||||
weakKnowledgePointIds?: string[]
|
||||
}
|
||||
|
||||
/** AI 推荐练习来源 */
|
||||
export interface AiRecommendedSourceMeta {
|
||||
/** AI 推荐的知识点列表 */
|
||||
recommendedKnowledgePointIds: string[]
|
||||
/** 推荐理由 */
|
||||
reason: string
|
||||
/** 推荐理由(枚举值,UI 层翻译) */
|
||||
reason: AiRecommendedReason
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 推荐理由枚举。
|
||||
*
|
||||
* 业务数据,存数据库,UI 层通过 t(`reasons.${reason}`) 查 i18n。
|
||||
* 严禁存翻译文本到数据库。
|
||||
*/
|
||||
export type AiRecommendedReason = "student_initiated" | "teacher_assigned" | "parent_suggested"
|
||||
|
||||
export type PracticeSourceMeta =
|
||||
| ErrorVariantSourceMeta
|
||||
| KnowledgePointSourceMeta
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
WeaknessAnalysisInputSchema,
|
||||
ChildSummaryInputSchema,
|
||||
StudyPathInputSchema,
|
||||
ExplainErrorInputSchema,
|
||||
} from "./schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
@@ -37,6 +38,8 @@ import type {
|
||||
StudyPathInput,
|
||||
StudyPathResult,
|
||||
AiUsageStats,
|
||||
ExplainErrorInput,
|
||||
ExplainErrorResult,
|
||||
} from "./types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -327,15 +330,14 @@ export async function recommendStudyPathAction(
|
||||
|
||||
// 同步填充 currentMastery(若未传入)
|
||||
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
|
||||
serviceInput.currentMastery = kps
|
||||
.filter((kp) => masteryMap.has(kp.id))
|
||||
.map((kp) => {
|
||||
const m = masteryMap.get(kp.id)!
|
||||
return {
|
||||
serviceInput.currentMastery = kps.flatMap((kp) => {
|
||||
const m = masteryMap.get(kp.id)
|
||||
if (!m) return []
|
||||
return [{
|
||||
knowledgePoint: kp.name,
|
||||
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
|
||||
errorCount: m.totalQuestions - m.correctQuestions,
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -376,6 +378,38 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.chatFailed") }
|
||||
return { success: false, message: t("error.statsFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 错题 AI 解释(P2-1 新增)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function explainErrorAction(
|
||||
input: ExplainErrorInput
|
||||
): Promise<ActionState<ExplainErrorResult>> {
|
||||
const t = await getTranslations("ai")
|
||||
try {
|
||||
const ctx = await requireAiPermission(
|
||||
Permissions.AI_CHAT,
|
||||
Permissions.ERROR_BOOK_READ
|
||||
)
|
||||
const parsed = ExplainErrorInputSchema.safeParse(input)
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: t("error.invalidInput") }
|
||||
}
|
||||
|
||||
const service = createAiService(ctx.userId)
|
||||
const result = await safeAiCall(() => service.explainError(parsed.data))
|
||||
if (!result.ok) {
|
||||
return { success: false, message: result.message }
|
||||
}
|
||||
return { success: true, data: result.data }
|
||||
} catch (error) {
|
||||
if (error instanceof PermissionDeniedError) {
|
||||
return { success: false, message: error.message }
|
||||
}
|
||||
return { success: false, message: t("error.analysisFailed") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,11 +229,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
|
||||
contextMessage: "Current page: Homework grading view",
|
||||
contextMessage: t("chat.contextMessage.teacherGrading"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.teacher.0"),
|
||||
"What are common mistakes in this type of question?",
|
||||
"How should I give constructive feedback?",
|
||||
t("chat.suggestedPrompts.context.teacherGrading.0"),
|
||||
t("chat.suggestedPrompts.context.teacherGrading.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -243,11 +243,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
|
||||
contextMessage: "Current page: Lesson plan editor",
|
||||
contextMessage: t("chat.contextMessage.teacherLesson"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.teacher.1"),
|
||||
"Suggest a hook for this lesson",
|
||||
"What are some differentiation strategies?",
|
||||
t("chat.suggestedPrompts.context.teacherLesson.0"),
|
||||
t("chat.suggestedPrompts.context.teacherLesson.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -257,11 +257,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
|
||||
contextMessage: "Current page: Exam builder",
|
||||
contextMessage: t("chat.contextMessage.teacherExam"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.teacher.2"),
|
||||
"Generate a question on this topic",
|
||||
"Analyze the difficulty distribution",
|
||||
t("chat.suggestedPrompts.context.teacherExam.0"),
|
||||
t("chat.suggestedPrompts.context.teacherExam.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -271,7 +271,7 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
|
||||
contextMessage: "Current page: Error book (student view)",
|
||||
contextMessage: t("chat.contextMessage.studentErrorBook"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.student.0"),
|
||||
t("chat.suggestedPrompts.student.1"),
|
||||
@@ -285,11 +285,11 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
|
||||
contextMessage: "Current page: Student homework view",
|
||||
contextMessage: t("chat.contextMessage.studentHomework"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.student.0"),
|
||||
"Give me a hint, not the answer",
|
||||
"Help me understand this concept",
|
||||
t("chat.suggestedPrompts.context.studentHomework.0"),
|
||||
t("chat.suggestedPrompts.context.studentHomework.1"),
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -299,7 +299,7 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
|
||||
contextMessage: "Current page: Parent dashboard",
|
||||
contextMessage: t("chat.contextMessage.parent"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.parent.0"),
|
||||
t("chat.suggestedPrompts.parent.1"),
|
||||
@@ -312,7 +312,7 @@ function inferContextFromPath(
|
||||
return {
|
||||
systemPrompt:
|
||||
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
|
||||
contextMessage: "Current page: Admin dashboard",
|
||||
contextMessage: t("chat.contextMessage.admin"),
|
||||
suggestedPrompts: [
|
||||
t("chat.suggestedPrompts.admin.0"),
|
||||
t("chat.suggestedPrompts.admin.1"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
type ChartConfig,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { AiChartSpecSchema } from "../schema"
|
||||
|
||||
/**
|
||||
* AI 图表渲染器
|
||||
@@ -111,14 +113,15 @@ interface AiChartRendererProps {
|
||||
|
||||
/**
|
||||
* 解析 JSON 规格,失败时返回 null
|
||||
*
|
||||
* 使用 Zod schema 校验,避免 as 断言。
|
||||
*/
|
||||
function parseSpec(spec: string): AiChartSpec | null {
|
||||
try {
|
||||
const parsed = JSON.parse(spec) as AiChartSpec
|
||||
if (!parsed || !Array.isArray(parsed.data) || !Array.isArray(parsed.series)) {
|
||||
return null
|
||||
}
|
||||
return parsed
|
||||
const parsed: unknown = JSON.parse(spec)
|
||||
const result = AiChartSpecSchema.safeParse(parsed)
|
||||
if (!result.success) return null
|
||||
return result.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -139,12 +142,13 @@ export function AiChartRenderer({
|
||||
spec,
|
||||
className,
|
||||
}: AiChartRendererProps): React.ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
const parsed = useMemo(() => parseSpec(spec), [spec])
|
||||
|
||||
if (!parsed) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||
图表数据格式错误,无法渲染
|
||||
{t("chart.parseError")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"use client"
|
||||
|
||||
import { Component, type ReactNode } from "react"
|
||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
/**
|
||||
* AI 专用 Error Boundary
|
||||
*
|
||||
* 薄包装:委托给共享 SectionErrorBoundary,使用 ai 命名空间。
|
||||
* 保留同名导出以兼容现有 import。
|
||||
*/
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import type { ReactNode } from "react"
|
||||
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||
|
||||
type AiErrorBoundaryProps = {
|
||||
children: ReactNode
|
||||
@@ -15,74 +18,14 @@ type AiErrorBoundaryProps = {
|
||||
onError?: (error: Error, info: unknown) => void
|
||||
}
|
||||
|
||||
type AiErrorBoundaryState = {
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 专用 Error Boundary
|
||||
*
|
||||
* 包裹所有 AI 数据区块,防止单个 AI 调用失败导致整页崩溃。
|
||||
* 提供重试按钮与友好的错误提示。
|
||||
*/
|
||||
export class AiErrorBoundary extends Component<
|
||||
AiErrorBoundaryProps,
|
||||
AiErrorBoundaryState
|
||||
> {
|
||||
constructor(props: AiErrorBoundaryProps) {
|
||||
super(props)
|
||||
this.state = { error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): AiErrorBoundaryState {
|
||||
return { error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: unknown): void {
|
||||
if (this.props.onError) {
|
||||
this.props.onError(error, info)
|
||||
}
|
||||
}
|
||||
|
||||
private handleReset = (): void => {
|
||||
this.setState({ error: null })
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback(this.state.error, this.handleReset)
|
||||
}
|
||||
return <DefaultAiErrorFallback error={this.state.error} onReset={this.handleReset} />
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultAiErrorFallback({
|
||||
error,
|
||||
onReset,
|
||||
}: {
|
||||
error: Error
|
||||
onReset: () => void
|
||||
}): ReactNode {
|
||||
const t = useTranslations("ai")
|
||||
export function AiErrorBoundary({
|
||||
children,
|
||||
fallback,
|
||||
onError,
|
||||
}: AiErrorBoundaryProps): ReactNode {
|
||||
return (
|
||||
<Card className="border-destructive/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t("error.boundaryTitle")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("error.boundaryDescription")}</p>
|
||||
<p className="text-xs text-muted-foreground/70 font-mono">{error.message}</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
{t("error.retry")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<SectionErrorBoundary namespace="ai" fallback={fallback} onError={onError}>
|
||||
{children}
|
||||
</SectionErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -28,6 +28,28 @@ const CHART_TYPES: Record<string, AiChartType> = {
|
||||
"chart:radar": "radar",
|
||||
}
|
||||
|
||||
/**
|
||||
* 类型守卫:判断字符串是否为合法的 AiChartType
|
||||
*/
|
||||
function isAiChartType(value: string): value is AiChartType {
|
||||
return value === "bar" || value === "line" || value === "pie" || value === "radar"
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 language 标识中解析图表类型
|
||||
*/
|
||||
function resolveChartType(lang: string): AiChartType | undefined {
|
||||
// 优先查表(兼容 "chart:bar" 形式)
|
||||
const fromTable = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
|
||||
if (fromTable) return fromTable
|
||||
// 兼容 "chart:bar" 前缀形式
|
||||
if (lang.startsWith(CHART_LANG_PREFIX)) {
|
||||
const suffix = lang.slice(CHART_LANG_PREFIX.length)
|
||||
return isAiChartType(suffix) ? suffix : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* AI Markdown 渲染器
|
||||
*
|
||||
@@ -87,12 +109,9 @@ function AiMarkdownRendererImpl({
|
||||
|
||||
// 检测图表代码块:language-chart:bar / chart:line / chart:pie / chart:radar
|
||||
const lang = codeClass?.replace("language-", "").trim() ?? ""
|
||||
const chartType = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
|
||||
?? (lang.startsWith(CHART_LANG_PREFIX)
|
||||
? (lang.slice(CHART_LANG_PREFIX.length) as AiChartType)
|
||||
: undefined)
|
||||
const chartType = resolveChartType(lang)
|
||||
|
||||
if (chartType && (chartType === "bar" || chartType === "line" || chartType === "pie" || chartType === "radar")) {
|
||||
if (chartType) {
|
||||
const raw = String(children).replace(/\n$/, "")
|
||||
return <AiChartRenderer type={chartType} spec={raw} />
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AiProviderSelector({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("provider.label")}</FormLabel>
|
||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
|
||||
<Select value={typeof field.value === "string" ? field.value : ""} onValueChange={field.onChange} disabled={loading}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect } from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
@@ -33,7 +33,7 @@ export function AiUsageDashboard(): React.ReactNode {
|
||||
const [stats, setStats] = useState<AiUsageStats | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadStats = async (): Promise<void> => {
|
||||
const loadStats = useCallback(async (): Promise<void> => {
|
||||
if (!aiClient.getAiUsageStats) return
|
||||
setLoading(true)
|
||||
try {
|
||||
@@ -41,19 +41,18 @@ export function AiUsageDashboard(): React.ReactNode {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data)
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.chatFailed"))
|
||||
toast.error(result.message ?? t("error.statsFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.chatFailed"))
|
||||
toast.error(t("error.statsFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [aiClient, t])
|
||||
|
||||
useEffect(() => {
|
||||
void loadStats()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [loadStats])
|
||||
|
||||
const statCards = stats
|
||||
? [
|
||||
|
||||
54
src/modules/ai/context/create-ai-client-service.ts
Normal file
54
src/modules/ai/context/create-ai-client-service.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
130
src/modules/ai/hooks/use-drag-position.ts
Normal file
130
src/modules/ai/hooks/use-drag-position.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,50 +2,43 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
|
||||
type Position = { x: number; y: number }
|
||||
import {
|
||||
BALL_SIZE,
|
||||
HIDE_THRESHOLD,
|
||||
MARGIN,
|
||||
type Position,
|
||||
clampPosition,
|
||||
getDefaultPosition,
|
||||
savePosition,
|
||||
usePositionPersistence,
|
||||
} from "./use-position-persistence"
|
||||
import { useDragPosition } from "./use-drag-position"
|
||||
|
||||
const STORAGE_KEY = "ai-widget-position"
|
||||
const HIDE_THRESHOLD = 0.55
|
||||
const BALL_SIZE = 56
|
||||
const MARGIN = 16
|
||||
|
||||
function clampPosition(pos: Position): Position {
|
||||
if (typeof window === "undefined") return pos
|
||||
const maxX = window.innerWidth - BALL_SIZE - MARGIN
|
||||
const maxY = window.innerHeight - BALL_SIZE - MARGIN
|
||||
return {
|
||||
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
|
||||
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
|
||||
}
|
||||
/**
|
||||
* 计算吸附到最近边缘后的 X 坐标
|
||||
*/
|
||||
function snapToEdge(x: number): number {
|
||||
if (typeof window === "undefined") return x
|
||||
const w = window.innerWidth
|
||||
const centerX = x + BALL_SIZE / 2
|
||||
const distanceToLeft = centerX
|
||||
const distanceToRight = w - centerX
|
||||
return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - MARGIN
|
||||
}
|
||||
|
||||
function loadPosition(): Position {
|
||||
if (typeof window === "undefined") {
|
||||
return { x: 9999, y: 9999 }
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as Partial<Position>
|
||||
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
|
||||
return clampPosition({ x: parsed.x, y: parsed.y })
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const x = window.innerWidth - BALL_SIZE - MARGIN * 2
|
||||
const y = window.innerHeight - BALL_SIZE - MARGIN * 4
|
||||
return clampPosition({ x, y })
|
||||
}
|
||||
|
||||
function savePosition(pos: Position): void {
|
||||
if (typeof window === "undefined") return
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
/**
|
||||
* 计算半隐藏时的视觉偏移量
|
||||
*/
|
||||
function calculateHiddenOffset(
|
||||
position: Position,
|
||||
hidden: boolean,
|
||||
hovered: boolean,
|
||||
dragging: boolean
|
||||
): number {
|
||||
if (!hidden || hovered || dragging) return 0
|
||||
return position.x <= MARGIN + 2
|
||||
? -(BALL_SIZE * HIDE_THRESHOLD)
|
||||
: BALL_SIZE * HIDE_THRESHOLD
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,135 +50,69 @@ function savePosition(pos: Position): void {
|
||||
* - 单击(未发生拖动)触发 onClick
|
||||
* - 位置持久化到 localStorage
|
||||
* - 窗口 resize 时自动校正位置
|
||||
*
|
||||
* V3:拆分为 use-position-persistence + use-drag-position + 本 hook 组合
|
||||
*/
|
||||
export function useFloatingBall(onClick: () => void) {
|
||||
// 服务端与客户端首次渲染一致(position 在屏幕外,不渲染按钮)
|
||||
// 在 useEffect 中加载真实位置,避免 hydration mismatch
|
||||
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
|
||||
export function useFloatingBall(onClick: () => void): {
|
||||
position: Position
|
||||
hidden: boolean
|
||||
dragging: boolean
|
||||
hovered: boolean
|
||||
hiddenOffset: number
|
||||
handlers: {
|
||||
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||
onPointerCancel: () => void
|
||||
onMouseEnter: () => void
|
||||
onMouseLeave: () => void
|
||||
}
|
||||
show: () => void
|
||||
resetPosition: () => void
|
||||
} {
|
||||
const { position, setPosition } = usePositionPersistence()
|
||||
const [hidden, setHidden] = useState(false)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const [hovered, setHovered] = useState(false)
|
||||
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
|
||||
const justHiddenRef = useRef(false)
|
||||
|
||||
const dragStateRef = useRef({
|
||||
active: false,
|
||||
moved: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
originX: 0,
|
||||
originY: 0,
|
||||
pointerId: -1,
|
||||
})
|
||||
const onClickRef = useRef(onClick)
|
||||
useEffect(() => {
|
||||
onClickRef.current = onClick
|
||||
}, [onClick])
|
||||
|
||||
// 初始化位置:在客户端 mount 后加载真实位置
|
||||
// 避免 hydration mismatch(服务端与客户端位置不同)
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setPosition(loadPosition())
|
||||
}, [])
|
||||
|
||||
// 窗口 resize 时校正
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setPosition((prev) => clampPosition(prev))
|
||||
}
|
||||
window.addEventListener("resize", handleResize)
|
||||
return () => window.removeEventListener("resize", handleResize)
|
||||
}, [])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
// 仅主键响应拖拽
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return
|
||||
const state = dragStateRef.current
|
||||
state.active = true
|
||||
state.moved = false
|
||||
state.startX = e.clientX
|
||||
state.startY = e.clientY
|
||||
state.originX = position.x
|
||||
state.originY = position.y
|
||||
state.pointerId = e.pointerId
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const handleDragStart = useCallback((): void => {
|
||||
setHidden(false)
|
||||
setDragging(true)
|
||||
},
|
||||
[position]
|
||||
)
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const state = dragStateRef.current
|
||||
if (!state.active || e.pointerId !== state.pointerId) return
|
||||
const dx = e.clientX - state.startX
|
||||
const dy = e.clientY - state.startY
|
||||
if (!state.moved && Math.abs(dx) + Math.abs(dy) < 4) return
|
||||
state.moved = true
|
||||
const next = clampPosition({
|
||||
x: state.originX + dx,
|
||||
y: state.originY + dy,
|
||||
})
|
||||
setPosition(next)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handlePointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const state = dragStateRef.current
|
||||
if (!state.active || e.pointerId !== state.pointerId) return
|
||||
state.active = false
|
||||
setDragging(false)
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleRelease = useCallback(
|
||||
(moved: boolean): void => {
|
||||
// 未移动 → 视为点击
|
||||
if (!state.moved) {
|
||||
if (!moved) {
|
||||
onClickRef.current()
|
||||
return
|
||||
}
|
||||
|
||||
// 移动了 → 吸附到最近边缘
|
||||
const w = window.innerWidth
|
||||
const centerX = position.x + BALL_SIZE / 2
|
||||
const distanceToLeft = centerX
|
||||
const distanceToRight = w - centerX
|
||||
const snapLeft = distanceToLeft < distanceToRight
|
||||
const snappedX = snapLeft ? MARGIN : w - BALL_SIZE - MARGIN
|
||||
|
||||
// 判断是否半隐藏:吸附后位置贴近边缘
|
||||
const shouldHide = true
|
||||
|
||||
const snappedX = snapToEdge(position.x)
|
||||
const finalPos = clampPosition({ x: snappedX, y: position.y })
|
||||
setPosition(finalPos)
|
||||
savePosition(finalPos)
|
||||
setHidden(shouldHide)
|
||||
setHidden(true)
|
||||
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
|
||||
justHiddenRef.current = shouldHide
|
||||
justHiddenRef.current = true
|
||||
// 清除 hovered,确保 hiddenOffset 生效
|
||||
setHovered(false)
|
||||
},
|
||||
[position]
|
||||
[position, setPosition]
|
||||
)
|
||||
|
||||
const handlePointerCancel = useCallback(() => {
|
||||
const state = dragStateRef.current
|
||||
state.active = false
|
||||
setDragging(false)
|
||||
}, [])
|
||||
const { dragging, handlers: dragHandlers } = useDragPosition(
|
||||
position,
|
||||
setPosition,
|
||||
{ onDragStart: handleDragStart, onRelease: handleRelease }
|
||||
)
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
const handleMouseEnter = useCallback((): void => {
|
||||
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
|
||||
if (justHiddenRef.current) {
|
||||
justHiddenRef.current = false
|
||||
@@ -195,33 +122,26 @@ export function useFloatingBall(onClick: () => void) {
|
||||
if (hidden) setHidden(false)
|
||||
}, [hidden])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
const handleMouseLeave = useCallback((): void => {
|
||||
setHovered(false)
|
||||
// 离开后清除 justHidden 标记,下次进入可正常展开
|
||||
justHiddenRef.current = false
|
||||
}, [])
|
||||
|
||||
const show = useCallback(() => {
|
||||
const show = useCallback((): void => {
|
||||
justHiddenRef.current = false
|
||||
setHidden(false)
|
||||
}, [])
|
||||
const resetPosition = useCallback(() => {
|
||||
justHiddenRef.current = false
|
||||
const fresh = typeof window === "undefined"
|
||||
? loadPosition()
|
||||
: clampPosition({
|
||||
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
|
||||
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
|
||||
})
|
||||
setPosition(fresh)
|
||||
savePosition(fresh)
|
||||
setHidden(false)
|
||||
}, [])
|
||||
|
||||
// 半隐藏时的视觉偏移量
|
||||
const hiddenOffset = hidden && !hovered && !dragging
|
||||
? (position.x <= MARGIN + 2 ? -(BALL_SIZE * HIDE_THRESHOLD) : BALL_SIZE * HIDE_THRESHOLD)
|
||||
: 0
|
||||
const resetPosition = useCallback((): void => {
|
||||
justHiddenRef.current = false
|
||||
const fresh = getDefaultPosition()
|
||||
setPosition(fresh)
|
||||
savePosition(fresh)
|
||||
setHidden(false)
|
||||
}, [setPosition])
|
||||
|
||||
const hiddenOffset = calculateHiddenOffset(position, hidden, hovered, dragging)
|
||||
|
||||
return {
|
||||
position,
|
||||
@@ -230,10 +150,7 @@ export function useFloatingBall(onClick: () => void) {
|
||||
hovered,
|
||||
hiddenOffset,
|
||||
handlers: {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
onPointerCancel: handlePointerCancel,
|
||||
...dragHandlers,
|
||||
onMouseEnter: handleMouseEnter,
|
||||
onMouseLeave: handleMouseLeave,
|
||||
},
|
||||
|
||||
99
src/modules/ai/hooks/use-position-persistence.ts
Normal file
99
src/modules/ai/hooks/use-position-persistence.ts
Normal 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 }
|
||||
}
|
||||
@@ -225,3 +225,50 @@ export const StudyPathResultSchema = z.object({
|
||||
summary: z.string().min(1),
|
||||
motivation: z.string().min(1),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 错题 AI 解释校验
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ExplainErrorInputSchema = z.object({
|
||||
questionText: z.string().min(1).max(4000),
|
||||
questionType: z.string().min(1),
|
||||
studentAnswer: z.string().min(1).max(8000),
|
||||
correctAnswer: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
knowledgePointIds: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
export const ExplainErrorResultSchema = z.object({
|
||||
errorAnalysis: z.string().min(1),
|
||||
correctApproach: z.string().min(1),
|
||||
keyConcepts: z.array(z.string().min(1)),
|
||||
preventionTips: z.array(z.string().min(1)),
|
||||
practiceSuggestion: z.string().min(1),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 图表规格校验(用于 ai-chart-renderer.tsx 解析 AI 返回的图表 JSON)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const AiChartSeriesSchema = z.object({
|
||||
dataKey: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
color: z.string().optional(),
|
||||
fillOpacity: z.number().optional(),
|
||||
strokeWidth: z.number().optional(),
|
||||
strokeDasharray: z.string().optional(),
|
||||
})
|
||||
|
||||
export const AiChartSpecSchema = z.object({
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
type: z.enum(["bar", "line", "pie", "radar"]).optional(),
|
||||
data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
|
||||
xKey: z.string().optional(),
|
||||
angleKey: z.string().optional(),
|
||||
series: z.array(AiChartSeriesSchema),
|
||||
yDomain: z.tuple([z.number(), z.number()]).optional(),
|
||||
height: z.number().optional(),
|
||||
showLegend: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
||||
CHILD_SUMMARY_SYSTEM_PROMPT,
|
||||
STUDY_PATH_SYSTEM_PROMPT,
|
||||
EXPLAIN_ERROR_SYSTEM_PROMPT,
|
||||
} from "./prompt-templates"
|
||||
import { withAiTracking } from "./usage-tracker"
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
WeaknessAnalysisResultSchema,
|
||||
ChildSummaryResultSchema,
|
||||
StudyPathResultSchema,
|
||||
ExplainErrorResultSchema,
|
||||
} from "../schema"
|
||||
import type {
|
||||
AiChatMessage,
|
||||
@@ -41,6 +43,8 @@ import type {
|
||||
ChildSummaryResult,
|
||||
StudyPathInput,
|
||||
StudyPathResult,
|
||||
ExplainErrorInput,
|
||||
ExplainErrorResult,
|
||||
} from "../types"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -144,9 +148,10 @@ const callAi = async (
|
||||
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
|
||||
...(options?.providerId ? { providerId: options.providerId } : {}),
|
||||
})
|
||||
// 从 unknown 类型安全提取 total_tokens(避免 as 断言)
|
||||
const tokenUsage =
|
||||
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
|
||||
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
|
||||
? Number(result.usage.total_tokens ?? 0)
|
||||
: undefined
|
||||
return { content: result.content, tokenUsage }
|
||||
}
|
||||
@@ -169,7 +174,11 @@ export class DefaultAiService implements AiService {
|
||||
...options,
|
||||
temperature: options?.temperature ?? 0.7,
|
||||
})
|
||||
return { result: { content, usage: null }, tokenUsage }
|
||||
// usage 字段返回 token 用量对象(unknown 类型),便于调用方按需类型缩小
|
||||
return {
|
||||
result: { content, usage: tokenUsage !== undefined ? { total_tokens: tokenUsage } : null },
|
||||
tokenUsage,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -192,9 +201,10 @@ export class DefaultAiService implements AiService {
|
||||
{ temperature: 0.5, maxTokens: 3000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
// 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
|
||||
const list =
|
||||
parsed && typeof parsed === "object" && "questions" in parsed
|
||||
? (parsed as Record<string, unknown>).questions
|
||||
? parsed.questions
|
||||
: parsed
|
||||
const validated = SimilarQuestionListSchema.safeParse(list)
|
||||
if (!validated.success) return { result: [] }
|
||||
@@ -413,6 +423,39 @@ export class DefaultAiService implements AiService {
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
|
||||
async explainError(input: ExplainErrorInput): Promise<ExplainErrorResult> {
|
||||
return withAiTracking(this.userId, "explain_error", undefined, async () => {
|
||||
const userLines = [
|
||||
`Question Type: ${input.questionType}`,
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
input.knowledgePointIds?.length
|
||||
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
|
||||
: "",
|
||||
`Question:\n${input.questionText}`,
|
||||
`Student Answer:\n${input.studentAnswer}`,
|
||||
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
|
||||
].filter((line) => line.length > 0)
|
||||
const { content } = await callAi(
|
||||
buildChatMessages(EXPLAIN_ERROR_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||
{ temperature: 0.4, maxTokens: 2000 }
|
||||
)
|
||||
const parsed = extractJson(content)
|
||||
const validated = ExplainErrorResultSchema.safeParse(parsed)
|
||||
if (!validated.success) {
|
||||
return {
|
||||
result: {
|
||||
errorAnalysis: "Unable to analyze the error at this time.",
|
||||
correctApproach: "Please consult your teacher for help.",
|
||||
keyConcepts: [],
|
||||
preventionTips: [],
|
||||
practiceSuggestion: "Review the relevant chapter and try again.",
|
||||
},
|
||||
}
|
||||
}
|
||||
return { result: validated.data }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -275,3 +275,26 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
|
||||
"- motivation should be age-appropriate and encouraging.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
export const EXPLAIN_ERROR_SYSTEM_PROMPT = [
|
||||
"You are an expert K12 tutor specializing in helping students understand their mistakes.",
|
||||
"Analyze the student's error and provide a clear, encouraging explanation.",
|
||||
"Return JSON only without markdown.",
|
||||
"Output schema:",
|
||||
"{",
|
||||
' "errorAnalysis": "detailed analysis of why the student made this error",',
|
||||
' "correctApproach": "step-by-step correct solution approach",',
|
||||
' "keyConcepts": ["list of key concepts the student needs to review"],',
|
||||
' "preventionTips": ["tips to avoid similar mistakes in the future"],',
|
||||
' "practiceSuggestion": "specific practice recommendation"',
|
||||
"}",
|
||||
"Rules:",
|
||||
"- Use age-appropriate language for K12 students.",
|
||||
"- Be encouraging and constructive, never dismissive.",
|
||||
"- errorAnalysis should identify the specific misconception, not just say 'wrong'.",
|
||||
"- correctApproach should be step-by-step and easy to follow.",
|
||||
"- keyConcepts should list 2-5 fundamental concepts.",
|
||||
"- preventionTips should be actionable and specific.",
|
||||
"- practiceSuggestion should recommend a specific type of practice problem.",
|
||||
"Never output placeholders.",
|
||||
].join("\n")
|
||||
|
||||
@@ -5,7 +5,7 @@ import { recordAiEvent } from "../data-access"
|
||||
|
||||
export type AiUsageEvent = {
|
||||
userId: string
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path"
|
||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path" | "explain_error"
|
||||
providerId?: string
|
||||
model?: string
|
||||
success: boolean
|
||||
@@ -23,6 +23,7 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
|
||||
weakness_analysis: "ai.weakness_analysis",
|
||||
child_summary: "ai.child_summary",
|
||||
study_path: "ai.study_path",
|
||||
explain_error: "ai.explain_error",
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -221,6 +221,36 @@ export type AiUsageStats = {
|
||||
}>
|
||||
}
|
||||
|
||||
/** 错题 AI 解释输入 */
|
||||
export type ExplainErrorInput = {
|
||||
/** 题目文本 */
|
||||
questionText: string
|
||||
/** 题目类型 */
|
||||
questionType: string
|
||||
/** 学生错误答案 */
|
||||
studentAnswer: string
|
||||
/** 正确答案 */
|
||||
correctAnswer?: string
|
||||
/** 学科 */
|
||||
subject?: string
|
||||
/** 知识点 ID 列表 */
|
||||
knowledgePointIds?: string[]
|
||||
}
|
||||
|
||||
/** 错题 AI 解释结果 */
|
||||
export type ExplainErrorResult = {
|
||||
/** 错误原因分析 */
|
||||
errorAnalysis: string
|
||||
/** 正确解题思路 */
|
||||
correctApproach: string
|
||||
/** 关键知识点 */
|
||||
keyConcepts: string[]
|
||||
/** 类似错误防范建议 */
|
||||
preventionTips: string[]
|
||||
/** 练习建议 */
|
||||
practiceSuggestion: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI 能力配置(角色驱动)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -236,6 +266,7 @@ export type AiCapability =
|
||||
| "study-path"
|
||||
| "child-summary"
|
||||
| "usage-stats"
|
||||
| "explain-error"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 服务接口
|
||||
@@ -257,6 +288,7 @@ export interface AiService {
|
||||
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
|
||||
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
|
||||
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
|
||||
explainError(input: ExplainErrorInput): Promise<ExplainErrorResult>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -290,6 +322,9 @@ export interface AiClientService {
|
||||
input: StudyPathInput
|
||||
) => Promise<ActionState<StudyPathResult>>
|
||||
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
|
||||
explainError?: (
|
||||
input: ExplainErrorInput
|
||||
) => Promise<ActionState<ExplainErrorResult>>
|
||||
/** 预留埋点接口 */
|
||||
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
||||
}
|
||||
|
||||
382
src/modules/auth/actions.ts
Normal file
382
src/modules/auth/actions.ts
Normal 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 Action(P0-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)
|
||||
}
|
||||
@@ -1,24 +1,42 @@
|
||||
import { GraduationCap } from "lucide-react"
|
||||
|
||||
import { DEFAULT_BRAND_CONFIG, type BrandConfig } from "@/modules/settings/brand-config"
|
||||
|
||||
interface AuthLayoutProps {
|
||||
children: React.ReactNode
|
||||
/** audit-P2-6: 品牌配置(由 Server Component layout 注入,未配置时使用默认值) */
|
||||
brand?: BrandConfig
|
||||
}
|
||||
|
||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
||||
/**
|
||||
* 认证页面布局(audit-P2-6: 支持品牌配置注入)
|
||||
*
|
||||
* 品牌配置通过 props 注入,来源:
|
||||
* - `app/(auth)/layout.tsx`(Server Component)调用 `getBrandConfig()` 获取
|
||||
* - 未配置时使用 `DEFAULT_BRAND_CONFIG` 默认值
|
||||
*/
|
||||
export function AuthLayout({ children, brand = DEFAULT_BRAND_CONFIG }: AuthLayoutProps) {
|
||||
const { schoolName, logoUrl, testimonialQuote, testimonialAuthor } = brand
|
||||
|
||||
return (
|
||||
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex">
|
||||
<div className="absolute inset-0 bg-zinc-900" />
|
||||
<div className="relative z-20 flex items-center text-lg font-medium">
|
||||
{logoUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- brand logo from admin config
|
||||
<img src={logoUrl} alt={schoolName} className="mr-2 h-6 w-6 object-contain" />
|
||||
) : (
|
||||
<GraduationCap className="mr-2 h-6 w-6" />
|
||||
Next_Edu
|
||||
)}
|
||||
{schoolName}
|
||||
</div>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">
|
||||
“This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.”
|
||||
“{testimonialQuote}”
|
||||
</p>
|
||||
<footer className="text-sm">Sofia Davis</footer>
|
||||
<footer className="text-sm">{testimonialAuthor}</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,34 +4,71 @@ import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
import { Alert, AlertDescription } from "@/shared/components/ui/alert"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2, Github, ShieldCheck } from "lucide-react"
|
||||
import { preflightTwoFactorAction } from "@/modules/settings/actions-security"
|
||||
import { Loader2, Github, ShieldCheck, AlertCircle } from "lucide-react"
|
||||
import { preflightTwoFactorAction } from "@/modules/auth/actions"
|
||||
|
||||
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
/** 触发账户锁定提示的失败次数阈值(与后端 PASSWORD_RULES.maxFailedAttempts 对齐) */
|
||||
const LOCK_HINT_THRESHOLD = 3
|
||||
|
||||
/** 简单邮箱格式校验(与服务端 Zod 校验对齐,仅用于前置拦截) */
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
const t = useTranslations("auth.login")
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
const [requiresTwoFactor, setRequiresTwoFactor] = React.useState<boolean>(false)
|
||||
const [totpCode, setTotpCode] = React.useState<string>("")
|
||||
const [error, setError] = React.useState<string>("")
|
||||
const [failedAttempts, setFailedAttempts] = React.useState<number>(0)
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
/** 清除错误并重置为初始状态(用于输入变更时) */
|
||||
const clearError = React.useCallback(() => {
|
||||
setError("")
|
||||
}, [])
|
||||
|
||||
function validateCredentials(email: string, password: string): string | null {
|
||||
if (!email.trim()) return t("errors.emailRequired")
|
||||
if (!EMAIL_RE.test(email)) return t("errors.emailFormat")
|
||||
if (!password) return t("errors.passwordRequired")
|
||||
return null
|
||||
}
|
||||
|
||||
async function onSubmit(event: React.SyntheticEvent) {
|
||||
event.preventDefault()
|
||||
setIsLoading(true)
|
||||
setError("")
|
||||
|
||||
const form = event.currentTarget as HTMLFormElement
|
||||
const formData = new FormData(form)
|
||||
const email = String(formData.get("email") ?? "")
|
||||
const email = String(formData.get("email") ?? "").trim()
|
||||
const password = String(formData.get("password") ?? "")
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
|
||||
|
||||
// 客户端校验:2FA 模式下校验 totpCode,否则校验 email/password
|
||||
if (requiresTwoFactor) {
|
||||
if (!totpCode.trim()) {
|
||||
setError(t("errors.totpRequired"))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const validationError = validateCredentials(email, password)
|
||||
if (validationError) {
|
||||
setError(validationError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
// 首次提交:检查是否需要 2FA
|
||||
if (!requiresTwoFactor) {
|
||||
try {
|
||||
@@ -60,25 +97,39 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
router.push(result?.url ?? callbackUrl)
|
||||
router.refresh()
|
||||
} else {
|
||||
// 2FA 验证码错误时保留 2FA 输入框,允许用户重新输入
|
||||
// 失败计数:仅在非 2FA 模式下累计(2FA 失败不计入账户锁定计数)
|
||||
const nextAttempts = requiresTwoFactor ? failedAttempts : failedAttempts + 1
|
||||
setFailedAttempts(nextAttempts)
|
||||
|
||||
// 根据失败次数与上下文选择错误消息
|
||||
if (requiresTwoFactor) {
|
||||
setError("Invalid 2FA code. Please try again.")
|
||||
setError(t("errors.invalid2fa"))
|
||||
} else if (nextAttempts >= LOCK_HINT_THRESHOLD) {
|
||||
// 达到阈值后切换为更严重的提示(账户可能已被锁定)
|
||||
setError(t("errors.tooManyAttempts"))
|
||||
} else {
|
||||
setError("Invalid email or password.")
|
||||
setError(t("errors.invalidCredentials"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 切换回普通登录模式时重置 2FA 相关状态与失败计数 */
|
||||
function handleBackToLogin() {
|
||||
setRequiresTwoFactor(false)
|
||||
setTotpCode("")
|
||||
setError("")
|
||||
}
|
||||
|
||||
const showLockHint = failedAttempts >= LOCK_HINT_THRESHOLD && !requiresTwoFactor
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-6", className)} {...props}>
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Welcome back
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{requiresTwoFactor
|
||||
? "Enter the 6-digit code from your authenticator app"
|
||||
: "Enter your email to sign in to your account"}
|
||||
{requiresTwoFactor ? t("subtitle2fa") : t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
@@ -86,7 +137,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
{!requiresTwoFactor ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Label htmlFor="email">{t("email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
@@ -96,16 +147,17 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
onChange={clearError}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm font-medium text-muted-foreground hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
{t("forgotPassword")}
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
@@ -114,6 +166,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
disabled={isLoading}
|
||||
onChange={clearError}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -121,7 +174,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="totpCode" className="flex items-center gap-1.5">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
2FA Code
|
||||
{t("totpLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id="totpCode"
|
||||
@@ -132,35 +185,47 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
placeholder="123456"
|
||||
maxLength={8}
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setTotpCode(e.target.value)
|
||||
clearError()
|
||||
}}
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Enter your 6-digit authenticator code or an 8-character backup code.
|
||||
{t("totpHint")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRequiresTwoFactor(false)
|
||||
setTotpCode("")
|
||||
setError("")
|
||||
}}
|
||||
onClick={handleBackToLogin}
|
||||
className="text-xs text-muted-foreground hover:underline justify-self-start"
|
||||
disabled={isLoading}
|
||||
>
|
||||
← Back to login
|
||||
← {t("backToLogin")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<Alert className="border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{/* 账户锁定提示:达到失败阈值后展示,引导用户重置密码 */}
|
||||
{showLockHint && !error ? (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{t("errors.accountLockedHint")}</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Button disabled={isLoading}>
|
||||
{isLoading && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{requiresTwoFactor ? "Verify & Sign In" : "Sign In with Email"}
|
||||
{requiresTwoFactor ? t("verifyAndSignIn") : t("signIn")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -170,7 +235,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
Or continue with
|
||||
{t("orContinueWith")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,12 +248,12 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
GitHub
|
||||
</Button>
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
{t("noAccount")}{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
Sign up
|
||||
{t("register")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { Loader2 } from "lucide-react"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import type { RegisterResult } from "@/modules/auth/types"
|
||||
|
||||
const ADULT_AGE = 18
|
||||
|
||||
@@ -35,10 +37,11 @@ function calcAge(birth: string): number | null {
|
||||
}
|
||||
|
||||
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
registerAction: (formData: FormData) => Promise<ActionState>
|
||||
registerAction: (formData: FormData) => Promise<ActionState<RegisterResult>>
|
||||
}
|
||||
|
||||
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
|
||||
const t = useTranslations("auth.register")
|
||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||
const [birthDate, setBirthDate] = React.useState<string>("")
|
||||
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
|
||||
@@ -53,15 +56,15 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
event.preventDefault()
|
||||
|
||||
if (!agreedTerms) {
|
||||
toast.error("请阅读并同意《隐私政策》和《用户协议》后再注册")
|
||||
toast.error(t("toast.needAgreeTerms"))
|
||||
return
|
||||
}
|
||||
if (isMinor && !agreedGuardian) {
|
||||
toast.error("未成年人注册须确认已获得监护人同意")
|
||||
toast.error(t("toast.needGuardianConsent"))
|
||||
return
|
||||
}
|
||||
if (isMinor && !guardianRelation) {
|
||||
toast.error("请选择监护人与您的关系")
|
||||
toast.error(t("toast.needGuardianRelation"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -72,14 +75,14 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
const res = await registerAction(formData)
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message || "账户创建成功")
|
||||
toast.success(res.message || t("toast.createSuccess"))
|
||||
router.push("/login")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || "注册失败")
|
||||
toast.error(res.message || t("toast.createFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error("注册失败")
|
||||
toast.error(t("toast.createFailed"))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
@@ -88,19 +91,19 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
return (
|
||||
<div className={cn("grid gap-6", className)} {...props}>
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">创建账户</h1>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t("createAccount")}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
填写以下信息以创建您的账户
|
||||
{t("subtitleAlt")}
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">姓名</Label>
|
||||
<Label htmlFor="name">{t("name")}</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
placeholder="请输入姓名"
|
||||
placeholder={t("namePlaceholder")}
|
||||
type="text"
|
||||
autoCapitalize="words"
|
||||
autoComplete="name"
|
||||
@@ -109,7 +112,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">邮箱</Label>
|
||||
<Label htmlFor="email">{t("email")}</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
@@ -122,7 +125,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Label htmlFor="password">{t("password")}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
@@ -132,7 +135,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="birthDate">出生日期</Label>
|
||||
<Label htmlFor="birthDate">{t("birthDate")}</Label>
|
||||
<Input
|
||||
id="birthDate"
|
||||
name="birthDate"
|
||||
@@ -142,47 +145,62 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
onChange={(e) => setBirthDate(e.target.value)}
|
||||
/>
|
||||
{age !== null && (
|
||||
<p className="text-xs text-muted-foreground">当前年龄:{age} 岁</p>
|
||||
<p className="text-xs text-muted-foreground">{t("currentAge", { age })}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* audit-P2-3: 邀请码(可选,提供时覆盖默认 student 角色) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invitationCode">{t("invitationCodeLabel")}</Label>
|
||||
<Input
|
||||
id="invitationCode"
|
||||
name="invitationCode"
|
||||
type="text"
|
||||
placeholder={t("invitationCodePlaceholder")}
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
disabled={isLoading}
|
||||
maxLength={64}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isMinor && (
|
||||
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||
检测到您是未成年人,请填写监护人信息
|
||||
{t("guardianSectionTitle")}
|
||||
</p>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="guardianName">监护人姓名</Label>
|
||||
<Label htmlFor="guardianName">{t("guardianName")}</Label>
|
||||
<Input
|
||||
id="guardianName"
|
||||
name="guardianName"
|
||||
placeholder="请输入监护人姓名"
|
||||
placeholder={t("guardianNamePlaceholder")}
|
||||
type="text"
|
||||
disabled={isLoading}
|
||||
required={isMinor}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="guardianPhone">监护人电话</Label>
|
||||
<Label htmlFor="guardianPhone">{t("guardianPhone")}</Label>
|
||||
<Input
|
||||
id="guardianPhone"
|
||||
name="guardianPhone"
|
||||
placeholder="请输入监护人手机号"
|
||||
placeholder={t("guardianPhonePlaceholder")}
|
||||
type="tel"
|
||||
disabled={isLoading}
|
||||
required={isMinor}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="guardianRelation">监护人与您的关系</Label>
|
||||
<Label htmlFor="guardianRelation">{t("guardianRelation")}</Label>
|
||||
<Select value={guardianRelation} onValueChange={setGuardianRelation}>
|
||||
<SelectTrigger id="guardianRelation">
|
||||
<SelectValue placeholder="请选择关系" />
|
||||
<SelectValue placeholder={t("guardianRelationPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="父亲">父亲</SelectItem>
|
||||
<SelectItem value="母亲">母亲</SelectItem>
|
||||
<SelectItem value="其他法定监护人">其他法定监护人</SelectItem>
|
||||
<SelectItem value="父亲">{t("guardianRelationFather")}</SelectItem>
|
||||
<SelectItem value="母亲">{t("guardianRelationMother")}</SelectItem>
|
||||
<SelectItem value="其他法定监护人">{t("guardianRelationOther")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<input
|
||||
@@ -201,25 +219,30 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
onCheckedChange={(v) => setAgreedTerms(v === true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input type="hidden" name="agreedTerms" value={agreedTerms ? "true" : "false"} />
|
||||
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
|
||||
我已阅读并同意
|
||||
{t.rich("agreeTermsRich", {
|
||||
privacy: (chunks) => (
|
||||
<Link
|
||||
href="/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
||||
>
|
||||
《隐私政策》
|
||||
{chunks}
|
||||
</Link>
|
||||
和
|
||||
),
|
||||
terms: (chunks) => (
|
||||
<Link
|
||||
href="/terms"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
||||
>
|
||||
《用户协议》
|
||||
{chunks}
|
||||
</Link>
|
||||
),
|
||||
})}
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
@@ -231,8 +254,9 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
onCheckedChange={(v) => setAgreedGuardian(v === true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<input type="hidden" name="agreedGuardian" value={agreedGuardian ? "true" : "false"} />
|
||||
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
|
||||
我确认已获得监护人同意使用本服务
|
||||
{t("agreeGuardianLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
@@ -241,17 +265,17 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
{isLoading && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
创建账户
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
已有账户?{" "}
|
||||
{t("alreadyHaveAccount")}{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
立即登录
|
||||
{t("loginNow")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
122
src/modules/auth/data-access.ts
Normal file
122
src/modules/auth/data-access.ts
Normal 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
130
src/modules/auth/schema.ts
Normal 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
|
||||
}
|
||||
213
src/modules/auth/services/login-service.ts
Normal file
213
src/modules/auth/services/login-service.ts
Normal 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-9:2FA 校验失败埋点(用于 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,
|
||||
}
|
||||
}
|
||||
120
src/modules/auth/services/two-factor-service.ts
Normal file
120
src/modules/auth/services/two-factor-service.ts
Normal 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
45
src/modules/auth/types.ts
Normal 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_ROLE(student)
|
||||
*/
|
||||
role?: string
|
||||
/**
|
||||
* 邀请码注册时自动加入的班级 ID(audit-P2-3 新增)。
|
||||
*
|
||||
* 来自邀请码,注册成功后可触发班级加入逻辑。
|
||||
* 当前仅透传,未在 createUser 中自动加入班级(避免引入 classes 模块依赖)。
|
||||
*/
|
||||
classId?: string
|
||||
}
|
||||
|
||||
/** 邮箱可用性查询结果 */
|
||||
export interface EmailAvailabilityResult {
|
||||
available: boolean
|
||||
}
|
||||
310
src/modules/files/actions.ts
Normal file
310
src/modules/files/actions.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
@@ -15,13 +16,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { FileUpload } from "./file-upload"
|
||||
import { FilePreviewDialog } from "./file-preview-dialog"
|
||||
import { FileUpload } from "./file-upload"
|
||||
import { useFileBatchOperations } from "../hooks/use-file-batch-operations"
|
||||
import type { FileAttachment, FileStats } from "../types"
|
||||
|
||||
interface AdminFilesViewProps {
|
||||
@@ -29,174 +31,159 @@ interface AdminFilesViewProps {
|
||||
stats: FileStats
|
||||
}
|
||||
|
||||
// 文件类型分组选项
|
||||
const TYPE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: "all", label: "All Types" },
|
||||
{ value: "image/", label: "Images" },
|
||||
{ value: "application/pdf", label: "PDF" },
|
||||
{ value: "application/msword", label: "Word" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", label: "Word (docx)" },
|
||||
{ value: "application/vnd.ms-excel", label: "Excel" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", label: "Excel (xlsx)" },
|
||||
{ value: "application/vnd.ms-powerpoint", label: "PowerPoint" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", label: "PowerPoint (pptx)" },
|
||||
{ value: "text/", label: "Text" },
|
||||
{ value: "application/zip", label: "ZIP" },
|
||||
]
|
||||
interface TypeOption {
|
||||
value: string
|
||||
labelKey:
|
||||
| "allTypes"
|
||||
| "images"
|
||||
| "pdf"
|
||||
| "word"
|
||||
| "wordDocx"
|
||||
| "excel"
|
||||
| "excelXlsx"
|
||||
| "powerpoint"
|
||||
| "powerpointPptx"
|
||||
| "text"
|
||||
| "zip"
|
||||
}
|
||||
|
||||
export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
const TYPE_OPTIONS = [
|
||||
{ value: "all", labelKey: "allTypes" },
|
||||
{ value: "image/", labelKey: "images" },
|
||||
{ value: "application/pdf", labelKey: "pdf" },
|
||||
{ value: "application/msword", labelKey: "word" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", labelKey: "wordDocx" },
|
||||
{ value: "application/vnd.ms-excel", labelKey: "excel" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", labelKey: "excelXlsx" },
|
||||
{ value: "application/vnd.ms-powerpoint", labelKey: "powerpoint" },
|
||||
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", labelKey: "powerpointPptx" },
|
||||
{ value: "text/", labelKey: "text" },
|
||||
{ value: "application/zip", labelKey: "zip" },
|
||||
] as const satisfies TypeOption[]
|
||||
|
||||
type TypeLabelKey = TypeOption["labelKey"]
|
||||
|
||||
export function AdminFilesView({
|
||||
files,
|
||||
stats,
|
||||
}: AdminFilesViewProps): React.ReactElement {
|
||||
const t = useTranslations("files.admin")
|
||||
const router = useRouter()
|
||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
||||
const [search, setSearch] = useState<string>("")
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
// 客户端二次筛选(与 server 端筛选互补,提升交互即时性)
|
||||
const filteredFiles = useMemo(() => {
|
||||
return files.filter((f) => {
|
||||
if (typeFilter !== "all") {
|
||||
if (typeFilter.endsWith("/")) {
|
||||
if (!f.mimeType.startsWith(typeFilter)) return false
|
||||
} else if (f.mimeType !== typeFilter) {
|
||||
return false
|
||||
const renderTypeLabel = (key: TypeLabelKey): string => {
|
||||
switch (key) {
|
||||
case "allTypes": return t("filter.allTypes")
|
||||
case "images": return t("filter.images")
|
||||
case "pdf": return t("filter.pdf")
|
||||
case "word": return t("filter.word")
|
||||
case "wordDocx": return t("filter.wordDocx")
|
||||
case "excel": return t("filter.excel")
|
||||
case "excelXlsx": return t("filter.excelXlsx")
|
||||
case "powerpoint": return t("filter.powerpoint")
|
||||
case "powerpointPptx": return t("filter.powerpointPptx")
|
||||
case "text": return t("filter.text")
|
||||
case "zip": return t("filter.zip")
|
||||
}
|
||||
}
|
||||
if (search.trim()) {
|
||||
const kw = search.trim().toLowerCase()
|
||||
if (
|
||||
!f.originalName.toLowerCase().includes(kw) &&
|
||||
!f.filename.toLowerCase().includes(kw)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
|
||||
const {
|
||||
selectedIds,
|
||||
typeFilter,
|
||||
search,
|
||||
deleting,
|
||||
setTypeFilter,
|
||||
setSearch,
|
||||
filteredFiles,
|
||||
allSelected,
|
||||
someSelected,
|
||||
toggleAll,
|
||||
toggleOne,
|
||||
handleBatchDelete,
|
||||
} = useFileBatchOperations({
|
||||
files,
|
||||
onAfterDelete: () => router.refresh(),
|
||||
})
|
||||
}, [files, typeFilter, search])
|
||||
|
||||
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
|
||||
const someSelected = selectedIds.size > 0 && !allSelected
|
||||
|
||||
const toggleAll = () => {
|
||||
if (allSelected) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleOne = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleUploaded = () => {
|
||||
router.refresh()
|
||||
}
|
||||
|
||||
const handleDeleted = () => {
|
||||
router.refresh()
|
||||
setSelectedIds(new Set())
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
if (selectedIds.size === 0) return
|
||||
const ids = Array.from(selectedIds)
|
||||
setDeleting(true)
|
||||
try {
|
||||
const res = await fetch("/api/files/batch-delete", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
})
|
||||
const body = await res.json().catch(() => null)
|
||||
if (!res.ok || !body?.success) {
|
||||
toast.error(body?.message || "Failed to delete files")
|
||||
return
|
||||
}
|
||||
toast.success(`Deleted ${body.deletedCount} file(s)`)
|
||||
handleDeleted()
|
||||
} catch {
|
||||
toast.error("Failed to delete files")
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-6 p-8">
|
||||
<div className="space-y-1">
|
||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Files className="h-6 w-6" />
|
||||
Files
|
||||
<Files className="h-6 w-6" aria-hidden="true" />
|
||||
{t("title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Upload and manage all files in the system.
|
||||
</p>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<WidgetBoundary title={t("stats.totalFiles")} skeletonHeight={120}>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Files className="h-3.5 w-3.5" />
|
||||
Total Files
|
||||
<Files className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("stats.totalFiles")}
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
|
||||
</div>
|
||||
<div className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<HardDrive className="h-3.5 w-3.5" />
|
||||
Total Size
|
||||
<HardDrive className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t("stats.totalSize")}
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
|
||||
</div>
|
||||
{stats.byType.slice(0, 2).map((t) => (
|
||||
<div key={t.mimeType} className="rounded-md border p-4">
|
||||
{stats.byType.slice(0, 2).map((typeStat) => (
|
||||
<div key={typeStat.mimeType} className="rounded-md border p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<FileIcon mimeType={t.mimeType} className="h-3.5 w-3.5" />
|
||||
<span className="truncate" title={t.mimeType}>{t.mimeType}</span>
|
||||
<FileIcon mimeType={typeStat.mimeType} className="h-3.5 w-3.5" />
|
||||
<span className="truncate" title={typeStat.mimeType}>
|
||||
{typeStat.mimeType}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-2xl font-bold">{t.count}</p>
|
||||
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
|
||||
<p className="mt-1 text-2xl font-bold">{typeStat.count}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(typeStat.size)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</WidgetBoundary>
|
||||
|
||||
<FileUpload onUploaded={handleUploaded} />
|
||||
<WidgetBoundary title={t("title")} skeletonHeight={160}>
|
||||
<FileUpload onUploaded={() => router.refresh()} />
|
||||
</WidgetBoundary>
|
||||
|
||||
{/* 筛选与批量操作工具栏 */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="w-full sm:w-[200px]">
|
||||
<SelectValue placeholder="Filter by type" />
|
||||
<SelectValue placeholder={t("filter.byType")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TYPE_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
{renderTypeLabel(opt.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Search
|
||||
className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Input
|
||||
placeholder="Search by file name..."
|
||||
placeholder={t("filter.search")}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8"
|
||||
aria-label={t("filter.search")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{selectedIds.size > 0 ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{selectedIds.size} selected</Badge>
|
||||
<Badge variant="secondary">
|
||||
{t("selection.selected", { count: selectedIds.size })}
|
||||
</Badge>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
@@ -204,18 +191,18 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
disabled={deleting}
|
||||
onClick={() => void handleBatchDelete()}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{deleting ? "Deleting..." : "Delete Selected"}
|
||||
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{deleting ? t("selection.deleting") : t("selection.deleteSelected")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* 文件列表 */}
|
||||
<WidgetBoundary title={t("title")} skeletonHeight={400}>
|
||||
{filteredFiles.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No files found"
|
||||
description="Try adjusting your filters or upload a new file."
|
||||
title={t("empty.title")}
|
||||
description={t("empty.description")}
|
||||
icon={FileWarning}
|
||||
className="h-auto border-none shadow-none"
|
||||
/>
|
||||
@@ -225,13 +212,13 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
<Checkbox
|
||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||
onCheckedChange={toggleAll}
|
||||
aria-label="Select all"
|
||||
aria-label={t("columns.file")}
|
||||
/>
|
||||
<span className="flex-1">File</span>
|
||||
<span className="hidden w-24 sm:block">Size</span>
|
||||
<span className="hidden w-32 md:block">Type</span>
|
||||
<span className="hidden w-32 md:block">Uploaded</span>
|
||||
<span className="w-24 text-right">Actions</span>
|
||||
<span className="flex-1">{t("columns.file")}</span>
|
||||
<span className="hidden w-24 sm:block">{t("columns.size")}</span>
|
||||
<span className="hidden w-32 md:block">{t("columns.type")}</span>
|
||||
<span className="hidden w-32 md:block">{t("columns.uploaded")}</span>
|
||||
<span className="w-24 text-right">{t("columns.actions")}</span>
|
||||
</div>
|
||||
<ul className="divide-y">
|
||||
{filteredFiles.map((file) => {
|
||||
@@ -254,6 +241,7 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="truncate text-sm font-medium hover:underline"
|
||||
title={file.originalName}
|
||||
aria-label={`${t("columns.file")}: ${file.originalName}`}
|
||||
>
|
||||
{file.originalName}
|
||||
</a>
|
||||
@@ -264,7 +252,10 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
<span className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block" title={file.mimeType}>
|
||||
<span
|
||||
className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block"
|
||||
title={file.mimeType}
|
||||
>
|
||||
{file.mimeType}
|
||||
</span>
|
||||
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
|
||||
@@ -284,6 +275,7 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</WidgetBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
import {
|
||||
@@ -26,17 +27,26 @@ interface FilePreviewDialogProps {
|
||||
export function FilePreviewDialog({
|
||||
file,
|
||||
trigger,
|
||||
triggerLabel = "Preview",
|
||||
triggerLabel,
|
||||
triggerVariant = "outline",
|
||||
triggerSize = "sm",
|
||||
}: FilePreviewDialogProps) {
|
||||
}: FilePreviewDialogProps): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{trigger ?? (
|
||||
<Button type="button" variant={triggerVariant} size={triggerSize}>
|
||||
<Eye className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"} />
|
||||
{triggerLabel ? <span>{triggerLabel}</span> : <span className="sr-only">Preview</span>}
|
||||
<Eye
|
||||
className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{triggerLabel ? (
|
||||
<span>{triggerLabel}</span>
|
||||
) : (
|
||||
<span className="sr-only">{t("trigger")}</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
@@ -44,7 +54,7 @@ export function FilePreviewDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
File preview · {file.mimeType}
|
||||
{t("title")} · {file.mimeType}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[75vh] overflow-auto">
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||
import { useFilePreview, useImageZoom } from "../hooks/use-file-preview"
|
||||
import type { FileAttachment } from "../types"
|
||||
|
||||
interface FilePreviewProps {
|
||||
@@ -42,7 +43,8 @@ function classify(mimeType: string): PreviewKind {
|
||||
return "other"
|
||||
}
|
||||
|
||||
export function FilePreview({ file, className }: FilePreviewProps) {
|
||||
export function FilePreview({ file, className }: FilePreviewProps): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
const kind = classify(file.mimeType)
|
||||
const url = file.url ?? "#"
|
||||
|
||||
@@ -66,9 +68,10 @@ export function FilePreview({ file, className }: FilePreviewProps) {
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${t("download")} ${file.originalName}`}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("download")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -86,7 +89,7 @@ function PreviewBody({
|
||||
kind: PreviewKind
|
||||
file: FileAttachment
|
||||
url: string
|
||||
}) {
|
||||
}): React.ReactElement {
|
||||
if (kind === "image") {
|
||||
return <ImagePreview url={url} alt={file.originalName} />
|
||||
}
|
||||
@@ -96,6 +99,7 @@ function PreviewBody({
|
||||
<iframe
|
||||
src={url}
|
||||
title={file.originalName}
|
||||
aria-label={`PDF preview: ${file.originalName}`}
|
||||
className="h-[70vh] w-full rounded-md border"
|
||||
/>
|
||||
)
|
||||
@@ -105,35 +109,12 @@ function PreviewBody({
|
||||
return <TextPreview url={url} />
|
||||
}
|
||||
|
||||
// Office / other: show info card + download button
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
|
||||
<p className="mt-3 text-sm font-medium">
|
||||
{kind === "office" ? "Office file preview not available" : "Preview not available"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{kind === "office"
|
||||
? "Download the file to view its contents in your Office application."
|
||||
: "Download the file to view its contents."}
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||
<a
|
||||
href={url}
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
return <OtherPreview kind={kind} file={file} url={url} />
|
||||
}
|
||||
|
||||
function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
function ImagePreview({ url, alt }: { url: string; alt: string }): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useImageZoom()
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -143,13 +124,13 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))}
|
||||
disabled={zoom <= 0.25}
|
||||
onClick={zoomOut}
|
||||
disabled={!canZoomOut}
|
||||
aria-label={t("zoomOut")}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
<span className="sr-only">Zoom out</span>
|
||||
<ZoomOut className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground w-12 text-center">
|
||||
<span className="w-12 text-center text-xs text-muted-foreground" aria-live="polite">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<Button
|
||||
@@ -157,14 +138,17 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setZoom((z) => Math.min(4, z + 0.25))}
|
||||
disabled={zoom >= 4}
|
||||
onClick={zoomIn}
|
||||
disabled={!canZoomIn}
|
||||
aria-label={t("zoomIn")}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
<span className="sr-only">Zoom in</span>
|
||||
<ZoomIn className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="overflow-auto rounded-md border bg-muted/30 p-2" style={{ maxHeight: "70vh" }}>
|
||||
<div
|
||||
className="overflow-auto rounded-md border bg-muted/30 p-2"
|
||||
style={{ maxHeight: "70vh" }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={url}
|
||||
@@ -177,34 +161,23 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function TextPreview({ url }: { url: string }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const text = await res.text()
|
||||
setContent(text)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load text")
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
function TextPreview({ url }: { url: string }): React.ReactElement {
|
||||
const t = useTranslations("files.preview.text")
|
||||
const { content, error, loading, load } = useFilePreview()
|
||||
|
||||
if (content === null && !error && !loading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||
<FileText className="h-12 w-12 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">Text file</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Click below to load the content.</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => void load()}>
|
||||
Load preview
|
||||
<FileText className="h-12 w-12 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="mt-3 text-sm font-medium">{t("title")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("hint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => void load(url)}
|
||||
>
|
||||
{t("load")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -212,16 +185,29 @@ function TextPreview({ url }: { url: string }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
|
||||
Loading...
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
{t("loading")}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
||||
Failed to load text: {error}
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col items-center justify-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
|
||||
>
|
||||
<span>{t("error", { message: error })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void load(url)}
|
||||
>
|
||||
{t("load")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -232,3 +218,38 @@ function TextPreview({ url }: { url: string }) {
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
function OtherPreview({
|
||||
kind,
|
||||
file,
|
||||
url,
|
||||
}: {
|
||||
kind: PreviewKind
|
||||
file: FileAttachment
|
||||
url: string
|
||||
}): React.ReactElement {
|
||||
const t = useTranslations("files.preview")
|
||||
const isOffice = kind === "office"
|
||||
const title = isOffice ? t("office.title") : t("other.title")
|
||||
const hint = isOffice ? t("office.hint") : t("other.hint")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
|
||||
<p className="mt-3 text-sm font-medium">{title}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||
<a
|
||||
href={url}
|
||||
download={file.originalName}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`${t("download")} ${file.originalName}`}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
{t("download")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { UploadCloud, X } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Progress } from "@/shared/components/ui/progress"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
ALLOWED_MIME_TYPES,
|
||||
formatFileSize,
|
||||
MAX_FILE_SIZE,
|
||||
} from "@/shared/lib/file-storage"
|
||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { useFileUpload } from "../hooks/use-file-upload"
|
||||
import type { FileTargetType, FileUploadResult } from "../types"
|
||||
|
||||
interface FileUploadProps {
|
||||
@@ -23,146 +20,31 @@ interface FileUploadProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface UploadTask {
|
||||
file: File
|
||||
progress: number
|
||||
status: "uploading" | "success" | "error"
|
||||
message?: string
|
||||
result?: FileUploadResult
|
||||
}
|
||||
|
||||
const ACCEPT_ATTR = (ALLOWED_MIME_TYPES as readonly string[]).join(",")
|
||||
|
||||
export function FileUpload({
|
||||
targetType,
|
||||
targetId,
|
||||
onUploaded,
|
||||
multiple = true,
|
||||
className,
|
||||
}: FileUploadProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [tasks, setTasks] = useState<UploadTask[]>([])
|
||||
|
||||
const validateFile = (file: File): string | null => {
|
||||
if (file.size === 0) return "File is empty"
|
||||
if (file.size > MAX_FILE_SIZE) return "File size exceeds 10MB limit"
|
||||
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
|
||||
return `File type ${file.type || "unknown"} is not allowed`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const uploadOne = useCallback(
|
||||
async (file: File): Promise<void> => {
|
||||
const taskId = `${file.name}-${file.size}-${Date.now()}`
|
||||
setTasks((prev) => [
|
||||
...prev,
|
||||
{ file, progress: 0, status: "uploading" },
|
||||
])
|
||||
|
||||
const validationError = validateFile(file)
|
||||
if (validationError) {
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file
|
||||
? { ...t, status: "error", message: validationError, progress: 100 }
|
||||
: t
|
||||
)
|
||||
)
|
||||
toast.error(`${file.name}: ${validationError}`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append("file", file)
|
||||
if (targetType) formData.append("targetType", targetType)
|
||||
if (targetId) formData.append("targetId", targetId)
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
const result = await new Promise<FileUploadResult>((resolve, reject) => {
|
||||
xhr.open("POST", "/api/upload")
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const pct = Math.round((e.loaded / e.total) * 100)
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file ? { ...t, progress: pct } : t
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText)
|
||||
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
|
||||
resolve(body as FileUploadResult)
|
||||
} else {
|
||||
reject(new Error(body.message || "Upload failed"))
|
||||
}
|
||||
} catch {
|
||||
reject(new Error("Invalid response"))
|
||||
}
|
||||
}
|
||||
xhr.onerror = () => reject(new Error("Network error"))
|
||||
xhr.send(formData)
|
||||
})
|
||||
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file
|
||||
? { ...t, status: "success", progress: 100, result }
|
||||
: t
|
||||
)
|
||||
)
|
||||
onUploaded?.(result)
|
||||
toast.success(`${file.name} uploaded`)
|
||||
void taskId
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "Upload failed"
|
||||
setTasks((prev) =>
|
||||
prev.map((t) =>
|
||||
t.file === file ? { ...t, status: "error", message } : t
|
||||
)
|
||||
)
|
||||
toast.error(`${file.name}: ${message}`)
|
||||
}
|
||||
},
|
||||
[targetType, targetId, onUploaded]
|
||||
)
|
||||
|
||||
const handleFiles = useCallback(
|
||||
(fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return
|
||||
const files = Array.from(fileList)
|
||||
if (!multiple) {
|
||||
void uploadOne(files[0])
|
||||
} else {
|
||||
files.forEach((f) => void uploadOne(f))
|
||||
}
|
||||
},
|
||||
[uploadOne, multiple]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
},
|
||||
[handleFiles]
|
||||
)
|
||||
|
||||
const removeTask = (task: UploadTask) => {
|
||||
setTasks((prev) => prev.filter((t) => t !== task))
|
||||
}
|
||||
}: FileUploadProps): React.ReactElement {
|
||||
const t = useTranslations("files.upload")
|
||||
const {
|
||||
tasks,
|
||||
isDragging,
|
||||
inputRef,
|
||||
setIsDragging,
|
||||
handleFiles,
|
||||
removeTask,
|
||||
acceptAttr,
|
||||
maxFileSize,
|
||||
} = useFileUpload({ targetType, targetId, multiple, onUploaded })
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-describedby="file-upload-hint"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
@@ -175,7 +57,11 @@ export function FileUpload({
|
||||
setIsDragging(true)
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}}
|
||||
className={cn(
|
||||
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||
isDragging
|
||||
@@ -183,19 +69,18 @@ export function FileUpload({
|
||||
: "border-input hover:border-primary/50 hover:bg-accent/50"
|
||||
)}
|
||||
>
|
||||
<UploadCloud className="h-10 w-10 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm font-medium">
|
||||
Click to upload or drag and drop
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {formatFileSize(MAX_FILE_SIZE)}
|
||||
<UploadCloud className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="mt-2 text-sm font-medium">{t("title")}</p>
|
||||
<p id="file-upload-hint" className="mt-1 text-xs text-muted-foreground">
|
||||
{t("hint", { size: formatFileSize(maxFileSize) })}
|
||||
</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept={ACCEPT_ATTR}
|
||||
accept={acceptAttr}
|
||||
multiple={multiple}
|
||||
aria-label={t("title")}
|
||||
onChange={(e) => {
|
||||
handleFiles(e.target.files)
|
||||
e.target.value = ""
|
||||
@@ -204,7 +89,7 @@ export function FileUpload({
|
||||
</div>
|
||||
|
||||
{tasks.length > 0 ? (
|
||||
<ul className="space-y-2">
|
||||
<ul className="space-y-2" aria-live="polite">
|
||||
{tasks.map((task, idx) => (
|
||||
<li
|
||||
key={`${task.file.name}-${idx}`}
|
||||
@@ -221,13 +106,17 @@ export function FileUpload({
|
||||
</span>
|
||||
</div>
|
||||
{task.status === "uploading" ? (
|
||||
<Progress value={task.progress} className="h-1.5" />
|
||||
<Progress
|
||||
value={task.progress}
|
||||
className="h-1.5"
|
||||
aria-label={`Uploading ${task.file.name}`}
|
||||
/>
|
||||
) : null}
|
||||
{task.status === "error" ? (
|
||||
<p className="text-xs text-destructive">{task.message}</p>
|
||||
) : null}
|
||||
{task.status === "success" ? (
|
||||
<p className="text-xs text-green-600">Uploaded</p>
|
||||
<p className="text-xs text-green-600">{t("uploaded")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
@@ -236,9 +125,9 @@ export function FileUpload({
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => removeTask(task)}
|
||||
aria-label="Remove"
|
||||
aria-label={t("remove")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<X className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
132
src/modules/files/hooks/use-file-batch-operations.ts
Normal file
132
src/modules/files/hooks/use-file-batch-operations.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
64
src/modules/files/hooks/use-file-preview.ts
Normal file
64
src/modules/files/hooks/use-file-preview.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
181
src/modules/files/hooks/use-file-upload.ts
Normal file
181
src/modules/files/hooks/use-file-upload.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
84
src/modules/files/schema.ts
Normal file
84
src/modules/files/schema.ts
Normal 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:文件名模糊匹配
|
||||
* - limit:1..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
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
// 文件关联的目标资源类型(多态关联)
|
||||
export type FileTargetType = "exam" | "textbook" | "question" | "announcement" | "homework"
|
||||
// P1-5 新增 "user_avatar":用于用户头像上传场景的 targetType 字段对齐
|
||||
// P2-3 新增 "message":用于私信附件上传场景的 targetType 字段对齐
|
||||
export type FileTargetType =
|
||||
| "exam"
|
||||
| "textbook"
|
||||
| "question"
|
||||
| "announcement"
|
||||
| "homework"
|
||||
| "user_avatar"
|
||||
| "message"
|
||||
|
||||
// 文件附件记录(DB 行的 TypeScript 表示)
|
||||
export interface FileAttachment {
|
||||
|
||||
@@ -30,7 +30,12 @@ import {
|
||||
getUnreadNotificationCount,
|
||||
archiveNotification,
|
||||
} from "./data-access"
|
||||
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "./preferences"
|
||||
import { UpdateNotificationPreferencesSchema } from "./schema"
|
||||
import type { NotificationPayload, ChannelSendResult, Notification, NotificationPreferences, UpdateNotificationPreferencesInput } from "./types"
|
||||
|
||||
/**
|
||||
* Zod 校验:通知负载(sendNotificationAction 入参)
|
||||
@@ -298,3 +303,81 @@ export async function archiveNotificationAction(
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 通知偏好 Server Actions
|
||||
//
|
||||
// V3-P1-3: 从 messaging/actions.ts 迁移至 notifications/actions.ts,
|
||||
// 使通知偏好的 data-access、schema、actions 位于同一模块。
|
||||
// 权限复用 MESSAGE_READ(任何能读消息的用户都能管理通知偏好)。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 获取当前用户的通知偏好。
|
||||
*/
|
||||
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
const prefs = await getNotificationPreferences(ctx.userId)
|
||||
return { success: true, data: prefs }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前用户的通知偏好。
|
||||
*
|
||||
* 从 FormData 中解析布尔值(checkbox 提交 "on" 或不提交)和时间字符串。
|
||||
*/
|
||||
export async function updateNotificationPreferencesAction(
|
||||
prevState: ActionState<NotificationPreferences> | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState<NotificationPreferences>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||
|
||||
const parseBool = (key: string): boolean => formData.get(key) === "on"
|
||||
const parseTime = (key: string): string | null => {
|
||||
const v = formData.get(key)
|
||||
if (typeof v !== "string") return null
|
||||
const trimmed = v.trim()
|
||||
return trimmed.length > 0 ? trimmed : null
|
||||
}
|
||||
|
||||
const parsed = UpdateNotificationPreferencesSchema.safeParse({
|
||||
emailEnabled: parseBool("emailEnabled"),
|
||||
smsEnabled: parseBool("smsEnabled"),
|
||||
pushEnabled: parseBool("pushEnabled"),
|
||||
homeworkNotifications: parseBool("homeworkNotifications"),
|
||||
gradeNotifications: parseBool("gradeNotifications"),
|
||||
announcementNotifications: parseBool("announcementNotifications"),
|
||||
messageNotifications: parseBool("messageNotifications"),
|
||||
attendanceNotifications: parseBool("attendanceNotifications"),
|
||||
quietHoursEnabled: parseBool("quietHoursEnabled"),
|
||||
quietHoursStart: parseTime("quietHoursStart"),
|
||||
quietHoursEnd: parseTime("quietHoursEnd"),
|
||||
})
|
||||
|
||||
if (!parsed.success) {
|
||||
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
|
||||
}
|
||||
|
||||
const input: UpdateNotificationPreferencesInput = parsed.data
|
||||
|
||||
const updated = await upsertNotificationPreferences(ctx.userId, input)
|
||||
if (!updated) {
|
||||
return { success: false, message: "Failed to update notification preferences" }
|
||||
}
|
||||
|
||||
revalidatePath("/settings")
|
||||
|
||||
return { success: true, message: "Notification preferences updated", data: updated }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
return { success: false, message: "Unexpected error" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ function getSmsConfig(): {
|
||||
accessKeySecret: string | undefined
|
||||
signName: string | undefined
|
||||
templateCode: string | undefined
|
||||
smsSdkAppId: string | undefined
|
||||
} {
|
||||
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
|
||||
return {
|
||||
@@ -46,6 +47,7 @@ function getSmsConfig(): {
|
||||
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
||||
signName: process.env.SMS_SIGN_NAME,
|
||||
templateCode: process.env.SMS_TEMPLATE_CODE,
|
||||
smsSdkAppId: process.env.SMS_SDK_APP_ID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,7 +200,7 @@ class TencentSmsSender implements NotificationChannelSender {
|
||||
const params = buildTemplateParams(payload)
|
||||
const response = await client.SendSms({
|
||||
PhoneNumberSet: [`+86${recipient.phone}`],
|
||||
SmsSdkAppId: this.config.templateCode ?? "",
|
||||
SmsSdkAppId: this.config.smsSdkAppId ?? "",
|
||||
SignName: this.config.signName ?? "",
|
||||
TemplateId: this.config.templateCode ?? "",
|
||||
TemplateParamSet: [params.title, params.content],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -32,6 +32,7 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
announcement: Megaphone,
|
||||
homework: PenTool,
|
||||
grade: GraduationCap,
|
||||
diagnostic: Stethoscope,
|
||||
}
|
||||
|
||||
/** 轮询降级间隔(毫秒) */
|
||||
|
||||
@@ -5,7 +5,7 @@ import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
|
||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -21,8 +21,11 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
announcement: Megaphone,
|
||||
homework: PenTool,
|
||||
grade: GraduationCap,
|
||||
diagnostic: Stethoscope,
|
||||
}
|
||||
|
||||
const TYPE_KEYS: NotificationType[] = ["message", "announcement", "homework", "grade", "diagnostic"]
|
||||
|
||||
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
|
||||
low: "bg-muted text-muted-foreground",
|
||||
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||
@@ -103,7 +106,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
>
|
||||
{t("filter.all")}
|
||||
</Button>
|
||||
{(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => (
|
||||
{TYPE_KEYS.map((type) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant={filterType === type ? "default" : "outline"}
|
||||
|
||||
@@ -37,7 +37,7 @@ import type {
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const isNotificationType = (v: unknown): v is NotificationType =>
|
||||
v === "message" || v === "announcement" || v === "homework" || v === "grade"
|
||||
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"
|
||||
|
||||
const toNotificationType = (v: string): NotificationType =>
|
||||
isNotificationType(v) ? v : "message"
|
||||
@@ -207,6 +207,7 @@ export async function logNotificationSend(
|
||||
const errorPart = result.error ? ` error="${result.error}"` : ""
|
||||
|
||||
// 始终输出 console 日志(便于开发调试)
|
||||
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.info
|
||||
console.info(
|
||||
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||
)
|
||||
@@ -227,6 +228,7 @@ export async function logNotificationSend(
|
||||
})
|
||||
} catch (dbError) {
|
||||
// DB 写入失败不阻塞通知流程,仅记录错误
|
||||
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.error
|
||||
console.error("[NotificationLog] Failed to persist log:", dbError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,12 +141,6 @@ export async function sendNotification(
|
||||
export async function sendBatchNotifications(
|
||||
payloads: NotificationPayload[]
|
||||
): Promise<ChannelSendResult[][]> {
|
||||
// 并行处理每个 payload
|
||||
const results = await Promise.all(payloads.map((p) => sendNotification(p)))
|
||||
|
||||
// 汇总日志
|
||||
const flatResults = results.flat()
|
||||
logNotificationSendBatch(flatResults)
|
||||
|
||||
return results
|
||||
// 并行处理每个 payload(每条通知的日志已在 sendNotification 内部记录)
|
||||
return Promise.all(payloads.map((p) => sendNotification(p)))
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ export {
|
||||
markNotificationAsReadAction,
|
||||
markAllNotificationsAsReadAction,
|
||||
archiveNotificationAction,
|
||||
getNotificationPreferencesAction,
|
||||
updateNotificationPreferencesAction,
|
||||
} from "./actions"
|
||||
export { UpdateNotificationPreferencesSchema } from "./schema"
|
||||
export type { UpdateNotificationPreferencesFormInput } from "./schema"
|
||||
export { NotificationList, NotificationDropdown } from "./components"
|
||||
export type {
|
||||
NotificationChannel,
|
||||
|
||||
142
src/modules/notifications/schema.test.ts
Normal file
142
src/modules/notifications/schema.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
25
src/modules/notifications/schema.ts
Normal file
25
src/modules/notifications/schema.ts
Normal 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
|
||||
>
|
||||
@@ -18,7 +18,7 @@
|
||||
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||
|
||||
/** 站内通知类型(message_notifications.type 列) */
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade"
|
||||
export type NotificationType = "message" | "announcement" | "homework" | "grade" | "diagnostic"
|
||||
|
||||
/** 通知优先级(message_notifications.priority 列) */
|
||||
export type NotificationPriority = "low" | "normal" | "high" | "urgent"
|
||||
|
||||
@@ -6,6 +6,12 @@ import { eq } from "drizzle-orm"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||
import { logAudit } from "@/shared/lib/audit-logger"
|
||||
import { resolveDefaultPath } from "@/shared/lib/route-resolver"
|
||||
import {
|
||||
rateLimit,
|
||||
rateLimitKey,
|
||||
RATE_LIMIT_RULES,
|
||||
} from "@/shared/lib/rate-limit"
|
||||
import { db } from "@/shared/db"
|
||||
import { users } from "@/shared/db/schema"
|
||||
import {
|
||||
@@ -18,7 +24,6 @@ import {
|
||||
getOnboardingStatus,
|
||||
updateUserProfile,
|
||||
bindParentToChild,
|
||||
resolveDefaultPathByRoles,
|
||||
} from "./data-access"
|
||||
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
|
||||
|
||||
@@ -74,7 +79,7 @@ export async function completeOnboardingAction(
|
||||
const roleNames = ctx.roles
|
||||
return {
|
||||
success: true,
|
||||
data: { defaultPath: resolveDefaultPathByRoles(roleNames) },
|
||||
data: { defaultPath: resolveDefaultPath(roleNames) },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +111,36 @@ export async function completeOnboardingAction(
|
||||
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s)
|
||||
)
|
||||
|
||||
// audit-P1-8:家长绑定子女独立速率限制。
|
||||
// 防止被撤销子女关系的家长反复尝试枚举三因子(生日 × 手机后4)。
|
||||
// 限制每小时 5 次完整 onboarding 提交,远低于 3.65M 组合枚举所需量级。
|
||||
// 仅在家长角色且实际提交子女绑定时检查,避免误伤其他角色。
|
||||
if (normalizedRoles.includes("parent") && input.children.length > 0) {
|
||||
const bindLimit = await rateLimit({
|
||||
key: rateLimitKey("onboarding:bind", userId),
|
||||
limit: RATE_LIMIT_RULES.ONBOARDING_BIND.limit,
|
||||
windowMs: RATE_LIMIT_RULES.ONBOARDING_BIND.windowMs,
|
||||
})
|
||||
if (!bindLimit.success) {
|
||||
await logAudit({
|
||||
action: "onboarding.bind_rate_limited",
|
||||
module: "onboarding",
|
||||
targetId: userId,
|
||||
targetType: "user",
|
||||
detail: {
|
||||
childrenCount: input.children.length,
|
||||
retryAfterMs: bindLimit.retryAfterMs,
|
||||
},
|
||||
status: "failure",
|
||||
})
|
||||
const retryMinutes = Math.ceil(bindLimit.retryAfterMs / 60_000)
|
||||
return {
|
||||
success: false,
|
||||
message: `子女绑定尝试过于频繁,请 ${retryMinutes} 分钟后再试`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 收集局部失败项(P1-2):班级码/子女绑定失败不回滚整个事务
|
||||
const failures: OnboardingFailureItem[] = []
|
||||
|
||||
@@ -190,7 +225,7 @@ export async function completeOnboardingAction(
|
||||
.set({ onboardedAt: new Date() })
|
||||
.where(eq(users.id, userId))
|
||||
|
||||
return { defaultPath: resolveDefaultPathByRoles(normalizedRoles) }
|
||||
return { defaultPath: resolveDefaultPath(normalizedRoles) }
|
||||
})
|
||||
|
||||
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross)
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
@@ -12,9 +8,10 @@ import { Label } from "@/shared/components/ui/label"
|
||||
import { Textarea } from "@/shared/components/ui/textarea"
|
||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
||||
import { completeOnboardingAction } from "@/modules/onboarding/actions"
|
||||
import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
|
||||
import type { OnboardingStatus } from "@/modules/onboarding/types"
|
||||
import { useOnboardingForm } from "@/modules/onboarding/hooks/use-onboarding-form"
|
||||
import { ParentChildrenForm } from "@/modules/onboarding/components/parent-children-form"
|
||||
|
||||
interface OnboardingStepperProps {
|
||||
initialStatus: OnboardingStatus
|
||||
@@ -22,169 +19,56 @@ interface OnboardingStepperProps {
|
||||
|
||||
/**
|
||||
* v3 i18n:所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。
|
||||
*
|
||||
* P1-2 重构后结构:
|
||||
* - 状态与提交逻辑:useOnboardingForm Hook(hooks/use-onboarding-form.ts)
|
||||
* - 家长子女子表单:ParentChildrenForm(components/parent-children-form.tsx)
|
||||
* - 本文件:纯展示编排器,负责步骤切换、表单字段、按钮渲染
|
||||
*/
|
||||
const STEPS_KEYS = ["roleConfirm", "basicInfo", "roleInfo", "complete"] as const
|
||||
|
||||
interface ChildRow {
|
||||
childEmail: string
|
||||
childBirthDate: string
|
||||
childPhoneSuffix: string
|
||||
childRelation: string
|
||||
}
|
||||
|
||||
const EMPTY_CHILD_ROW: ChildRow = {
|
||||
childEmail: "",
|
||||
childBirthDate: "",
|
||||
childPhoneSuffix: "",
|
||||
childRelation: "",
|
||||
}
|
||||
|
||||
export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { update } = useSession()
|
||||
const t = useTranslations("onboarding")
|
||||
const tCommon = useTranslations("common.actions")
|
||||
|
||||
// P1-1:URL query 参数持久化当前步骤
|
||||
const initialStep = clampStep(Number(searchParams.get("step") ?? "0"))
|
||||
const [step, setStep] = React.useState(initialStep)
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
||||
const form = useOnboardingForm(initialStatus)
|
||||
const {
|
||||
step,
|
||||
isSubmitting,
|
||||
name,
|
||||
phone,
|
||||
address,
|
||||
classCodes,
|
||||
teacherSubjects,
|
||||
children,
|
||||
primaryRole,
|
||||
isStudent,
|
||||
isTeacher,
|
||||
isParent,
|
||||
stepKeys,
|
||||
currentStepId,
|
||||
maxStep,
|
||||
canSkip,
|
||||
setName,
|
||||
setPhone,
|
||||
setAddress,
|
||||
setClassCodes,
|
||||
toggleSubject,
|
||||
addChildRow,
|
||||
removeChildRow,
|
||||
updateChildRow,
|
||||
onNext,
|
||||
onBack,
|
||||
onSkip,
|
||||
onFinish,
|
||||
} = form
|
||||
|
||||
const [name, setName] = React.useState(initialStatus.name ?? "")
|
||||
const [phone, setPhone] = React.useState("")
|
||||
const [address, setAddress] = React.useState("")
|
||||
const [classCodes, setClassCodes] = React.useState("")
|
||||
const [teacherSubjects, setTeacherSubjects] = React.useState<ClassSubject[]>([])
|
||||
const [children, setChildren] = React.useState<ChildRow[]>([{ ...EMPTY_CHILD_ROW }])
|
||||
|
||||
const primaryRole = initialStatus.roles.primary
|
||||
const isAdmin = primaryRole === "admin"
|
||||
const isStudent = primaryRole === "student"
|
||||
const isTeacher = primaryRole === "teacher"
|
||||
const isParent = primaryRole === "parent"
|
||||
|
||||
const stepKeys = isAdmin ? STEPS_KEYS.filter((_, i) => i !== 2) : STEPS_KEYS
|
||||
const maxStep = stepKeys.length - 1
|
||||
|
||||
const canNext = React.useMemo(() => {
|
||||
if (step === 1) {
|
||||
return name.trim().length > 0 && phone.trim().length > 0
|
||||
}
|
||||
if (step === 2 && isParent) {
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
return validChildren.length > 0
|
||||
}
|
||||
return true
|
||||
}, [step, name, phone, isParent, children])
|
||||
|
||||
const toggleSubject = (subject: ClassSubject) => {
|
||||
setTeacherSubjects((prev) =>
|
||||
prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]
|
||||
)
|
||||
}
|
||||
|
||||
const goToStep = React.useCallback(
|
||||
(next: number) => {
|
||||
const clamped = Math.max(0, Math.min(maxStep, next))
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set("step", String(clamped))
|
||||
router.replace(`/onboarding?${params.toString()}`, { scroll: false })
|
||||
setStep(clamped)
|
||||
},
|
||||
[maxStep, router, searchParams]
|
||||
)
|
||||
|
||||
const onNext = () => {
|
||||
if (step === 1 && !canNext) {
|
||||
toast.error(t("validation.needNamePhone"))
|
||||
return
|
||||
}
|
||||
if (step === 2 && isParent && !canNext) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
goToStep(step + 1)
|
||||
}
|
||||
|
||||
const onBack = () => {
|
||||
goToStep(step - 1)
|
||||
}
|
||||
|
||||
const canSkipStep2 = isAdmin || isStudent || isTeacher
|
||||
const onSkip = () => {
|
||||
if (isParent) return
|
||||
goToStep(isAdmin ? 2 : 3)
|
||||
}
|
||||
|
||||
const addChildRow = () => {
|
||||
setChildren((prev) => [...prev, { ...EMPTY_CHILD_ROW }])
|
||||
}
|
||||
const removeChildRow = (idx: number) => {
|
||||
setChildren((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)))
|
||||
}
|
||||
const updateChildRow = (idx: number, patch: Partial<ChildRow>) => {
|
||||
setChildren((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
||||
}
|
||||
|
||||
const onFinish = async () => {
|
||||
if (isParent) {
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
if (validChildren.length === 0) {
|
||||
toast.error(t("validation.needOneChild"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.set("name", name.trim())
|
||||
formData.set("phone", phone.trim())
|
||||
formData.set("address", address.trim())
|
||||
// v3:邀请码统一大写化后提交
|
||||
formData.set("classCodes", classCodes.trim().toUpperCase())
|
||||
formData.set("teacherSubjects", JSON.stringify(teacherSubjects))
|
||||
const validChildren = children.filter(
|
||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
||||
)
|
||||
formData.set("children", JSON.stringify(validChildren))
|
||||
|
||||
const result = await completeOnboardingAction(null, formData)
|
||||
if (!result.success) {
|
||||
toast.error(result.message ?? t("toast.submitFailed"))
|
||||
return
|
||||
}
|
||||
|
||||
if (result.message && result.message.includes("绑定失败")) {
|
||||
toast.warning(result.message)
|
||||
} else {
|
||||
toast.success(t("toast.completeSuccess"))
|
||||
}
|
||||
|
||||
await update?.()
|
||||
const target = result.data?.defaultPath ?? "/dashboard"
|
||||
router.push(target)
|
||||
router.refresh()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
|
||||
toast.error(msg)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = t(`steps.${stepKeys[step]}`)
|
||||
// audit-P1-13:标题/描述/内容均按 currentStepId 渲染,不再依赖数字下标。
|
||||
const title = t(`steps.${currentStepId}`)
|
||||
const description =
|
||||
step === 0
|
||||
currentStepId === "roleConfirm"
|
||||
? t("role.adminAssigned")
|
||||
: step === 1
|
||||
: currentStepId === "basicInfo"
|
||||
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
|
||||
: step === 2
|
||||
: currentStepId === "roleInfo"
|
||||
? isParent
|
||||
? t("parent.bindHint")
|
||||
: t("steps.roleInfo")
|
||||
@@ -216,8 +100,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Step 0: 角色确认(只读) */}
|
||||
{step === 0 ? (
|
||||
{/* Step: 角色确认(只读) */}
|
||||
{currentStepId === "roleConfirm" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>{t("role.yourRole")}</Label>
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm">
|
||||
@@ -232,8 +116,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 1: 基础信息 */}
|
||||
{step === 1 ? (
|
||||
{/* Step: 基础信息 */}
|
||||
{currentStepId === "basicInfo" ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="onb_name">{t("form.name")} *</Label>
|
||||
@@ -268,8 +152,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 2: 角色信息 */}
|
||||
{step === 2 ? (
|
||||
{/* Step: 角色信息 */}
|
||||
{currentStepId === "roleInfo" ? (
|
||||
<div className="grid gap-4">
|
||||
{isTeacher ? (
|
||||
<>
|
||||
@@ -315,91 +199,19 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
) : null}
|
||||
|
||||
{isParent ? (
|
||||
<div className="grid gap-4">
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
|
||||
{t("parent.bindHint")}
|
||||
</div>
|
||||
{children.map((row, idx) => (
|
||||
<div key={idx} className="grid gap-3 rounded-md border p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">{t("parent.childN", { index: idx + 1 })}</span>
|
||||
{children.length > 1 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeChildRow(idx)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{tCommon("remove")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_email_${idx}`}>{t("parent.childEmail")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_email_${idx}`}
|
||||
type="email"
|
||||
value={row.childEmail}
|
||||
onChange={(e) => updateChildRow(idx, { childEmail: e.target.value })}
|
||||
placeholder="student@example.com"
|
||||
<ParentChildrenForm
|
||||
childRows={children}
|
||||
isSubmitting={isSubmitting}
|
||||
onUpdate={updateChildRow}
|
||||
onAdd={addChildRow}
|
||||
onRemove={removeChildRow}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_birth_${idx}`}>{t("parent.childBirthDate")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_birth_${idx}`}
|
||||
type="date"
|
||||
value={row.childBirthDate}
|
||||
onChange={(e) => updateChildRow(idx, { childBirthDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_phone_${idx}`}>{t("parent.childPhoneSuffix")} *</Label>
|
||||
<Input
|
||||
id={`onb_child_phone_${idx}`}
|
||||
value={row.childPhoneSuffix}
|
||||
onChange={(e) =>
|
||||
updateChildRow(idx, {
|
||||
childPhoneSuffix: e.target.value.replace(/\D/g, "").slice(0, 4),
|
||||
})
|
||||
}
|
||||
placeholder="4 位数字"
|
||||
inputMode="numeric"
|
||||
maxLength={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={`onb_child_relation_${idx}`}>{t("parent.childRelation")}</Label>
|
||||
<Input
|
||||
id={`onb_child_relation_${idx}`}
|
||||
value={row.childRelation}
|
||||
onChange={(e) => updateChildRow(idx, { childRelation: e.target.value })}
|
||||
placeholder={t("parent.childRelationPlaceholder")}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{children.length < 10 ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addChildRow}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("parent.addChild")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Step 3: 完成 */}
|
||||
{step === (isAdmin ? 2 : 3) ? (
|
||||
{/* Step: 完成 */}
|
||||
{currentStepId === "complete" ? (
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
|
||||
<div className="font-medium">{t("complete.ready")}</div>
|
||||
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
|
||||
@@ -417,7 +229,7 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
>
|
||||
{tCommon("previous")}
|
||||
</Button>
|
||||
{step === 2 && canSkipStep2 ? (
|
||||
{canSkip ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
@@ -444,8 +256,3 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function clampStep(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.max(0, Math.min(3, Math.floor(value)))
|
||||
}
|
||||
|
||||
122
src/modules/onboarding/components/parent-children-form.tsx
Normal file
122
src/modules/onboarding/components/parent-children-form.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
parentStudentRelations,
|
||||
} from "@/shared/db/schema"
|
||||
import type { Role } from "@/shared/types/permissions"
|
||||
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
|
||||
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
|
||||
import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } from "./types"
|
||||
|
||||
/**
|
||||
@@ -125,15 +125,3 @@ export async function bindParentToChild(
|
||||
|
||||
return { studentId: child.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色解析默认跳转路径(与 proxy.ts 的 resolveDefaultPath 保持一致)。
|
||||
*/
|
||||
export function resolveDefaultPathByRoles(roleNames: string[]): string {
|
||||
const normalized = roleNames.map((r) => normalizeRole(r))
|
||||
if (normalized.includes("admin")) return "/admin/dashboard"
|
||||
if (normalized.includes("teacher")) return "/teacher/dashboard"
|
||||
if (normalized.includes("student")) return "/student/dashboard"
|
||||
if (normalized.includes("parent")) return "/parent/dashboard"
|
||||
return "/dashboard"
|
||||
}
|
||||
|
||||
220
src/modules/onboarding/hooks/use-onboarding-form.ts
Normal file
220
src/modules/onboarding/hooks/use-onboarding-form.ts
Normal 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 表单状态与处理逻辑 Hook(P1-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-1:URL 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>
|
||||
@@ -1,12 +1,94 @@
|
||||
import type { NormalizedRole } from "@/shared/lib/role-utils"
|
||||
import type { Role } from "@/shared/types/permissions"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// audit-P1-13:Onboarding 步骤配置驱动设计
|
||||
//
|
||||
// 原先步骤列表与角色过滤逻辑硬编码在 use-onboarding-form.ts 中
|
||||
// (`STEPS_KEYS` 常量 + `isAdmin ? filter : identity` 三元表达式),
|
||||
// 新增/调整步骤时必须修改 Hook 逻辑代码。
|
||||
//
|
||||
// 现抽取为配置常量 ONBOARDING_STEPS + 纯函数 getOnboardingStepsForRole,
|
||||
// onboarding-stepper 与 use-onboarding-form 仅消费配置,不再内联步骤定义。
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Onboarding 流程的 4 个步骤标识,同时作为 i18n 键后缀(`onboarding.steps.${id}`)。 */
|
||||
export type OnboardingStepId = "roleConfirm" | "basicInfo" | "roleInfo" | "complete"
|
||||
|
||||
/**
|
||||
* 单个步骤的配置。
|
||||
*
|
||||
* - `applicableRoles`:该步骤对哪些角色显示。`undefined` 表示对所有角色显示。
|
||||
* 角色不在列表中时,该步骤被完全跳过(例如 admin 与 grade_head/teaching_head
|
||||
* 跳过 roleInfo 步骤,因为他们在该步骤无需填写任何内容)。
|
||||
* - `canSkip`:返回 true 时显示"跳过"按钮。仅在该步骤对当前角色显示时才有意义。
|
||||
* parent 必须绑定至少一个子女,故 roleInfo 步骤对 parent 不可跳过。
|
||||
*/
|
||||
export type OnboardingStepConfig = {
|
||||
id: OnboardingStepId
|
||||
applicableRoles?: readonly NormalizedRole[]
|
||||
canSkip?: (role: NormalizedRole) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 步骤配置数组(顺序即展示顺序,不可随意调整)。
|
||||
*
|
||||
* - roleConfirm:角色确认(只读,所有角色)
|
||||
* - basicInfo:基础信息(姓名/手机/地址,所有角色)
|
||||
* - roleInfo:角色信息(教师填班级码+科目、学生填班级码、家长绑定子女;
|
||||
* 仅 teacher/student/parent 显示;admin 与 grade_head/teaching_head 无需填写故跳过)
|
||||
* - complete:完成(所有角色)
|
||||
*/
|
||||
export const ONBOARDING_STEPS: readonly OnboardingStepConfig[] = [
|
||||
{ id: "roleConfirm" },
|
||||
{ id: "basicInfo" },
|
||||
{
|
||||
id: "roleInfo",
|
||||
applicableRoles: ["teacher", "student", "parent"],
|
||||
canSkip: (role) => role !== "parent",
|
||||
},
|
||||
{ id: "complete" },
|
||||
]
|
||||
|
||||
/**
|
||||
* 返回给定角色适用的步骤列表(保持 ONBOARDING_STEPS 中的顺序)。
|
||||
*
|
||||
* 用法:`const steps = getOnboardingStepsForRole(primaryRole)`
|
||||
* `steps[stepIndex].id` 即当前步骤标识。
|
||||
*/
|
||||
export function getOnboardingStepsForRole(
|
||||
role: NormalizedRole,
|
||||
): readonly OnboardingStepConfig[] {
|
||||
return ONBOARDING_STEPS.filter(
|
||||
(step) => !step.applicableRoles || step.applicableRoles.includes(role),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长在 Onboarding 中填写的子女信息行(P1-2 拆分时从 onboarding-stepper.tsx 提取)。
|
||||
* 三因子验证:邮箱 + 生日 + 手机号后 4 位。
|
||||
*/
|
||||
export type ChildRow = {
|
||||
childEmail: string
|
||||
childBirthDate: string
|
||||
childPhoneSuffix: string
|
||||
childRelation: string
|
||||
}
|
||||
|
||||
export const EMPTY_CHILD_ROW: ChildRow = {
|
||||
childEmail: "",
|
||||
childBirthDate: "",
|
||||
childPhoneSuffix: "",
|
||||
childRelation: "",
|
||||
}
|
||||
|
||||
/**
|
||||
* Onboarding 步骤中展示给用户的角色信息。
|
||||
* 角色来源于 usersToRoles(管理员预分配),用户不可修改。
|
||||
*/
|
||||
export type OnboardingRoleInfo = {
|
||||
/** 规范化后的主角色(admin/teacher/student/parent) */
|
||||
primary: "admin" | "teacher" | "student" | "parent"
|
||||
/** 规范化后的主角色(admin/grade_head/teaching_head/teacher/student/parent) */
|
||||
primary: NormalizedRole
|
||||
/** 用户拥有的全部角色名(含 grade_head/teaching_head 等) */
|
||||
all: Role[]
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Mail,
|
||||
Stethoscope,
|
||||
} from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
@@ -26,7 +27,7 @@ export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "sch
|
||||
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
|
||||
|
||||
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
|
||||
typeof v === "string" && (VALID_TABS as string[]).includes(v)
|
||||
typeof v === "string" && VALID_TABS.some((tab) => tab === v)
|
||||
|
||||
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
|
||||
isTab(v) ? v : "overview"
|
||||
@@ -40,40 +41,47 @@ export function ChildDetailPanel({
|
||||
initialTab?: string
|
||||
siblingSwitcher?: React.ReactNode
|
||||
}) {
|
||||
const t = useTranslations("parent")
|
||||
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
|
||||
const childName = basicInfo.name ?? "Child"
|
||||
const childName = basicInfo.name ?? t("childDetail.defaultName")
|
||||
|
||||
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
|
||||
|
||||
const tabs = useMemo(
|
||||
() => [
|
||||
{ id: "overview" as const, label: "Overview", icon: ClipboardList },
|
||||
{ id: "homework" as const, label: "Homework", icon: ClipboardList },
|
||||
{ id: "grades" as const, label: "Grades", icon: BarChart3 },
|
||||
{ id: "exams" as const, label: "Exams", icon: GraduationCap },
|
||||
{ id: "schedule" as const, label: "Schedule", icon: CalendarDays },
|
||||
{ id: "attendance" as const, label: "Attendance", icon: CalendarDays },
|
||||
{ id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope },
|
||||
{ id: "overview" as const, label: t("childDetail.tabs.overview"), icon: ClipboardList },
|
||||
{ id: "homework" as const, label: t("childDetail.tabs.homework"), icon: ClipboardList },
|
||||
{ id: "grades" as const, label: t("childDetail.tabs.grades"), icon: BarChart3 },
|
||||
{ id: "exams" as const, label: t("childDetail.tabs.exams"), icon: GraduationCap },
|
||||
{ id: "schedule" as const, label: t("childDetail.tabs.schedule"), icon: CalendarDays },
|
||||
{ id: "attendance" as const, label: t("childDetail.tabs.attendance"), icon: CalendarDays },
|
||||
{ id: "diagnostic" as const, label: t("childDetail.tabs.diagnostic"), icon: Stethoscope },
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{siblingSwitcher}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ChildDetailTab)} className="w-full">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => {
|
||||
if (isTab(v)) setTab(v)
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="overflow-x-auto">
|
||||
<TabsList className="w-full justify-start">
|
||||
{tabs.map((t) => (
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger
|
||||
key={t.id}
|
||||
value={t.id}
|
||||
key={tab.id}
|
||||
value={tab.id}
|
||||
className="gap-1.5"
|
||||
aria-label={`${t.label} tab`}
|
||||
aria-label={t("childDetail.tabAriaLabel", { label: tab.label })}
|
||||
>
|
||||
<t.icon className="h-3.5 w-3.5" />
|
||||
{t.label}
|
||||
<tab.icon className="h-3.5 w-3.5" />
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
@@ -108,7 +116,7 @@ export function ChildDetailPanel({
|
||||
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
|
||||
<div>
|
||||
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
|
||||
Subject Analysis
|
||||
{t("childDetail.subjectAnalysis")}
|
||||
</h3>
|
||||
<ChildGradeDetail grades={gradeTrend} />
|
||||
</div>
|
||||
@@ -134,14 +142,16 @@ export function ChildDetailPanel({
|
||||
<TabsContent value="attendance" className="mt-6">
|
||||
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Attendance details are available on the{" "}
|
||||
{t.rich("childDetail.attendanceHint", {
|
||||
link: (chunks) => (
|
||||
<a
|
||||
href="/parent/attendance"
|
||||
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Attendance page
|
||||
{chunks}
|
||||
</a>
|
||||
.
|
||||
),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -149,7 +159,7 @@ export function ChildDetailPanel({
|
||||
<TabsContent value="diagnostic" className="mt-6">
|
||||
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Diagnostic reports will be available here once published by the school.
|
||||
{t("childDetail.diagnosticHint")}
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -157,9 +167,12 @@ export function ChildDetailPanel({
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button asChild variant="ghost" size="sm" className="gap-2">
|
||||
<a href={`/messages?studentId=${basicInfo.id}`} aria-label={`Contact teacher about ${childName}`}>
|
||||
<a
|
||||
href={`/messages?studentId=${basicInfo.id}`}
|
||||
aria-label={t("childDetail.contactTeacherAria", { name: childName })}
|
||||
>
|
||||
<Mail className="h-4 w-4" />
|
||||
Contact Teacher
|
||||
{t("childDetail.contactTeacher")}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -175,21 +188,24 @@ export function SiblingSwitcher({
|
||||
current: { id: string; name: string | null }
|
||||
siblings: Array<{ id: string; name: string | null }>
|
||||
}) {
|
||||
const t = useTranslations("parent")
|
||||
if (siblings.length <= 1) return null
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">Switch child</span>
|
||||
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">
|
||||
{t("childDetail.switchChild")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{siblings.map((s) => {
|
||||
const isActive = s.id === current.id
|
||||
const label = s.name ?? "Child"
|
||||
const label = s.name ?? t("childDetail.defaultName")
|
||||
return (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`/parent/children/${s.id}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
aria-label={`View ${label}'s details`}
|
||||
aria-label={t("childDetail.viewDetailsAria", { name: label })}
|
||||
className={cn(
|
||||
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { JSX } from "react"
|
||||
import Link from "next/link"
|
||||
import { GraduationCap, TrendingUp, Award, BookOpen } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { GraduationCap, TrendingUp, Award, BookOpen, ChevronRight } from "lucide-react"
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -32,14 +33,19 @@ interface ChildExamDetailProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* V3-11: 家长端子女考试详情视图
|
||||
* V3-11 / P3-4: 家长端子女考试详情视图
|
||||
*
|
||||
* 对标智学网家长端,展示:
|
||||
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
|
||||
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
|
||||
* - 成绩趋势可视化
|
||||
*
|
||||
* P3-4 改进:
|
||||
* - 所有用户可见文本使用 i18n 翻译键(next-intl)
|
||||
* - 触控目标 ≥ 44px(min-h-[44px])
|
||||
* - 语义化标签 + ARIA 属性
|
||||
*/
|
||||
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
|
||||
const t = useTranslations("examHomework")
|
||||
const hasResults = examResults.length > 0
|
||||
|
||||
const examCount = examResults.length
|
||||
@@ -60,33 +66,39 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||
<GraduationCap className="h-5 w-5 text-primary" />
|
||||
<GraduationCap className="h-5 w-5 text-primary" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
|
||||
<p className="text-xs text-muted-foreground">Exams Taken</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("homework.parentExam.examsTaken")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
|
||||
<TrendingUp className="h-5 w-5 text-blue-500" />
|
||||
<TrendingUp className="h-5 w-5 text-blue-500" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">Average Score</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("homework.parentExam.averageScore")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 pt-6">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||
<Award className="h-5 w-5 text-green-500" />
|
||||
<Award className="h-5 w-5 text-green-500" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
|
||||
<p className="text-xs text-muted-foreground">Best Score</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("homework.parentExam.bestScore")}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -97,26 +109,28 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
{childName}'s Exam Results
|
||||
{t("homework.parentExam.examResults", { name: childName })}
|
||||
</CardTitle>
|
||||
<CardDescription>Recent exam scores and performance trends</CardDescription>
|
||||
<CardDescription>
|
||||
{t("homework.parentExam.examResultsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasResults ? (
|
||||
<EmptyState
|
||||
icon={GraduationCap}
|
||||
title="No exam results"
|
||||
description="Exam results will appear here once available."
|
||||
title={t("homework.parentExam.noResults")}
|
||||
description={t("homework.parentExam.noResultsHint")}
|
||||
className="border-none h-48"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<ul className="space-y-3" aria-label={t("homework.parentExam.examResults", { name: childName })}>
|
||||
{examResults.map((r) => {
|
||||
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
|
||||
const isPass = scoreRate >= 60
|
||||
return (
|
||||
<li key={r.submissionId}>
|
||||
<Link
|
||||
key={r.submissionId}
|
||||
href={`/parent/children/${childId}?tab=grades`}
|
||||
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
@@ -124,7 +138,7 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-sm truncate">{r.examTitle}</div>
|
||||
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
|
||||
{isPass ? "Pass" : "Below 60%"}
|
||||
{isPass ? t("homework.parentExam.pass") : t("homework.parentExam.belowPass")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
@@ -138,13 +152,17 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
||||
</div>
|
||||
<Progress value={scoreRate} className="h-1.5 mt-1" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
|
||||
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{scoreRate.toFixed(0)}%
|
||||
</span>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { useState, useRef, type KeyboardEvent } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { useTranslations, useLocale } from "next-intl"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import {
|
||||
ATTENDANCE_STATUS_DOT_COLORS,
|
||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||
} from "@/modules/attendance/constants"
|
||||
} from "@/shared/constants/attendance-status"
|
||||
import type {
|
||||
ParentAttendanceListItem,
|
||||
ParentAttendanceStatus,
|
||||
@@ -86,6 +86,7 @@ export function ParentAttendanceCalendar({
|
||||
summary: ParentStudentAttendanceSummary
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const locale = useLocale()
|
||||
const now = new Date()
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
||||
@@ -99,7 +100,7 @@ export function ParentAttendanceCalendar({
|
||||
}
|
||||
|
||||
const days = buildCalendarDays(viewYear, viewMonth)
|
||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
|
||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
})
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
@@ -50,15 +48,17 @@ const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长考勤页顶部的出勤率汇总卡片。
|
||||
* 家长考勤页顶部的出勤率汇总卡片(RSC)。
|
||||
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
|
||||
*
|
||||
* P2-8 修复:从 client component 降级为 RSC,使用 `getTranslations`。
|
||||
*/
|
||||
export function ParentAttendanceRateCard({
|
||||
export async function ParentAttendanceRateCard({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const t = await getTranslations("attendance")
|
||||
const stats = aggregateStats(summaries)
|
||||
if (stats.totalStudents === 0) return null
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"use client"
|
||||
|
||||
import { AlertTriangle, Phone } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { getTranslations } from "next-intl/server"
|
||||
|
||||
import { Card } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
@@ -14,8 +12,11 @@ type Warning = {
|
||||
severity: "high" | "medium"
|
||||
}
|
||||
|
||||
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
|
||||
type Translator = ReturnType<typeof useTranslations>
|
||||
/** 翻译函数类型(与 useTranslations / getTranslations 返回值兼容) */
|
||||
type Translator = (
|
||||
key: string,
|
||||
values?: Record<string, string | number | Date>,
|
||||
) => string
|
||||
|
||||
/**
|
||||
* 构建考勤异常预警列表(纯函数,便于测试)。
|
||||
@@ -67,15 +68,17 @@ export function buildWarnings(
|
||||
}
|
||||
|
||||
/**
|
||||
* 家长视角的考勤异常预警横幅。
|
||||
* 家长视角的考勤异常预警横幅(RSC)。
|
||||
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
|
||||
*
|
||||
* P2-8 修复:从 client component 降级为 RSC,使用 `getTranslations`。
|
||||
*/
|
||||
export function ParentAttendanceWarning({
|
||||
export async function ParentAttendanceWarning({
|
||||
summaries,
|
||||
}: {
|
||||
summaries: ParentStudentAttendanceSummary[]
|
||||
}) {
|
||||
const t = useTranslations("attendance")
|
||||
const t = await getTranslations("attendance")
|
||||
const warnings = buildWarnings(summaries, t)
|
||||
if (warnings.length === 0) return null
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
getStudentSchedule,
|
||||
} from "@/modules/classes/data-access"
|
||||
import {
|
||||
getStudentDashboardGrades,
|
||||
getStudentHomeworkAssignments,
|
||||
getStudentExamResults,
|
||||
} from "@/modules/homework/data-access"
|
||||
} from "@/modules/homework/data-access-student"
|
||||
import { getStudentDashboardGrades } from "@/modules/homework/stats-service"
|
||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||
import { getGradeNameById } from "@/modules/school/data-access"
|
||||
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/data-access"
|
||||
@@ -283,3 +283,22 @@ export const getParentIdsByStudentIds = cache(
|
||||
return Array.from(new Set(rows.map((r) => r.parentId)))
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* L-2: 批量查询学生→家长映射(保留关联关系)。
|
||||
* 用于考勤通知等需要按家长-学生分组发送的场景,避免 N+1 查询。
|
||||
* 返回数组形如 [{ parentId, studentId }],同一家长多个学生会出现多条。
|
||||
*/
|
||||
export const getParentStudentMapByStudentIds = cache(
|
||||
async (studentIds: string[]): Promise<Array<{ parentId: string; studentId: string }>> => {
|
||||
if (studentIds.length === 0) return []
|
||||
const rows = await db
|
||||
.select({
|
||||
parentId: parentStudentRelations.parentId,
|
||||
studentId: parentStudentRelations.studentId,
|
||||
})
|
||||
.from(parentStudentRelations)
|
||||
.where(inArray(parentStudentRelations.studentId, studentIds))
|
||||
return rows.map((r) => ({ parentId: r.parentId, studentId: r.studentId }))
|
||||
},
|
||||
)
|
||||
|
||||
@@ -104,12 +104,14 @@ export type ParentAttendanceStatus =
|
||||
| "late"
|
||||
| "early_leave"
|
||||
| "excused"
|
||||
| "school_activity"
|
||||
|
||||
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
|
||||
export type ParentAttendanceListItem = {
|
||||
id: string
|
||||
date: string
|
||||
status: ParentAttendanceStatus
|
||||
reason: string | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user