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 { revalidatePath } from "next/cache"
|
||||||
|
|
||||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
|
||||||
import { Permissions } from "@/shared/types/permissions"
|
import { Permissions, type AuthContext } from "@/shared/types/permissions"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import { handleActionError } from "@/shared/lib/action-utils"
|
import { handleActionError } from "@/shared/lib/action-utils"
|
||||||
|
import {
|
||||||
|
getStudentActiveClassId,
|
||||||
|
getStudentActiveGradeId,
|
||||||
|
verifyTeacherOwnsClass,
|
||||||
|
} from "@/modules/classes/data-access"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CreatePracticeSessionSchema,
|
CreatePracticeSessionSchema,
|
||||||
@@ -22,7 +27,98 @@ import {
|
|||||||
getPracticeSessions,
|
getPracticeSessions,
|
||||||
getPracticeStats,
|
getPracticeStats,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import type { PracticeSessionDetail, PracticeSessionSummary, PracticeStats, PracticeSourceMeta } from "./types"
|
import { parseSourceMeta } from "./lib/source-meta"
|
||||||
|
import { practiceErrors } from "./lib/errors"
|
||||||
|
import type {
|
||||||
|
PracticeSessionDetail,
|
||||||
|
PracticeSessionSummary,
|
||||||
|
PracticeStats,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 权限辅助:dataScope 下 studentId 归属校验
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据 dataScope 解析并校验目标学生 ID。
|
||||||
|
*
|
||||||
|
* 行级权限校验(P0 安全修复):
|
||||||
|
* - `all`:admin 可访问任意 studentId(不传则用 ctx.userId)
|
||||||
|
* - `owned`:仅可访问 ctx.userId(学生本人)
|
||||||
|
* - `class_members`:仅可访问 ctx.userId(学生本人)
|
||||||
|
* - `children`:仅可访问 childrenIds 中的学生(家长)
|
||||||
|
* - `class_taught`:校验学生属于教师所教班级(教师)
|
||||||
|
* - `grade_managed`:校验学生属于年级主任所辖年级(年级主任)
|
||||||
|
*
|
||||||
|
* @throws {PermissionDeniedError} studentId 不在 dataScope 范围内
|
||||||
|
* @returns 校验通过的目标 studentId
|
||||||
|
*/
|
||||||
|
async function resolveTargetStudentId(
|
||||||
|
ctx: AuthContext,
|
||||||
|
studentId?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// 未传 studentId:根据 scope 选取默认值
|
||||||
|
if (!studentId || studentId === ctx.userId) {
|
||||||
|
if (ctx.dataScope.type === "children") {
|
||||||
|
// 家长默认查第一个孩子
|
||||||
|
return ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||||
|
}
|
||||||
|
return ctx.userId
|
||||||
|
}
|
||||||
|
|
||||||
|
// 传入了 studentId:按 scope 校验归属
|
||||||
|
switch (ctx.dataScope.type) {
|
||||||
|
case "all":
|
||||||
|
// admin 无限制
|
||||||
|
return studentId
|
||||||
|
|
||||||
|
case "owned":
|
||||||
|
// 学生仅能访问自己
|
||||||
|
if (studentId !== ctx.userId) {
|
||||||
|
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
}
|
||||||
|
return studentId
|
||||||
|
|
||||||
|
case "class_members":
|
||||||
|
// 学生仅能访问自己
|
||||||
|
if (studentId !== ctx.userId) {
|
||||||
|
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
}
|
||||||
|
return studentId
|
||||||
|
|
||||||
|
case "children":
|
||||||
|
// 家长仅能访问自己的子女
|
||||||
|
if (!ctx.dataScope.childrenIds.includes(studentId)) {
|
||||||
|
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
}
|
||||||
|
return studentId
|
||||||
|
|
||||||
|
case "class_taught": {
|
||||||
|
// 教师:校验学生属于其所教班级
|
||||||
|
const classId = await getStudentActiveClassId(studentId)
|
||||||
|
if (!classId) {
|
||||||
|
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
}
|
||||||
|
const owns = await verifyTeacherOwnsClass(classId, ctx.userId)
|
||||||
|
if (!owns) {
|
||||||
|
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
}
|
||||||
|
return studentId
|
||||||
|
}
|
||||||
|
|
||||||
|
case "grade_managed": {
|
||||||
|
// 年级主任:校验学生属于其所辖年级
|
||||||
|
const gradeId = await getStudentActiveGradeId(studentId)
|
||||||
|
if (!gradeId || !ctx.dataScope.gradeIds.includes(gradeId)) {
|
||||||
|
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
}
|
||||||
|
return studentId
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 查询 Actions
|
// 查询 Actions
|
||||||
@@ -33,25 +129,12 @@ export async function getPracticeSessionsAction(
|
|||||||
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>> {
|
): Promise<ActionState<{ data: PracticeSessionSummary[]; total: number }>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||||
let targetStudentId = ctx.userId
|
|
||||||
if (studentId && studentId !== ctx.userId) {
|
|
||||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
|
||||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
|
||||||
}
|
|
||||||
targetStudentId = studentId
|
|
||||||
} else if (ctx.dataScope.type === "children") {
|
|
||||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await getPracticeSessions(targetStudentId)
|
const result = await getPracticeSessions(targetStudentId)
|
||||||
return { success: true, data: result }
|
return { success: true, data: result }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
const message = e instanceof Error ? e.message : "获取练习列表失败"
|
|
||||||
return { success: false, message }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,28 +144,16 @@ export async function getPracticeSessionDetailAction(
|
|||||||
): Promise<ActionState<PracticeSessionDetail>> {
|
): Promise<ActionState<PracticeSessionDetail>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||||
let targetStudentId = ctx.userId
|
|
||||||
if (studentId && studentId !== ctx.userId) {
|
|
||||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
|
||||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
|
||||||
}
|
|
||||||
targetStudentId = studentId
|
|
||||||
} else if (ctx.dataScope.type === "children") {
|
|
||||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await getPracticeSessionById(sessionId, targetStudentId)
|
const data = await getPracticeSessionById(sessionId, targetStudentId)
|
||||||
if (!data) {
|
if (!data) {
|
||||||
return { success: false, message: "练习会话不存在或无权访问" }
|
// 通过 errorCode 返回,前端 t(`errors.session_not_found`) 查 i18n
|
||||||
|
throw practiceErrors.sessionNotFound()
|
||||||
}
|
}
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
const message = e instanceof Error ? e.message : "获取练习详情失败"
|
|
||||||
return { success: false, message }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,25 +162,12 @@ export async function getPracticeStatsAction(
|
|||||||
): Promise<ActionState<PracticeStats>> {
|
): Promise<ActionState<PracticeStats>> {
|
||||||
try {
|
try {
|
||||||
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
const ctx = await requirePermission(Permissions.ADAPTIVE_PRACTICE_READ)
|
||||||
|
const targetStudentId = await resolveTargetStudentId(ctx, studentId)
|
||||||
let targetStudentId = ctx.userId
|
|
||||||
if (studentId && studentId !== ctx.userId) {
|
|
||||||
if (ctx.dataScope.type !== "children" || !ctx.dataScope.childrenIds.includes(studentId)) {
|
|
||||||
throw new PermissionDeniedError(Permissions.ADAPTIVE_PRACTICE_READ)
|
|
||||||
}
|
|
||||||
targetStudentId = studentId
|
|
||||||
} else if (ctx.dataScope.type === "children") {
|
|
||||||
targetStudentId = ctx.dataScope.childrenIds[0] ?? ctx.userId
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await getPracticeStats(targetStudentId)
|
const data = await getPracticeStats(targetStudentId)
|
||||||
return { success: true, data }
|
return { success: true, data }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof PermissionDeniedError) {
|
return handleActionError(e)
|
||||||
return { success: false, message: e.message }
|
|
||||||
}
|
|
||||||
const message = e instanceof Error ? e.message : "获取练习统计失败"
|
|
||||||
return { success: false, message }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +184,8 @@ export async function createPracticeSessionAction(
|
|||||||
|
|
||||||
const jsonString = formData.get("json")
|
const jsonString = formData.get("json")
|
||||||
if (typeof jsonString !== "string") {
|
if (typeof jsonString !== "string") {
|
||||||
return { success: false, message: "提交格式错误,需要 JSON 字段" }
|
// errorCode: invalid_input → 前端 t(`errors.invalid_input`)
|
||||||
|
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString))
|
const parsed = CreatePracticeSessionSchema.safeParse(JSON.parse(jsonString))
|
||||||
@@ -134,31 +193,38 @@ export async function createPracticeSessionAction(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "输入验证失败",
|
message: "输入验证失败",
|
||||||
|
errorCode: "validation_error",
|
||||||
errors: parsed.error.flatten().fieldErrors,
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从 JSON 解析的 sourceMeta 需要经过 unknown 中间转换
|
// 严格类型守卫:替代 `as unknown as PracticeSourceMeta` 断言
|
||||||
// 因为 Zod 的 z.record(z.string(), z.unknown()) 返回 Record<string, unknown>
|
const sourceMeta = parseSourceMeta(parsed.data.practiceType, parsed.data.sourceMeta)
|
||||||
// 而实际运行时结构由前端按练习类型构建,此处做类型收窄
|
if (!sourceMeta) {
|
||||||
const sourceMeta = parsed.data.sourceMeta as unknown as PracticeSourceMeta
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "来源元数据结构与练习类型不匹配",
|
||||||
|
errorCode: "invalid_source_meta",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await createPracticeSession(ctx.userId, {
|
// 写入操作仅允许学生本人或家长代子女发起
|
||||||
|
// 家长 dataScope.type === "children" 时,使用第一个子女 ID
|
||||||
|
const targetStudentId = ctx.dataScope.type === "children"
|
||||||
|
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||||
|
: ctx.userId
|
||||||
|
|
||||||
|
const result = await createPracticeSession(targetStudentId, {
|
||||||
practiceType: parsed.data.practiceType,
|
practiceType: parsed.data.practiceType,
|
||||||
subjectId: parsed.data.subjectId,
|
subjectId: parsed.data.subjectId,
|
||||||
sourceMeta,
|
sourceMeta,
|
||||||
questionCount: parsed.data.questionCount,
|
questionCount: parsed.data.questionCount,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.selectedCount === 0) {
|
|
||||||
return { success: false, message: "未找到符合条件的题目,请尝试其他筛选条件" }
|
|
||||||
}
|
|
||||||
|
|
||||||
revalidatePath("/student/practice")
|
revalidatePath("/student/practice")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: `已创建练习会话,共 ${result.selectedCount} 道题目`,
|
|
||||||
data: result,
|
data: result,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -175,7 +241,7 @@ export async function submitPracticeAnswerAction(
|
|||||||
|
|
||||||
const jsonString = formData.get("json")
|
const jsonString = formData.get("json")
|
||||||
if (typeof jsonString !== "string") {
|
if (typeof jsonString !== "string") {
|
||||||
return { success: false, message: "提交格式错误" }
|
return { success: false, message: "提交格式错误", errorCode: "invalid_input" }
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString))
|
const parsed = SubmitPracticeAnswerSchema.safeParse(JSON.parse(jsonString))
|
||||||
@@ -183,15 +249,21 @@ export async function submitPracticeAnswerAction(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "输入验证失败",
|
message: "输入验证失败",
|
||||||
|
errorCode: "validation_error",
|
||||||
errors: parsed.error.flatten().fieldErrors,
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const { sessionId, answerId, answer, skip } = parsed.data
|
const { sessionId, answerId, answer, skip } = parsed.data
|
||||||
|
|
||||||
|
// 学生本人或家长代子女提交
|
||||||
|
const targetStudentId = ctx.dataScope.type === "children"
|
||||||
|
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||||
|
: ctx.userId
|
||||||
|
|
||||||
const result = await submitPracticeAnswer(
|
const result = await submitPracticeAnswer(
|
||||||
sessionId,
|
sessionId,
|
||||||
ctx.userId,
|
targetStudentId,
|
||||||
answerId,
|
answerId,
|
||||||
answer,
|
answer,
|
||||||
skip ?? false,
|
skip ?? false,
|
||||||
@@ -201,7 +273,6 @@ export async function submitPracticeAnswerAction(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: skip ? "已跳过此题" : (result.isCorrect === true ? "回答正确" : result.isCorrect === false ? "回答错误" : "答案已提交"),
|
|
||||||
data: result,
|
data: result,
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -223,16 +294,21 @@ export async function completePracticeSessionAction(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "输入验证失败",
|
message: "输入验证失败",
|
||||||
|
errorCode: "validation_error",
|
||||||
errors: parsed.error.flatten().fieldErrors,
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await completePracticeSession(parsed.data.sessionId, ctx.userId)
|
const targetStudentId = ctx.dataScope.type === "children"
|
||||||
|
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||||
|
: ctx.userId
|
||||||
|
|
||||||
|
await completePracticeSession(parsed.data.sessionId, targetStudentId)
|
||||||
|
|
||||||
revalidatePath("/student/practice")
|
revalidatePath("/student/practice")
|
||||||
revalidatePath(`/student/practice/${parsed.data.sessionId}`)
|
revalidatePath(`/student/practice/${parsed.data.sessionId}`)
|
||||||
|
|
||||||
return { success: true, message: "练习已完成" }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
@@ -252,15 +328,20 @@ export async function abandonPracticeSessionAction(
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
message: "输入验证失败",
|
message: "输入验证失败",
|
||||||
|
errorCode: "validation_error",
|
||||||
errors: parsed.error.flatten().fieldErrors,
|
errors: parsed.error.flatten().fieldErrors,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await abandonPracticeSession(parsed.data.sessionId, ctx.userId)
|
const targetStudentId = ctx.dataScope.type === "children"
|
||||||
|
? ctx.dataScope.childrenIds[0] ?? ctx.userId
|
||||||
|
: ctx.userId
|
||||||
|
|
||||||
|
await abandonPracticeSession(parsed.data.sessionId, targetStudentId)
|
||||||
|
|
||||||
revalidatePath("/student/practice")
|
revalidatePath("/student/practice")
|
||||||
|
|
||||||
return { success: true, message: "练习已放弃" }
|
return { success: true }
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return handleActionError(e)
|
return handleActionError(e)
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
interface PracticeHistoryProps {
|
||||||
sessions: PracticeSessionSummary[]
|
sessions: PracticeSessionSummary[]
|
||||||
|
/**
|
||||||
|
* 点击某条练习记录跳转的路由前缀。
|
||||||
|
* - 学生端传 `/student/practice`,将跳转到 `/student/practice/{sessionId}`
|
||||||
|
* - 家长端不传(无会话详情页),仅渲染为只读卡片
|
||||||
|
*/
|
||||||
|
routePrefix?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline"> = {
|
const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline"> = {
|
||||||
@@ -24,7 +30,10 @@ const STATUS_VARIANTS: Record<PracticeStatus, "default" | "secondary" | "outline
|
|||||||
/**
|
/**
|
||||||
* 专项练习历史列表
|
* 专项练习历史列表
|
||||||
*/
|
*/
|
||||||
export function PracticeHistory({ sessions }: PracticeHistoryProps): React.ReactNode {
|
export function PracticeHistory({
|
||||||
|
sessions,
|
||||||
|
routePrefix,
|
||||||
|
}: PracticeHistoryProps): React.ReactNode {
|
||||||
const t = useTranslations("practice")
|
const t = useTranslations("practice")
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
@@ -48,47 +57,55 @@ export function PracticeHistory({ sessions }: PracticeHistoryProps): React.React
|
|||||||
? (session.answeredQuestions / session.totalQuestions) * 100
|
? (session.answeredQuestions / session.totalQuestions) * 100
|
||||||
: 0
|
: 0
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<Link key={session.id} href={`/student/practice/${session.id}`}>
|
<Card className="transition-colors hover:bg-muted/30">
|
||||||
<Card className="transition-colors hover:bg-muted/30">
|
<CardContent className="p-4">
|
||||||
<CardContent className="p-4">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex-1 space-y-1">
|
||||||
<div className="flex-1 space-y-1">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<Badge variant={STATUS_VARIANTS[session.status]}>
|
||||||
<Badge variant={STATUS_VARIANTS[session.status]}>
|
{t(`status.${session.status}`)}
|
||||||
{t(`status.${session.status}`)}
|
</Badge>
|
||||||
</Badge>
|
<Badge variant="outline">
|
||||||
<Badge variant="outline">
|
{t(`types.${session.practiceType}`)}
|
||||||
{t(`types.${session.practiceType}`)}
|
</Badge>
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
{formatDate(session.startedAt)}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
|
||||||
{session.correctCount}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<XCircle className="h-3 w-3 text-rose-500" />
|
|
||||||
{session.answeredQuestions - session.correctCount}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
<div className="text-lg font-bold">{accuracy}%</div>
|
<span className="flex items-center gap-1">
|
||||||
<div className="text-xs text-muted-foreground">
|
<Clock className="h-3 w-3" />
|
||||||
{session.answeredQuestions}/{session.totalQuestions}
|
{formatDate(session.startedAt)}
|
||||||
</div>
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-emerald-500" />
|
||||||
|
{session.correctCount}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<XCircle className="h-3 w-3 text-rose-500" />
|
||||||
|
{session.answeredQuestions - session.correctCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progress} className="mt-2 h-1" />
|
<div className="text-right">
|
||||||
</CardContent>
|
<div className="text-lg font-bold">{accuracy}%</div>
|
||||||
</Card>
|
<div className="text-xs text-muted-foreground">
|
||||||
</Link>
|
{session.answeredQuestions}/{session.totalQuestions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Progress value={progress} className="mt-2 h-1" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (routePrefix) {
|
||||||
|
return (
|
||||||
|
<Link key={session.id} href={`${routePrefix}/${session.id}`}>
|
||||||
|
{content}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div key={session.id}>{content}</div>
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 { useState, useTransition, useMemo } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { CheckCircle2, XCircle, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
|
import { CheckCircle2, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { Progress } from "@/shared/components/ui/progress"
|
import { Progress } from "@/shared/components/ui/progress"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
|
||||||
import { RadioGroup, RadioGroupItem } from "@/shared/components/ui/radio-group"
|
|
||||||
import { Label } from "@/shared/components/ui/label"
|
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -25,36 +21,55 @@ import {
|
|||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from "@/shared/components/ui/alert-dialog"
|
} from "@/shared/components/ui/alert-dialog"
|
||||||
|
|
||||||
import { submitPracticeAnswerAction, completePracticeSessionAction, abandonPracticeSessionAction } from "../actions"
|
import { QuestionCard } from "./question-card"
|
||||||
import type { PracticeSessionDetail, PracticeAnswerRecord } from "../types"
|
import { PracticeResultView } from "./practice-result-view"
|
||||||
|
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
|
||||||
|
import type { PracticeSessionDetail } from "../types"
|
||||||
|
|
||||||
interface PracticeSessionViewProps {
|
interface PracticeSessionViewProps {
|
||||||
session: PracticeSessionDetail
|
session: PracticeSessionDetail
|
||||||
|
/**
|
||||||
|
* 放弃练习后的跳转回调(由页面层注入路由跳转逻辑)。
|
||||||
|
*
|
||||||
|
* 解耦设计:组件本身不硬编码路由,由调用方决定跳转目标。
|
||||||
|
* 不传则使用默认行为 `router.push("/student/practice")`(向后兼容)。
|
||||||
|
*/
|
||||||
|
onAbandoned?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 专项练习答题界面
|
* 专项练习答题界面(编排层)。
|
||||||
*
|
*
|
||||||
* 功能:
|
* 职责:
|
||||||
* 1. 逐题作答,支持上一题/下一题导航
|
* 1. 管理 UI 状态(当前题目索引、答案暂存、判分结果缓存)
|
||||||
* 2. 自动判分(选择题/判断题)
|
* 2. 通过 `usePracticeService()` 调用数据服务(不直接 import actions)
|
||||||
* 3. 跳过题目
|
* 3. 组合 QuestionCard / PracticeResultView 等子组件
|
||||||
* 4. 完成练习后展示结果摘要
|
*
|
||||||
|
* 子组件:
|
||||||
|
* - QuestionCard: 题目卡片(含内容渲染、答题输入、结果展示)
|
||||||
|
* - PracticeResultView: 完成后的结果视图
|
||||||
|
*
|
||||||
|
* 提交失败支持重试(通过 submitFailed + onRetry 传递给 QuestionCard)。
|
||||||
*/
|
*/
|
||||||
export function PracticeSessionView({ session }: PracticeSessionViewProps): React.ReactNode {
|
export function PracticeSessionView({
|
||||||
|
session,
|
||||||
|
onAbandoned,
|
||||||
|
}: PracticeSessionViewProps): React.ReactNode {
|
||||||
const t = useTranslations("practice")
|
const t = useTranslations("practice")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const service = usePracticeService()
|
||||||
|
const analytics = usePracticeAnalytics()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
const [currentIndex, setCurrentIndex] = useState(0)
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
const [answers, setAnswers] = useState<Record<string, unknown>>({})
|
const [answers, setAnswers] = useState<Record<string, unknown>>({})
|
||||||
const [results, setResults] = useState<Record<string, { isCorrect: boolean | null; score: number | null }>>({})
|
const [results, setResults] = useState<Record<string, { isCorrect: boolean | null; score: number | null }>>({})
|
||||||
|
const [failedAnswerIds, setFailedAnswerIds] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const answersList = session.answers
|
const answersList = session.answers
|
||||||
const total = answersList.length
|
const total = answersList.length
|
||||||
const current = answersList[currentIndex]
|
const current = answersList[currentIndex]
|
||||||
const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0
|
const progress = total > 0 ? ((currentIndex + 1) / total) * 100 : 0
|
||||||
|
|
||||||
// 已答题数和正确数
|
|
||||||
const answeredCount = useMemo(
|
const answeredCount = useMemo(
|
||||||
() => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length,
|
() => answersList.filter((a) => a.status === "answered" || a.status === "skipped").length,
|
||||||
[answersList],
|
[answersList],
|
||||||
@@ -64,6 +79,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
[answersList],
|
[answersList],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function resolveErrorMessage(
|
||||||
|
errorCode: string | undefined,
|
||||||
|
message: string | undefined,
|
||||||
|
fallbackKey: string,
|
||||||
|
): string {
|
||||||
|
if (errorCode) {
|
||||||
|
return t(`errors.${errorCode}`)
|
||||||
|
}
|
||||||
|
return message ?? t(fallbackKey)
|
||||||
|
}
|
||||||
|
|
||||||
if (session.status !== "in_progress") {
|
if (session.status !== "in_progress") {
|
||||||
return <PracticeResultView session={session} />
|
return <PracticeResultView session={session} />
|
||||||
}
|
}
|
||||||
@@ -92,16 +118,25 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
skip,
|
skip,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const res = await submitPracticeAnswerAction(undefined, formData)
|
const res = await service.submitAnswer(undefined, formData)
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
setResults((prev) => ({ ...prev, [current.id]: res.data! }))
|
const data = res.data
|
||||||
|
setResults((prev) => ({ ...prev, [current.id]: data }))
|
||||||
|
setFailedAnswerIds((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
next.delete(current.id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
analytics.trackAnswerSubmit(session.id, current.id, data.isCorrect)
|
||||||
toast.success(res.message ?? t("toasts.submitted"))
|
toast.success(res.message ?? t("toasts.submitted"))
|
||||||
// 自动跳到下一题
|
// 自动跳到下一题
|
||||||
if (currentIndex < total - 1) {
|
if (currentIndex < total - 1) {
|
||||||
setCurrentIndex(currentIndex + 1)
|
setCurrentIndex(currentIndex + 1)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("toasts.submitFailed"))
|
// 标记提交失败,显示重试按钮
|
||||||
|
setFailedAnswerIds((prev) => new Set(prev).add(current.id))
|
||||||
|
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -110,12 +145,13 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("sessionId", session.id)
|
formData.append("sessionId", session.id)
|
||||||
const res = await completePracticeSessionAction(undefined, formData)
|
const res = await service.completeSession(undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
analytics.trackSessionComplete(session.id, session.accuracy)
|
||||||
toast.success(res.message ?? t("toasts.completed"))
|
toast.success(res.message ?? t("toasts.completed"))
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("toasts.completeFailed"))
|
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -124,18 +160,32 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("sessionId", session.id)
|
formData.append("sessionId", session.id)
|
||||||
const res = await abandonPracticeSessionAction(undefined, formData)
|
const res = await service.abandonSession(undefined, formData)
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
|
analytics.trackSessionAbandon(session.id)
|
||||||
toast.success(res.message ?? t("toasts.abandoned"))
|
toast.success(res.message ?? t("toasts.abandoned"))
|
||||||
router.push("/student/practice")
|
// 优先使用注入的回调,否则回退到默认路由
|
||||||
|
if (onAbandoned) {
|
||||||
|
onAbandoned()
|
||||||
|
} else {
|
||||||
|
router.push("/student/practice")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("toasts.abandonFailed"))
|
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleRetry(): void {
|
||||||
|
if (current) {
|
||||||
|
analytics.trackErrorRetry(current.id)
|
||||||
|
}
|
||||||
|
handleSubmit(answers[current?.id ?? ""] ?? null)
|
||||||
|
}
|
||||||
|
|
||||||
const currentResult = current ? results[current.id] : undefined
|
const currentResult = current ? results[current.id] : undefined
|
||||||
const isAnswered = current?.status === "answered" || current?.status === "skipped"
|
const isAnswered = current?.status === "answered" || current?.status === "skipped"
|
||||||
|
const submitFailed = current ? failedAnswerIds.has(current.id) : false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -143,18 +193,27 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle className="text-lg">
|
<CardTitle
|
||||||
|
className="text-lg"
|
||||||
|
aria-label={`${t("session.progress")}: ${currentIndex + 1} / ${total}`}
|
||||||
|
>
|
||||||
{t("session.progress")}: {currentIndex + 1} / {total}
|
{t("session.progress")}: {currentIndex + 1} / {total}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
<div className="flex items-center gap-3 text-sm text-muted-foreground">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1" aria-label={`${t("result.correct")}: ${correctCount}`}>
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-500" aria-hidden="true" />
|
||||||
{correctCount}
|
{correctCount}
|
||||||
</span>
|
</span>
|
||||||
<span>{answeredCount}/{total}</span>
|
<span aria-label={`${t("result.answered")}: ${answeredCount}/${total}`}>
|
||||||
|
{answeredCount}/{total}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Progress value={progress} className="mt-2" />
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
className="mt-2"
|
||||||
|
aria-label={`${t("session.progress")}: ${Math.round(progress)}%`}
|
||||||
|
/>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -168,9 +227,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))}
|
onAnswerChange={(ans) => setAnswers((prev) => ({ ...prev, [current.id]: ans }))}
|
||||||
onSubmit={(ans) => handleSubmit(ans)}
|
onSubmit={(ans) => handleSubmit(ans)}
|
||||||
onSkip={() => handleSubmit(null, true)}
|
onSkip={() => handleSubmit(null, true)}
|
||||||
|
onRetry={handleRetry}
|
||||||
isAnswered={isAnswered}
|
isAnswered={isAnswered}
|
||||||
result={currentResult}
|
result={currentResult}
|
||||||
isPending={isPending}
|
isPending={isPending}
|
||||||
|
submitFailed={submitFailed}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
@@ -180,16 +241,17 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
|
onClick={() => setCurrentIndex(Math.max(0, currentIndex - 1))}
|
||||||
disabled={currentIndex === 0}
|
disabled={currentIndex === 0}
|
||||||
|
aria-label={t("session.previous")}
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4" />
|
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
|
||||||
{t("session.previous")}
|
{t("session.previous")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<AlertDialog>
|
<AlertDialog>
|
||||||
<AlertDialogTrigger asChild>
|
<AlertDialogTrigger asChild>
|
||||||
<Button variant="ghost" size="sm">
|
<Button variant="ghost" size="sm" aria-label={t("session.abandon")}>
|
||||||
<Flag className="h-4 w-4" />
|
<Flag className="h-4 w-4" aria-hidden="true" />
|
||||||
{t("session.abandon")}
|
{t("session.abandon")}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogTrigger>
|
</AlertDialogTrigger>
|
||||||
@@ -200,7 +262,11 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel>
|
<AlertDialogCancel>{t("session.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleAbandon} disabled={isPending}>
|
<AlertDialogAction
|
||||||
|
onClick={handleAbandon}
|
||||||
|
disabled={isPending}
|
||||||
|
aria-label={t("session.confirmAbandon")}
|
||||||
|
>
|
||||||
{t("session.confirmAbandon")}
|
{t("session.confirmAbandon")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
@@ -211,13 +277,18 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setCurrentIndex(currentIndex + 1)}
|
onClick={() => setCurrentIndex(currentIndex + 1)}
|
||||||
|
aria-label={t("session.next")}
|
||||||
>
|
>
|
||||||
{t("session.next")}
|
{t("session.next")}
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button onClick={handleComplete} disabled={isPending || answeredCount < total}>
|
<Button
|
||||||
<Trophy className="h-4 w-4" />
|
onClick={handleComplete}
|
||||||
|
disabled={isPending || answeredCount < total}
|
||||||
|
aria-label={t("session.complete")}
|
||||||
|
>
|
||||||
|
<Trophy className="h-4 w-4" aria-hidden="true" />
|
||||||
{t("session.complete")}
|
{t("session.complete")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -226,325 +297,3 @@ export function PracticeSessionView({ session }: PracticeSessionViewProps): Reac
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 题目卡片
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface QuestionCardProps {
|
|
||||||
answer: PracticeAnswerRecord
|
|
||||||
index: number
|
|
||||||
total: number
|
|
||||||
userAnswer: unknown
|
|
||||||
onAnswerChange: (answer: unknown) => void
|
|
||||||
onSubmit: (answer: unknown) => void
|
|
||||||
onSkip: () => void
|
|
||||||
isAnswered: boolean
|
|
||||||
result?: { isCorrect: boolean | null; score: number | null }
|
|
||||||
isPending: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
function QuestionCard({
|
|
||||||
answer,
|
|
||||||
index,
|
|
||||||
total,
|
|
||||||
userAnswer,
|
|
||||||
onAnswerChange,
|
|
||||||
onSubmit,
|
|
||||||
onSkip,
|
|
||||||
isAnswered,
|
|
||||||
result,
|
|
||||||
isPending,
|
|
||||||
}: QuestionCardProps): React.ReactNode {
|
|
||||||
const t = useTranslations("practice")
|
|
||||||
|
|
||||||
const question = answer.question
|
|
||||||
const content = answer.variantContent ?? question?.content
|
|
||||||
const questionType = question?.type ?? "unknown"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-base">
|
|
||||||
{t("session.question")} {index + 1}/{total}
|
|
||||||
</CardTitle>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant="outline">{questionType}</Badge>
|
|
||||||
{question?.difficulty ? (
|
|
||||||
<Badge variant="secondary">
|
|
||||||
{t("session.difficulty")}: {question.difficulty}
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
|
||||||
{answer.isVariant ? (
|
|
||||||
<Badge variant="default">{t("session.variant")}</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{/* 题目内容 */}
|
|
||||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
|
||||||
<pre className="whitespace-pre-wrap break-words font-sans">
|
|
||||||
{typeof content === "string"
|
|
||||||
? content
|
|
||||||
: JSON.stringify(content, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 作答区域 */}
|
|
||||||
{!isAnswered ? (
|
|
||||||
<AnswerInput
|
|
||||||
questionType={questionType}
|
|
||||||
content={content}
|
|
||||||
userAnswer={userAnswer}
|
|
||||||
onAnswerChange={onAnswerChange}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<AnswerResult
|
|
||||||
answer={answer}
|
|
||||||
result={result}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
{!isAnswered ? (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button variant="ghost" onClick={onSkip} disabled={isPending}>
|
|
||||||
{t("session.skip")}
|
|
||||||
</Button>
|
|
||||||
<Button onClick={() => onSubmit(userAnswer)} disabled={isPending || userAnswer === undefined}>
|
|
||||||
{isPending ? t("session.submitting") : t("session.submit")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 答题输入组件
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface AnswerInputProps {
|
|
||||||
questionType: string
|
|
||||||
content: unknown
|
|
||||||
userAnswer: unknown
|
|
||||||
onAnswerChange: (answer: unknown) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnswerInput({ questionType, content, userAnswer, onAnswerChange }: AnswerInputProps): React.ReactNode {
|
|
||||||
const t = useTranslations("practice")
|
|
||||||
|
|
||||||
if (questionType === "single_choice") {
|
|
||||||
const options = extractOptions(content)
|
|
||||||
const selectedId = typeof userAnswer === "string" ? userAnswer : ""
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioGroup value={selectedId} onValueChange={onAnswerChange}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{options.map((opt) => (
|
|
||||||
<div key={opt.id} className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value={opt.id} id={opt.id} />
|
|
||||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionType === "multiple_choice") {
|
|
||||||
const options = extractOptions(content)
|
|
||||||
const selectedIds = Array.isArray(userAnswer) ? userAnswer as string[] : []
|
|
||||||
|
|
||||||
function toggle(id: string): void {
|
|
||||||
const newIds = selectedIds.includes(id)
|
|
||||||
? selectedIds.filter((v) => v !== id)
|
|
||||||
: [...selectedIds, id]
|
|
||||||
onAnswerChange(newIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{options.map((opt) => (
|
|
||||||
<div key={opt.id} className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.includes(opt.id)}
|
|
||||||
onCheckedChange={() => toggle(opt.id)}
|
|
||||||
id={opt.id}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={opt.id}>{opt.text}</Label>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionType === "judgment") {
|
|
||||||
const value = typeof userAnswer === "string" ? userAnswer : ""
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RadioGroup value={value} onValueChange={onAnswerChange}>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="true" id="judgment-true" />
|
|
||||||
<Label htmlFor="judgment-true">{t("session.true")}</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="false" id="judgment-false" />
|
|
||||||
<Label htmlFor="judgment-false">{t("session.false")}</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</RadioGroup>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// text 题型
|
|
||||||
return (
|
|
||||||
<textarea
|
|
||||||
value={typeof userAnswer === "string" ? userAnswer : ""}
|
|
||||||
onChange={(e) => onAnswerChange(e.target.value)}
|
|
||||||
placeholder={t("session.textPlaceholder")}
|
|
||||||
className="w-full min-h-[120px] rounded-md border bg-background p-3 text-sm resize-y focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 答题结果展示
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
interface AnswerResultProps {
|
|
||||||
answer: PracticeAnswerRecord
|
|
||||||
result?: { isCorrect: boolean | null; score: number | null }
|
|
||||||
}
|
|
||||||
|
|
||||||
function AnswerResult({ answer, result }: AnswerResultProps): React.ReactNode {
|
|
||||||
const t = useTranslations("practice")
|
|
||||||
const isCorrect = result?.isCorrect ?? answer.isCorrect
|
|
||||||
const isSkipped = answer.status === "skipped"
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{/* 判分结果 */}
|
|
||||||
{isSkipped ? (
|
|
||||||
<div className="rounded-md border border-muted bg-muted/30 p-3 text-sm text-muted-foreground">
|
|
||||||
{t("session.skipped")}
|
|
||||||
</div>
|
|
||||||
) : isCorrect === true ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-md border border-emerald-200 bg-emerald-50/50 p-3 text-sm text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/20 dark:text-emerald-400">
|
|
||||||
<CheckCircle2 className="h-5 w-5" />
|
|
||||||
{t("session.correct")}
|
|
||||||
</div>
|
|
||||||
) : isCorrect === false ? (
|
|
||||||
<div className="flex items-center gap-2 rounded-md border border-rose-200 bg-rose-50/50 p-3 text-sm text-rose-700 dark:border-rose-900 dark:bg-rose-950/20 dark:text-rose-400">
|
|
||||||
<XCircle className="h-5 w-5" />
|
|
||||||
{t("session.incorrect")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-md border border-amber-200 bg-amber-50/50 p-3 text-sm text-amber-700 dark:border-amber-900 dark:bg-amber-950/20 dark:text-amber-400">
|
|
||||||
{t("session.pendingReview")}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 学生答案 */}
|
|
||||||
{answer.studentAnswer !== null && answer.studentAnswer !== undefined ? (
|
|
||||||
<div>
|
|
||||||
<h4 className="mb-1 text-sm font-medium">{t("session.yourAnswer")}</h4>
|
|
||||||
<pre className="whitespace-pre-wrap break-words rounded-md border bg-muted/30 p-2 text-xs font-sans">
|
|
||||||
{typeof answer.studentAnswer === "string"
|
|
||||||
? answer.studentAnswer
|
|
||||||
: JSON.stringify(answer.studentAnswer, null, 2)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 练习结果视图
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function PracticeResultView({ session }: { session: PracticeSessionDetail }): React.ReactNode {
|
|
||||||
const t = useTranslations("practice")
|
|
||||||
|
|
||||||
const total = session.totalQuestions
|
|
||||||
const answered = session.answeredQuestions
|
|
||||||
const correct = session.correctCount
|
|
||||||
const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2">
|
|
||||||
<Trophy className="h-5 w-5 text-amber-500" />
|
|
||||||
{t("result.title")}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
<div className="grid grid-cols-3 gap-4">
|
|
||||||
<div className="rounded-md border p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold">{answered}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{t("result.answered")}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold text-emerald-600">{correct}</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{t("result.correct")}</div>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border p-4 text-center">
|
|
||||||
<div className="text-2xl font-bold">{accuracy}%</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{t("result.accuracy")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 逐题回顾 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h4 className="text-sm font-medium">{t("result.review")}</h4>
|
|
||||||
{session.answers.map((a, idx) => (
|
|
||||||
<div
|
|
||||||
key={a.id}
|
|
||||||
className="flex items-center justify-between rounded-md border px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
{a.isCorrect === true ? (
|
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" />
|
|
||||||
) : a.isCorrect === false ? (
|
|
||||||
<XCircle className="h-4 w-4 text-rose-500" />
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
{t("session.question")} {idx + 1}
|
|
||||||
</span>
|
|
||||||
<Badge variant="outline">
|
|
||||||
{a.status === "answered" ? (a.isCorrect === true ? t("session.correct") : a.isCorrect === false ? t("session.incorrect") : t("session.pendingReview")) : t("session.skipped")}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 辅助函数
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function extractOptions(content: unknown): Array<{ id: string; text: string }> {
|
|
||||||
if (typeof content !== "object" || content === null) return []
|
|
||||||
const record = content as Record<string, unknown>
|
|
||||||
const options = record.options
|
|
||||||
if (!Array.isArray(options)) return []
|
|
||||||
|
|
||||||
return options
|
|
||||||
.filter((opt): opt is Record<string, unknown> =>
|
|
||||||
typeof opt === "object" && opt !== null && typeof opt.id === "string",
|
|
||||||
)
|
|
||||||
.map((opt) => ({
|
|
||||||
id: opt.id as string,
|
|
||||||
text: typeof opt.text === "string" ? opt.text : String(opt.text ?? ""),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useTransition } from "react"
|
import { useState, useTransition } from "react"
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react"
|
import { Target, BookOpen, AlertCircle, Sparkles } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -17,10 +17,11 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
|
|
||||||
import { createPracticeSessionAction } from "../actions"
|
import { isPracticeType } from "../lib/type-guards"
|
||||||
import type { PracticeType } from "../types"
|
import { usePracticeService, usePracticeAnalytics } from "../services/practice-service"
|
||||||
|
import type { AiRecommendedReason, PracticeType } from "../types"
|
||||||
|
|
||||||
interface PracticeStarterProps {
|
export interface PracticeStarterProps {
|
||||||
/** 知识点选项列表 */
|
/** 知识点选项列表 */
|
||||||
knowledgePoints: Array<{ id: string; name: string }>
|
knowledgePoints: Array<{ id: string; name: string }>
|
||||||
/** 预设模式(从错题本发起时传入) */
|
/** 预设模式(从错题本发起时传入) */
|
||||||
@@ -31,22 +32,50 @@ interface PracticeStarterProps {
|
|||||||
}
|
}
|
||||||
/** 是否禁用类型选择(预设模式时) */
|
/** 是否禁用类型选择(预设模式时) */
|
||||||
lockType?: boolean
|
lockType?: boolean
|
||||||
|
/**
|
||||||
|
* 会话创建成功后的回调(由页面层注入路由跳转逻辑)。
|
||||||
|
*
|
||||||
|
* 解耦设计:组件本身不感知路由,由调用方决定跳转目标
|
||||||
|
* (学生跳 `/student/practice/[id]`,家长可能跳 `/parent/practice/[id]`)。
|
||||||
|
* 不传则不跳转(仅 toast 提示)。
|
||||||
|
*/
|
||||||
|
onSessionCreated?: (sessionId: string) => void
|
||||||
|
/**
|
||||||
|
* AI 推荐练习的发起角色(决定 sourceMeta.reason 枚举值)。
|
||||||
|
*
|
||||||
|
* - 学生自主发起(默认):`"student_initiated"`
|
||||||
|
* - 家长建议子女:`"parent_suggested"`
|
||||||
|
* - 教师布置:`"teacher_assigned"`
|
||||||
|
*/
|
||||||
|
aiRecommendReason?: AiRecommendedReason
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const
|
const QUESTION_COUNT_OPTIONS = [5, 10, 15, 20, 30] as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 专项练习发起器
|
* 专项练习发起器。
|
||||||
|
*
|
||||||
|
* 通过 `usePracticeService()` 获取数据服务,不直接 import actions,
|
||||||
|
* 实现完全解耦:测试时可注入 mock 服务,不同角色可注入不同实现。
|
||||||
|
*
|
||||||
|
* 路由跳转通过 `onSessionCreated` 回调注入,组件本身不感知路由结构。
|
||||||
*
|
*
|
||||||
* 支持四种练习模式:
|
* 支持四种练习模式:
|
||||||
* 1. 错题变式练习:从错题本发起
|
* 1. 错题重做:从错题本发起(通过 presetMode)
|
||||||
* 2. 知识点专项:选择知识点后抽题
|
* 2. 知识点专项:选择知识点后抽题
|
||||||
* 3. 薄弱章节:自动识别薄弱知识点
|
* 3. 薄弱章节:自动识别薄弱知识点(不传 chapterId 时跨章节识别)
|
||||||
* 4. AI 推荐:AI 根据学情推荐
|
* 4. AI 推荐:根据学情推荐
|
||||||
*/
|
*/
|
||||||
export function PracticeStarter({ knowledgePoints, presetMode, lockType }: PracticeStarterProps): React.ReactNode {
|
export function PracticeStarter({
|
||||||
|
knowledgePoints,
|
||||||
|
presetMode,
|
||||||
|
lockType,
|
||||||
|
onSessionCreated,
|
||||||
|
aiRecommendReason = "student_initiated",
|
||||||
|
}: PracticeStarterProps): React.ReactNode {
|
||||||
const t = useTranslations("practice")
|
const t = useTranslations("practice")
|
||||||
const router = useRouter()
|
const service = usePracticeService()
|
||||||
|
const analytics = usePracticeAnalytics()
|
||||||
const [isPending, startTransition] = useTransition()
|
const [isPending, startTransition] = useTransition()
|
||||||
|
|
||||||
const [practiceType, setPracticeType] = useState<PracticeType>(
|
const [practiceType, setPracticeType] = useState<PracticeType>(
|
||||||
@@ -62,6 +91,38 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTypeChange(value: string): void {
|
||||||
|
// 严格类型守卫替代 `value as PracticeType` 断言
|
||||||
|
if (isPracticeType(value)) {
|
||||||
|
setPracticeType(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDifficultyChange(value: string): void {
|
||||||
|
const num = Number(value)
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
setDifficulty(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleQuestionCountChange(value: string): void {
|
||||||
|
const num = Number(value)
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
setQuestionCount(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(
|
||||||
|
errorCode: string | undefined,
|
||||||
|
message: string | undefined,
|
||||||
|
fallbackKey: string,
|
||||||
|
): string {
|
||||||
|
if (errorCode) {
|
||||||
|
return t(`errors.${errorCode}`)
|
||||||
|
}
|
||||||
|
return message ?? t(fallbackKey)
|
||||||
|
}
|
||||||
|
|
||||||
function handleStart(): void {
|
function handleStart(): void {
|
||||||
if (presetMode) {
|
if (presetMode) {
|
||||||
// 预设模式:直接使用传入的 sourceMeta
|
// 预设模式:直接使用传入的 sourceMeta
|
||||||
@@ -76,73 +137,80 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
|||||||
questionCount,
|
questionCount,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const res = await createPracticeSessionAction(undefined, formData)
|
const res = await service.createSession(undefined, formData)
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
|
analytics.trackSessionStart(presetMode.type, questionCount)
|
||||||
toast.success(res.message ?? t("toasts.created"))
|
toast.success(res.message ?? t("toasts.created"))
|
||||||
router.push(`/student/practice/${res.data.sessionId}`)
|
onSessionCreated?.(res.data.sessionId)
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message ?? t("toasts.createFailed"))
|
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自定义模式:根据类型构建 sourceMeta
|
||||||
|
let sourceMeta: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
if (practiceType === "knowledge_point") {
|
||||||
|
if (selectedKpIds.length === 0) {
|
||||||
|
toast.error(t("toasts.selectKnowledgePoint"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sourceMeta = {
|
||||||
|
knowledgePointIds: selectedKpIds,
|
||||||
|
difficulty: difficulty > 0 ? difficulty : undefined,
|
||||||
|
}
|
||||||
|
} else if (practiceType === "weak_chapter") {
|
||||||
|
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
|
||||||
|
// 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点
|
||||||
|
if (selectedKpIds.length === 0) {
|
||||||
|
toast.error(t("toasts.selectWeakKnowledgePoint"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sourceMeta = {
|
||||||
|
weakKnowledgePointIds: selectedKpIds,
|
||||||
|
}
|
||||||
|
} else if (practiceType === "ai_recommended") {
|
||||||
|
sourceMeta = {
|
||||||
|
recommendedKnowledgePointIds: selectedKpIds,
|
||||||
|
// 枚举值(业务数据),UI 层通过 t(`reasons.${reason}`) 查 i18n
|
||||||
|
reason: aiRecommendReason,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append(
|
||||||
|
"json",
|
||||||
|
JSON.stringify({
|
||||||
|
practiceType,
|
||||||
|
sourceMeta,
|
||||||
|
questionCount,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const res = await service.createSession(undefined, formData)
|
||||||
|
if (res.success && res.data) {
|
||||||
|
analytics.trackSessionStart(practiceType, questionCount)
|
||||||
|
toast.success(res.message ?? t("toasts.created"))
|
||||||
|
onSessionCreated?.(res.data.sessionId)
|
||||||
|
} else {
|
||||||
|
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义模式:根据类型构建 sourceMeta
|
const showKpSelector =
|
||||||
let sourceMeta: Record<string, unknown> = {}
|
!presetMode ||
|
||||||
|
practiceType === "knowledge_point" ||
|
||||||
if (practiceType === "knowledge_point") {
|
practiceType === "weak_chapter" ||
|
||||||
if (selectedKpIds.length === 0) {
|
practiceType === "ai_recommended"
|
||||||
toast.error(t("toasts.selectKnowledgePoint"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sourceMeta = {
|
|
||||||
knowledgePointIds: selectedKpIds,
|
|
||||||
difficulty: difficulty > 0 ? difficulty : undefined,
|
|
||||||
}
|
|
||||||
} else if (practiceType === "weak_chapter") {
|
|
||||||
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
|
|
||||||
if (selectedKpIds.length === 0) {
|
|
||||||
toast.error(t("toasts.selectWeakKnowledgePoint"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sourceMeta = {
|
|
||||||
chapterId: "",
|
|
||||||
weakKnowledgePointIds: selectedKpIds,
|
|
||||||
}
|
|
||||||
} else if (practiceType === "ai_recommended") {
|
|
||||||
sourceMeta = {
|
|
||||||
recommendedKnowledgePointIds: selectedKpIds,
|
|
||||||
reason: t("toasts.aiRecommendedReason"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(async () => {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append(
|
|
||||||
"json",
|
|
||||||
JSON.stringify({
|
|
||||||
practiceType,
|
|
||||||
sourceMeta,
|
|
||||||
questionCount,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
const res = await createPracticeSessionAction(undefined, formData)
|
|
||||||
if (res.success && res.data) {
|
|
||||||
toast.success(res.message ?? t("toasts.created"))
|
|
||||||
router.push(`/student/practice/${res.data.sessionId}`)
|
|
||||||
} else {
|
|
||||||
toast.error(res.message ?? t("toasts.createFailed"))
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const showKpSelector = !presetMode || practiceType === "knowledge_point" || practiceType === "weak_chapter" || practiceType === "ai_recommended"
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Target className="h-5 w-5 text-primary" />
|
<Target className="h-5 w-5 text-primary" aria-hidden="true" />
|
||||||
{t("starter.title")}
|
{t("starter.title")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{t("starter.description")}</CardDescription>
|
<CardDescription>{t("starter.description")}</CardDescription>
|
||||||
@@ -151,30 +219,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
|||||||
{/* 练习类型选择 */}
|
{/* 练习类型选择 */}
|
||||||
{!lockType ? (
|
{!lockType ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("starter.type")}</Label>
|
<Label htmlFor="practice-type-select">{t("starter.type")}</Label>
|
||||||
<Select
|
<Select value={practiceType} onValueChange={handleTypeChange}>
|
||||||
value={practiceType}
|
<SelectTrigger id="practice-type-select" aria-label={t("starter.type")}>
|
||||||
onValueChange={(v: string) => setPracticeType(v as PracticeType)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="knowledge_point">
|
<SelectItem value="knowledge_point">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<BookOpen className="h-4 w-4" />
|
<BookOpen className="h-4 w-4" aria-hidden="true" />
|
||||||
{t("types.knowledge_point")}
|
{t("types.knowledge_point")}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="weak_chapter">
|
<SelectItem value="weak_chapter">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" aria-hidden="true" />
|
||||||
{t("types.weak_chapter")}
|
{t("types.weak_chapter")}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="ai_recommended">
|
<SelectItem value="ai_recommended">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Sparkles className="h-4 w-4" />
|
<Sparkles className="h-4 w-4" aria-hidden="true" />
|
||||||
{t("types.ai_recommended")}
|
{t("types.ai_recommended")}
|
||||||
</span>
|
</span>
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -187,21 +252,27 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
|||||||
{showKpSelector && knowledgePoints.length > 0 ? (
|
{showKpSelector && knowledgePoints.length > 0 ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("starter.knowledgePoints")}</Label>
|
<Label>{t("starter.knowledgePoints")}</Label>
|
||||||
<div className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2">
|
<div
|
||||||
{knowledgePoints.map((kp) => (
|
className="grid grid-cols-2 gap-2 max-h-48 overflow-y-auto rounded-md border p-2"
|
||||||
<label
|
role="group"
|
||||||
key={kp.id}
|
aria-label={t("starter.knowledgePoints")}
|
||||||
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
|
>
|
||||||
>
|
{knowledgePoints.map((kp) => {
|
||||||
<input
|
const checked = selectedKpIds.includes(kp.id)
|
||||||
type="checkbox"
|
return (
|
||||||
checked={selectedKpIds.includes(kp.id)}
|
<label
|
||||||
onChange={() => toggleKp(kp.id)}
|
key={kp.id}
|
||||||
className="rounded border-input"
|
className="flex items-center gap-2 rounded-md p-2 hover:bg-muted/50 cursor-pointer"
|
||||||
/>
|
>
|
||||||
<span className="text-sm">{kp.name}</span>
|
<Checkbox
|
||||||
</label>
|
checked={checked}
|
||||||
))}
|
onCheckedChange={() => toggleKp(kp.id)}
|
||||||
|
aria-label={kp.name}
|
||||||
|
/>
|
||||||
|
<span className="text-sm">{kp.name}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -209,12 +280,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
|||||||
{/* 难度选择(仅知识点专项) */}
|
{/* 难度选择(仅知识点专项) */}
|
||||||
{practiceType === "knowledge_point" && !presetMode ? (
|
{practiceType === "knowledge_point" && !presetMode ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("starter.difficulty")}</Label>
|
<Label htmlFor="difficulty-select">{t("starter.difficulty")}</Label>
|
||||||
<Select
|
<Select value={String(difficulty)} onValueChange={handleDifficultyChange}>
|
||||||
value={String(difficulty)}
|
<SelectTrigger id="difficulty-select" aria-label={t("starter.difficulty")}>
|
||||||
onValueChange={(v: string) => setDifficulty(Number(v))}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -231,12 +299,9 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
|||||||
|
|
||||||
{/* 题目数量 */}
|
{/* 题目数量 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t("starter.questionCount")}</Label>
|
<Label htmlFor="question-count-select">{t("starter.questionCount")}</Label>
|
||||||
<Select
|
<Select value={String(questionCount)} onValueChange={handleQuestionCountChange}>
|
||||||
value={String(questionCount)}
|
<SelectTrigger id="question-count-select" aria-label={t("starter.questionCount")}>
|
||||||
onValueChange={(v: string) => setQuestionCount(Number(v))}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -254,6 +319,7 @@ export function PracticeStarter({ knowledgePoints, presetMode, lockType }: Pract
|
|||||||
onClick={handleStart}
|
onClick={handleStart}
|
||||||
disabled={isPending}
|
disabled={isPending}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
aria-label={isPending ? t("starter.creating") : t("starter.start")}
|
||||||
>
|
>
|
||||||
{isPending ? t("starter.creating") : t("starter.start")}
|
{isPending ? t("starter.creating") : t("starter.start")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
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 { db } from "@/shared/db"
|
||||||
import { practiceAnswers, practiceSessions, questions, questionsToKnowledgePoints, knowledgePoints } from "@/shared/db/schema"
|
import { practiceAnswers, practiceSessions, questions, questionsToKnowledgePoints, knowledgePoints } from "@/shared/db/schema"
|
||||||
import { getActiveStudentIdsByClassId, getClassNameById, getClassesByGradeId } from "@/modules/classes/data-access"
|
import {
|
||||||
|
getActiveStudentIdsByClassId,
|
||||||
|
getClassNamesByIds,
|
||||||
|
getClassNameById,
|
||||||
|
getClassesByGradeId,
|
||||||
|
} from "@/modules/classes/data-access"
|
||||||
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -54,6 +59,8 @@ export interface PracticeTypeBreakdown {
|
|||||||
* 获取班级专项练习统计。
|
* 获取班级专项练习统计。
|
||||||
*
|
*
|
||||||
* 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。
|
* 汇总班级所有学生的练习数据,包括会话数、完成数、正确率等。
|
||||||
|
* 使用单条 SQL 同时计算汇总统计与活跃学生数(COUNT(DISTINCT)),
|
||||||
|
* 避免 N+1 查询。
|
||||||
*
|
*
|
||||||
* @param classId 班级 ID
|
* @param classId 班级 ID
|
||||||
*/
|
*/
|
||||||
@@ -63,12 +70,14 @@ export const getClassPracticeStats = cache(async (
|
|||||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||||
if (studentIds.length === 0) return null
|
if (studentIds.length === 0) return null
|
||||||
|
|
||||||
|
// 单条 SQL 同时获取汇总统计与活跃学生数(替代原先两条 SQL)
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({
|
.select({
|
||||||
totalSessions: count(),
|
totalSessions: count(),
|
||||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||||
|
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||||
})
|
})
|
||||||
.from(practiceSessions)
|
.from(practiceSessions)
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
.where(inArray(practiceSessions.studentId, studentIds))
|
||||||
@@ -78,14 +87,7 @@ export const getClassPracticeStats = cache(async (
|
|||||||
|
|
||||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||||
const totalCorrect = Number(row.totalCorrect)
|
const totalCorrect = Number(row.totalCorrect)
|
||||||
|
const activeStudents = Number(row.activeStudents)
|
||||||
// 统计参与练习的学生数
|
|
||||||
const activeStudentsResult = await db
|
|
||||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
|
||||||
.from(practiceSessions)
|
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
|
||||||
|
|
||||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
classId,
|
classId,
|
||||||
@@ -141,6 +143,9 @@ export const getClassStudentPracticeSummaries = cache(async (
|
|||||||
/**
|
/**
|
||||||
* 获取年级专项练习统计。
|
* 获取年级专项练习统计。
|
||||||
*
|
*
|
||||||
|
* 使用单条 SQL 同时计算汇总统计与活跃学生数(COUNT(DISTINCT)),
|
||||||
|
* 避免 N+1 查询。
|
||||||
|
*
|
||||||
* @param gradeId 年级 ID
|
* @param gradeId 年级 ID
|
||||||
*/
|
*/
|
||||||
export const getGradePracticeStats = cache(async (
|
export const getGradePracticeStats = cache(async (
|
||||||
@@ -162,6 +167,7 @@ export const getGradePracticeStats = cache(async (
|
|||||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||||
|
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||||
})
|
})
|
||||||
.from(practiceSessions)
|
.from(practiceSessions)
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
.where(inArray(practiceSessions.studentId, studentIds))
|
||||||
@@ -172,18 +178,13 @@ export const getGradePracticeStats = cache(async (
|
|||||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
||||||
const totalCorrect = Number(row.totalCorrect)
|
const totalCorrect = Number(row.totalCorrect)
|
||||||
|
|
||||||
const activeStudentsResult = await db
|
|
||||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
|
||||||
.from(practiceSessions)
|
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalSessions: Number(row.totalSessions),
|
totalSessions: Number(row.totalSessions),
|
||||||
completedSessions: Number(row.completedSessions),
|
completedSessions: Number(row.completedSessions),
|
||||||
totalQuestionsAnswered,
|
totalQuestionsAnswered,
|
||||||
totalCorrect,
|
totalCorrect,
|
||||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||||
activeStudents: Number(activeStudentsResult[0]?.count ?? 0),
|
activeStudents: Number(row.activeStudents),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -271,6 +272,10 @@ export interface TeacherClassPracticeOverview {
|
|||||||
*
|
*
|
||||||
* 用于教师端分析页面顶部展示每个班级的练习情况。
|
* 用于教师端分析页面顶部展示每个班级的练习情况。
|
||||||
*
|
*
|
||||||
|
* N+1 优化:
|
||||||
|
* - 批量获取所有班级名称(1 条 SQL 替代 N 条)
|
||||||
|
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL)
|
||||||
|
*
|
||||||
* @param classIds 教师/年级主任可访问的班级 ID 列表
|
* @param classIds 教师/年级主任可访问的班级 ID 列表
|
||||||
*/
|
*/
|
||||||
export const getTeacherClassPracticeOverviews = cache(async (
|
export const getTeacherClassPracticeOverviews = cache(async (
|
||||||
@@ -278,23 +283,30 @@ export const getTeacherClassPracticeOverviews = cache(async (
|
|||||||
): Promise<TeacherClassPracticeOverview[]> => {
|
): Promise<TeacherClassPracticeOverview[]> => {
|
||||||
if (classIds.length === 0) return []
|
if (classIds.length === 0) return []
|
||||||
|
|
||||||
const results = await Promise.all(
|
// 批量获取所有班级名称(1 条 SQL)
|
||||||
classIds.map(async (classId): Promise<TeacherClassPracticeOverview | null> => {
|
const classNameMap = await getClassNamesByIds(classIds)
|
||||||
const className = await getClassNameById(classId)
|
|
||||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
// 并行获取每个班级的学生 ID 列表
|
||||||
|
const studentIdsPerClass = await Promise.all(
|
||||||
|
classIds.map(async (classId) => ({
|
||||||
|
classId,
|
||||||
|
studentIds: await getActiveStudentIdsByClassId(classId),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
|
||||||
|
const statsPerClass = await Promise.all(
|
||||||
|
studentIdsPerClass.map(async ({ classId, studentIds }) => {
|
||||||
const totalStudents = studentIds.length
|
const totalStudents = studentIds.length
|
||||||
if (totalStudents === 0) {
|
if (totalStudents === 0) {
|
||||||
return {
|
return {
|
||||||
classId,
|
classId,
|
||||||
className: className ?? "",
|
totalStudents: 0,
|
||||||
totalSessions: 0,
|
totalSessions: 0,
|
||||||
completedSessions: 0,
|
completedSessions: 0,
|
||||||
totalQuestionsAnswered: 0,
|
totalQuestionsAnswered: 0,
|
||||||
totalCorrect: 0,
|
totalCorrect: 0,
|
||||||
averageAccuracy: 0,
|
|
||||||
activeStudents: 0,
|
activeStudents: 0,
|
||||||
totalStudents: 0,
|
|
||||||
participationRate: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,39 +316,41 @@ export const getTeacherClassPracticeOverviews = cache(async (
|
|||||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||||
|
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||||
})
|
})
|
||||||
.from(practiceSessions)
|
.from(practiceSessions)
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
.where(inArray(practiceSessions.studentId, studentIds))
|
||||||
|
|
||||||
const row = rows[0]
|
const row = rows[0]
|
||||||
if (!row) return null
|
|
||||||
|
|
||||||
const totalQuestionsAnswered = Number(row.totalQuestionsAnswered)
|
|
||||||
const totalCorrect = Number(row.totalCorrect)
|
|
||||||
|
|
||||||
const activeStudentsResult = await db
|
|
||||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
|
||||||
.from(practiceSessions)
|
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
|
||||||
|
|
||||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
classId,
|
classId,
|
||||||
className: className ?? "",
|
|
||||||
totalSessions: Number(row.totalSessions),
|
|
||||||
completedSessions: Number(row.completedSessions),
|
|
||||||
totalQuestionsAnswered,
|
|
||||||
totalCorrect,
|
|
||||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
|
||||||
activeStudents,
|
|
||||||
totalStudents,
|
totalStudents,
|
||||||
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
|
totalSessions: Number(row?.totalSessions ?? 0),
|
||||||
|
completedSessions: Number(row?.completedSessions ?? 0),
|
||||||
|
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
|
||||||
|
totalCorrect: Number(row?.totalCorrect ?? 0),
|
||||||
|
activeStudents: Number(row?.activeStudents ?? 0),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
return results.filter((r): r is TeacherClassPracticeOverview => r !== null)
|
return statsPerClass.map((s) => {
|
||||||
|
const totalQuestionsAnswered = s.totalQuestionsAnswered
|
||||||
|
const totalCorrect = s.totalCorrect
|
||||||
|
const activeStudents = s.activeStudents
|
||||||
|
return {
|
||||||
|
classId: s.classId,
|
||||||
|
className: classNameMap.get(s.classId) ?? "",
|
||||||
|
totalSessions: s.totalSessions,
|
||||||
|
completedSessions: s.completedSessions,
|
||||||
|
totalQuestionsAnswered,
|
||||||
|
totalCorrect,
|
||||||
|
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||||
|
activeStudents,
|
||||||
|
totalStudents: s.totalStudents,
|
||||||
|
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -427,6 +441,10 @@ export interface GradeClassPracticeComparison {
|
|||||||
/**
|
/**
|
||||||
* 获取年级各班级专项练习对比数据。
|
* 获取年级各班级专项练习对比数据。
|
||||||
*
|
*
|
||||||
|
* N+1 优化:
|
||||||
|
* - 班级列表已含名称,无需额外查询
|
||||||
|
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL)
|
||||||
|
*
|
||||||
* @param gradeId 年级 ID
|
* @param gradeId 年级 ID
|
||||||
*/
|
*/
|
||||||
export const getGradeClassPracticeComparison = cache(async (
|
export const getGradeClassPracticeComparison = cache(async (
|
||||||
@@ -435,23 +453,27 @@ export const getGradeClassPracticeComparison = cache(async (
|
|||||||
const classList = await getClassesByGradeId(gradeId)
|
const classList = await getClassesByGradeId(gradeId)
|
||||||
if (classList.length === 0) return []
|
if (classList.length === 0) return []
|
||||||
|
|
||||||
const results = await Promise.all(
|
// 并行获取每个班级的学生 ID 列表
|
||||||
classList.map(async (cls): Promise<GradeClassPracticeComparison> => {
|
const studentIdsPerClass = await Promise.all(
|
||||||
const studentIds = await getActiveStudentIdsByClassId(cls.id)
|
classList.map(async (cls) => ({
|
||||||
const totalStudents = studentIds.length
|
cls,
|
||||||
|
studentIds: await getActiveStudentIdsByClassId(cls.id),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
|
||||||
|
const statsPerClass = await Promise.all(
|
||||||
|
studentIdsPerClass.map(async ({ cls, studentIds }) => {
|
||||||
|
const totalStudents = studentIds.length
|
||||||
if (totalStudents === 0) {
|
if (totalStudents === 0) {
|
||||||
return {
|
return {
|
||||||
classId: cls.id,
|
cls,
|
||||||
className: cls.name,
|
totalStudents: 0,
|
||||||
totalSessions: 0,
|
totalSessions: 0,
|
||||||
completedSessions: 0,
|
completedSessions: 0,
|
||||||
totalQuestionsAnswered: 0,
|
totalQuestionsAnswered: 0,
|
||||||
totalCorrect: 0,
|
totalCorrect: 0,
|
||||||
averageAccuracy: 0,
|
|
||||||
activeStudents: 0,
|
activeStudents: 0,
|
||||||
totalStudents: 0,
|
|
||||||
participationRate: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -461,36 +483,42 @@ export const getGradeClassPracticeComparison = cache(async (
|
|||||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||||
|
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||||
})
|
})
|
||||||
.from(practiceSessions)
|
.from(practiceSessions)
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
.where(inArray(practiceSessions.studentId, studentIds))
|
||||||
|
|
||||||
const row = rows[0]
|
const row = rows[0]
|
||||||
const totalQuestionsAnswered = Number(row?.totalQuestionsAnswered ?? 0)
|
|
||||||
const totalCorrect = Number(row?.totalCorrect ?? 0)
|
|
||||||
|
|
||||||
const activeStudentsResult = await db
|
|
||||||
.select({ count: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})` })
|
|
||||||
.from(practiceSessions)
|
|
||||||
.where(inArray(practiceSessions.studentId, studentIds))
|
|
||||||
|
|
||||||
const activeStudents = Number(activeStudentsResult[0]?.count ?? 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
classId: cls.id,
|
cls,
|
||||||
className: cls.name,
|
totalStudents,
|
||||||
totalSessions: Number(row?.totalSessions ?? 0),
|
totalSessions: Number(row?.totalSessions ?? 0),
|
||||||
completedSessions: Number(row?.completedSessions ?? 0),
|
completedSessions: Number(row?.completedSessions ?? 0),
|
||||||
totalQuestionsAnswered,
|
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
|
||||||
totalCorrect,
|
totalCorrect: Number(row?.totalCorrect ?? 0),
|
||||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
activeStudents: Number(row?.activeStudents ?? 0),
|
||||||
activeStudents,
|
|
||||||
totalStudents,
|
|
||||||
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const results = statsPerClass.map((s) => {
|
||||||
|
const totalQuestionsAnswered = s.totalQuestionsAnswered
|
||||||
|
const totalCorrect = s.totalCorrect
|
||||||
|
const activeStudents = s.activeStudents
|
||||||
|
return {
|
||||||
|
classId: s.cls.id,
|
||||||
|
className: s.cls.name,
|
||||||
|
totalSessions: s.totalSessions,
|
||||||
|
completedSessions: s.completedSessions,
|
||||||
|
totalQuestionsAnswered,
|
||||||
|
totalCorrect,
|
||||||
|
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||||
|
activeStudents,
|
||||||
|
totalStudents: s.totalStudents,
|
||||||
|
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 按参与率降序排列
|
// 按参与率降序排列
|
||||||
return results.sort((a, b) => b.participationRate - a.participationRate)
|
return results.sort((a, b) => b.participationRate - a.participationRate)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
questionsToKnowledgePoints,
|
questionsToKnowledgePoints,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
|
|
||||||
|
import { isWeakChapterSourceMeta, isAiRecommendedSourceMeta, isKnowledgePointSourceMeta, isErrorVariantSourceMeta } from "./lib/source-meta"
|
||||||
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
|
import type { PracticeSourceMeta, QuestionSelectionResult } from "./types"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -22,22 +23,25 @@ const DEFAULT_QUESTION_COUNT = 10
|
|||||||
/** 薄弱知识点掌握度阈值(低于此值视为薄弱) */
|
/** 薄弱知识点掌握度阈值(低于此值视为薄弱) */
|
||||||
const WEAK_MASTERY_THRESHOLD = 60
|
const WEAK_MASTERY_THRESHOLD = 60
|
||||||
|
|
||||||
|
/** 自动识别薄弱知识点默认返回数量 */
|
||||||
|
const DEFAULT_WEAK_KP_LIMIT = 5
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 出题策略
|
// 出题策略
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 错题变式练习出题策略。
|
* 错题重做出题策略(原"错题变式")。
|
||||||
*
|
*
|
||||||
* 从错题本中选取错题,直接使用原题进行练习(不依赖 AI 生成变式题,
|
* 从错题本中选取错题,直接使用原题进行练习。
|
||||||
* 确保即使 AI 不可用也能练习)。
|
* 该策略不依赖 AI 生成变式题,确保即使 AI 不可用也能练习。
|
||||||
*
|
*
|
||||||
* 策略:
|
* 策略:
|
||||||
* 1. 从 sourceQuestionIds 中查询题库中存在的题目
|
* 1. 从 sourceQuestionIds 中查询题库中存在的题目
|
||||||
* 2. 排除已在该练习会话中的题目(由调用方保证)
|
* 2. 排除已在该练习会话中的题目(由调用方保证)
|
||||||
* 3. 按难度升序排列(先易后难,建立信心)
|
* 3. 按难度升序排列(先易后难,建立信心)
|
||||||
*/
|
*/
|
||||||
async function selectForErrorVariant(
|
export async function selectForErrorVariant(
|
||||||
sourceMeta: PracticeSourceMeta,
|
sourceMeta: PracticeSourceMeta,
|
||||||
questionCount: number,
|
questionCount: number,
|
||||||
): Promise<QuestionSelectionResult> {
|
): Promise<QuestionSelectionResult> {
|
||||||
@@ -79,7 +83,7 @@ async function selectForErrorVariant(
|
|||||||
* 2. 按难度筛选(如指定)
|
* 2. 按难度筛选(如指定)
|
||||||
* 3. 随机抽取指定数量
|
* 3. 随机抽取指定数量
|
||||||
*/
|
*/
|
||||||
async function selectForKnowledgePoint(
|
export async function selectForKnowledgePoint(
|
||||||
sourceMeta: PracticeSourceMeta,
|
sourceMeta: PracticeSourceMeta,
|
||||||
questionCount: number,
|
questionCount: number,
|
||||||
): Promise<QuestionSelectionResult> {
|
): Promise<QuestionSelectionResult> {
|
||||||
@@ -125,14 +129,14 @@ async function selectForKnowledgePoint(
|
|||||||
/**
|
/**
|
||||||
* 薄弱章节练习出题策略。
|
* 薄弱章节练习出题策略。
|
||||||
*
|
*
|
||||||
* 根据学生掌握度自动识别薄弱知识点,从这些知识点中抽题。
|
* 行为:
|
||||||
|
* - 若 sourceMeta.weakKnowledgePointIds 已传入,直接使用
|
||||||
|
* - 若未传入,调用 identifyWeakKnowledgePoints(studentId, chapterId) 自动识别
|
||||||
*
|
*
|
||||||
* 策略:
|
* 然后从(自动识别或显式传入的)薄弱知识点中抽题,
|
||||||
* 1. 查询学生在指定章节知识点上的掌握度
|
* 排除学生已做过的题目避免重复。
|
||||||
* 2. 筛选掌握度低于阈值的知识点
|
|
||||||
* 3. 从薄弱知识点中抽题
|
|
||||||
*/
|
*/
|
||||||
async function selectForWeakChapter(
|
export async function selectForWeakChapter(
|
||||||
studentId: string,
|
studentId: string,
|
||||||
sourceMeta: PracticeSourceMeta,
|
sourceMeta: PracticeSourceMeta,
|
||||||
questionCount: number,
|
questionCount: number,
|
||||||
@@ -141,8 +145,15 @@ async function selectForWeakChapter(
|
|||||||
return { questionIds: [], variants: new Map() }
|
return { questionIds: [], variants: new Map() }
|
||||||
}
|
}
|
||||||
|
|
||||||
const { weakKnowledgePointIds } = sourceMeta
|
const { chapterId, weakKnowledgePointIds } = sourceMeta
|
||||||
if (weakKnowledgePointIds.length === 0) {
|
|
||||||
|
// 自动识别薄弱知识点(若未传入)
|
||||||
|
let kpIds = weakKnowledgePointIds
|
||||||
|
if (!kpIds || kpIds.length === 0) {
|
||||||
|
kpIds = await identifyWeakKnowledgePoints(studentId, chapterId, DEFAULT_WEAK_KP_LIMIT)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kpIds.length === 0) {
|
||||||
return { questionIds: [], variants: new Map() }
|
return { questionIds: [], variants: new Map() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +162,7 @@ async function selectForWeakChapter(
|
|||||||
|
|
||||||
// 从薄弱知识点中抽题
|
// 从薄弱知识点中抽题
|
||||||
const conditions = [
|
const conditions = [
|
||||||
inArray(questionsToKnowledgePoints.knowledgePointId, weakKnowledgePointIds),
|
inArray(questionsToKnowledgePoints.knowledgePointId, kpIds),
|
||||||
sql`${questions.parentId} IS NULL`,
|
sql`${questions.parentId} IS NULL`,
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -185,7 +196,7 @@ async function selectForWeakChapter(
|
|||||||
* AI 推荐的知识点列表由上层(AI 分析)提供,
|
* AI 推荐的知识点列表由上层(AI 分析)提供,
|
||||||
* 此函数仅负责从推荐知识点中抽题。
|
* 此函数仅负责从推荐知识点中抽题。
|
||||||
*/
|
*/
|
||||||
async function selectForAiRecommended(
|
export async function selectForAiRecommended(
|
||||||
studentId: string,
|
studentId: string,
|
||||||
sourceMeta: PracticeSourceMeta,
|
sourceMeta: PracticeSourceMeta,
|
||||||
questionCount: number,
|
questionCount: number,
|
||||||
@@ -274,7 +285,7 @@ export async function selectQuestionsForPractice(
|
|||||||
* 从专项练习中汇总已答题目。
|
* 从专项练习中汇总已答题目。
|
||||||
* 为控制查询量,仅查询最近 1000 条记录。
|
* 为控制查询量,仅查询最近 1000 条记录。
|
||||||
*/
|
*/
|
||||||
async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
|
export async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[]> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ questionId: practiceAnswers.questionId })
|
.select({ questionId: practiceAnswers.questionId })
|
||||||
.from(practiceAnswers)
|
.from(practiceAnswers)
|
||||||
@@ -298,7 +309,7 @@ async function getStudentAnsweredQuestionIds(studentId: string): Promise<string[
|
|||||||
export async function identifyWeakKnowledgePoints(
|
export async function identifyWeakKnowledgePoints(
|
||||||
studentId: string,
|
studentId: string,
|
||||||
chapterId?: string,
|
chapterId?: string,
|
||||||
limit: number = 5,
|
limit: number = DEFAULT_WEAK_KP_LIMIT,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const conditions = [
|
const conditions = [
|
||||||
eq(knowledgePointMastery.studentId, studentId),
|
eq(knowledgePointMastery.studentId, studentId),
|
||||||
@@ -317,27 +328,3 @@ export async function identifyWeakKnowledgePoints(
|
|||||||
|
|
||||||
return rows.map((r) => r.knowledgePointId)
|
return rows.map((r) => r.knowledgePointId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// 类型守卫
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
function isErrorVariantSourceMeta(meta: PracticeSourceMeta): meta is { errorBookItemIds: string[]; sourceQuestionIds: string[] } {
|
|
||||||
return typeof meta === "object" && meta !== null &&
|
|
||||||
"errorBookItemIds" in meta && "sourceQuestionIds" in meta
|
|
||||||
}
|
|
||||||
|
|
||||||
function isKnowledgePointSourceMeta(meta: PracticeSourceMeta): meta is { knowledgePointIds: string[]; difficulty?: number } {
|
|
||||||
return typeof meta === "object" && meta !== null &&
|
|
||||||
"knowledgePointIds" in meta
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWeakChapterSourceMeta(meta: PracticeSourceMeta): meta is { chapterId: string; weakKnowledgePointIds: string[] } {
|
|
||||||
return typeof meta === "object" && meta !== null &&
|
|
||||||
"chapterId" in meta && "weakKnowledgePointIds" in meta
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAiRecommendedSourceMeta(meta: PracticeSourceMeta): meta is { recommendedKnowledgePointIds: string[]; reason: string } {
|
|
||||||
return typeof meta === "object" && meta !== null &&
|
|
||||||
"recommendedKnowledgePointIds" in meta && "reason" in meta
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ import {
|
|||||||
questions,
|
questions,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import { selectQuestionsForPractice } from "./data-access-strategy"
|
import { selectQuestionsForPractice } from "./data-access-strategy"
|
||||||
|
import { autoGradeAnswer } from "./lib/grading"
|
||||||
|
import {
|
||||||
|
asPracticeAnswerStatus,
|
||||||
|
asPracticeSourceMeta,
|
||||||
|
asPracticeStatus,
|
||||||
|
asPracticeType,
|
||||||
|
} from "./lib/type-guards"
|
||||||
|
import { practiceErrors } from "./lib/errors"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
PracticeAnswerRecord,
|
PracticeAnswerRecord,
|
||||||
PracticeAnswerStatus,
|
|
||||||
PracticeSessionDetail,
|
PracticeSessionDetail,
|
||||||
PracticeSessionSummary,
|
PracticeSessionSummary,
|
||||||
PracticeSourceMeta,
|
PracticeSourceMeta,
|
||||||
@@ -34,8 +41,8 @@ function mapSessionRow(row: typeof practiceSessions.$inferSelect): PracticeSessi
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
studentId: row.studentId,
|
studentId: row.studentId,
|
||||||
subjectId: row.subjectId,
|
subjectId: row.subjectId,
|
||||||
practiceType: row.practiceType as PracticeType,
|
practiceType: asPracticeType(row.practiceType),
|
||||||
status: row.status as PracticeStatus,
|
status: asPracticeStatus(row.status),
|
||||||
totalQuestions: row.totalQuestions,
|
totalQuestions: row.totalQuestions,
|
||||||
answeredQuestions,
|
answeredQuestions,
|
||||||
correctCount,
|
correctCount,
|
||||||
@@ -56,7 +63,7 @@ function mapAnswerRow(row: typeof practiceAnswers.$inferSelect & {
|
|||||||
variantContent: row.variantContent,
|
variantContent: row.variantContent,
|
||||||
isVariant: row.isVariant,
|
isVariant: row.isVariant,
|
||||||
orderIndex: row.orderIndex,
|
orderIndex: row.orderIndex,
|
||||||
status: row.status as PracticeAnswerStatus,
|
status: asPracticeAnswerStatus(row.status),
|
||||||
studentAnswer: row.studentAnswer,
|
studentAnswer: row.studentAnswer,
|
||||||
isCorrect: row.isCorrect,
|
isCorrect: row.isCorrect,
|
||||||
score: row.score,
|
score: row.score,
|
||||||
@@ -170,7 +177,7 @@ export const getPracticeSessionById = cache(async (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...summary,
|
...summary,
|
||||||
sourceMeta: session.sourceMeta as PracticeSourceMeta | null,
|
sourceMeta: asPracticeSourceMeta(session.sourceMeta),
|
||||||
answers: mappedAnswers,
|
answers: mappedAnswers,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -213,7 +220,7 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
|||||||
}
|
}
|
||||||
|
|
||||||
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
|
const byType = Array.from(byTypeMap.entries()).map(([type, stat]) => ({
|
||||||
practiceType: type as PracticeType,
|
practiceType: asPracticeType(type),
|
||||||
sessionCount: stat.sessionCount,
|
sessionCount: stat.sessionCount,
|
||||||
totalQuestions: stat.totalQuestions,
|
totalQuestions: stat.totalQuestions,
|
||||||
correctCount: stat.correctCount,
|
correctCount: stat.correctCount,
|
||||||
@@ -241,6 +248,8 @@ export const getPracticeStats = cache(async (studentId: string): Promise<Practic
|
|||||||
* 2. 创建会话记录
|
* 2. 创建会话记录
|
||||||
* 3. 创建答题记录(初始状态为 pending)
|
* 3. 创建答题记录(初始状态为 pending)
|
||||||
*
|
*
|
||||||
|
* @throws {PracticeError} 未找到题目时抛 no_questions_found
|
||||||
|
*
|
||||||
* @returns 会话 ID 和选中的题目数量
|
* @returns 会话 ID 和选中的题目数量
|
||||||
*/
|
*/
|
||||||
export async function createPracticeSession(
|
export async function createPracticeSession(
|
||||||
@@ -263,7 +272,7 @@ export async function createPracticeSession(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (selection.questionIds.length === 0) {
|
if (selection.questionIds.length === 0) {
|
||||||
return { sessionId: "", selectedCount: 0 }
|
throw practiceErrors.noQuestionsFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = createId()
|
const sessionId = createId()
|
||||||
@@ -276,6 +285,7 @@ export async function createPracticeSession(
|
|||||||
studentId,
|
studentId,
|
||||||
subjectId: input.subjectId ?? null,
|
subjectId: input.subjectId ?? null,
|
||||||
practiceType,
|
practiceType,
|
||||||
|
// sourceMeta 已在上层通过 parseSourceMeta 校验,此处直接写入
|
||||||
sourceMeta: sourceMeta as unknown,
|
sourceMeta: sourceMeta as unknown,
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
totalQuestions: selection.questionIds.length,
|
totalQuestions: selection.questionIds.length,
|
||||||
@@ -314,6 +324,12 @@ export async function createPracticeSession(
|
|||||||
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
|
* - 选择题/判断题:通过 extractCorrectAnswer 比对答案
|
||||||
* - 填空题:暂不自动判分(isCorrect = null)
|
* - 填空题:暂不自动判分(isCorrect = null)
|
||||||
*
|
*
|
||||||
|
* 并发安全:整个校验+判分+统计更新流程包裹在事务中,
|
||||||
|
* 对答题记录加行锁(SELECT ... FOR UPDATE),
|
||||||
|
* 防止同一答案被并发重复判分导致统计累加错误。
|
||||||
|
*
|
||||||
|
* @throws {PracticeError} 会话/答题记录不存在、已结束、已作答时抛对应错误码
|
||||||
|
*
|
||||||
* @returns 是否判分成功
|
* @returns 是否判分成功
|
||||||
*/
|
*/
|
||||||
export async function submitPracticeAnswer(
|
export async function submitPracticeAnswer(
|
||||||
@@ -323,95 +339,123 @@ export async function submitPracticeAnswer(
|
|||||||
answer: unknown,
|
answer: unknown,
|
||||||
skip: boolean = false,
|
skip: boolean = false,
|
||||||
): Promise<{ isCorrect: boolean | null; score: number | null }> {
|
): Promise<{ isCorrect: boolean | null; score: number | null }> {
|
||||||
// 校验会话归属
|
// 事务:行锁 + 校验 + 判分 + 统计更新(防止并发重复判分)
|
||||||
const session = await db.query.practiceSessions.findFirst({
|
return await db.transaction(async (tx) => {
|
||||||
where: and(
|
// 1. 校验会话归属(带行锁)
|
||||||
eq(practiceSessions.id, sessionId),
|
const [session] = await tx
|
||||||
eq(practiceSessions.studentId, studentId),
|
.select()
|
||||||
),
|
.from(practiceSessions)
|
||||||
})
|
.where(and(
|
||||||
|
eq(practiceSessions.id, sessionId),
|
||||||
|
eq(practiceSessions.studentId, studentId),
|
||||||
|
))
|
||||||
|
.for("update")
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("练习会话不存在或无权访问")
|
throw practiceErrors.sessionNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.status !== "in_progress") {
|
if (session.status !== "in_progress") {
|
||||||
throw new Error("练习会话已结束")
|
throw practiceErrors.sessionEnded()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询答题记录
|
// 2. 查询答题记录(带行锁,防止并发重复提交)
|
||||||
const answerRecord = await db.query.practiceAnswers.findFirst({
|
const [answerRecord] = await tx
|
||||||
where: and(
|
.select()
|
||||||
eq(practiceAnswers.id, answerId),
|
.from(practiceAnswers)
|
||||||
eq(practiceAnswers.sessionId, sessionId),
|
.where(and(
|
||||||
),
|
eq(practiceAnswers.id, answerId),
|
||||||
})
|
eq(practiceAnswers.sessionId, sessionId),
|
||||||
|
))
|
||||||
|
.for("update")
|
||||||
|
|
||||||
if (!answerRecord) {
|
if (!answerRecord) {
|
||||||
throw new Error("答题记录不存在")
|
throw practiceErrors.answerNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (answerRecord.status === "answered") {
|
if (answerRecord.status === "answered") {
|
||||||
throw new Error("此题已作答")
|
throw practiceErrors.answerAlreadySubmitted()
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
|
|
||||||
if (skip) {
|
if (skip) {
|
||||||
// 跳过此题
|
// 跳过此题:状态置为 skipped,不累加已答题数与正确数
|
||||||
await db
|
await tx
|
||||||
|
.update(practiceAnswers)
|
||||||
|
.set({
|
||||||
|
status: "skipped",
|
||||||
|
answeredAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(practiceAnswers.id, answerId))
|
||||||
|
|
||||||
|
// 累加已答题数(不累加正确数)
|
||||||
|
await tx
|
||||||
|
.update(practiceSessions)
|
||||||
|
.set({
|
||||||
|
answeredQuestions: session.answeredQuestions + 1,
|
||||||
|
})
|
||||||
|
.where(eq(practiceSessions.id, sessionId))
|
||||||
|
|
||||||
|
return { isCorrect: null, score: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 自动判分:查询题目内容并提取正确答案
|
||||||
|
const [question] = await tx
|
||||||
|
.select()
|
||||||
|
.from(questions)
|
||||||
|
.where(eq(questions.id, answerRecord.questionId))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (!question) {
|
||||||
|
throw practiceErrors.questionNotFound()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是变式题,使用变式题内容
|
||||||
|
const contentToUse = answerRecord.variantContent ?? question.content
|
||||||
|
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
|
||||||
|
|
||||||
|
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
|
||||||
|
|
||||||
|
// 4. 更新答题记录
|
||||||
|
await tx
|
||||||
.update(practiceAnswers)
|
.update(practiceAnswers)
|
||||||
.set({
|
.set({
|
||||||
status: "skipped",
|
status: "answered",
|
||||||
|
studentAnswer: answer,
|
||||||
|
isCorrect,
|
||||||
|
score,
|
||||||
answeredAt: now,
|
answeredAt: now,
|
||||||
})
|
})
|
||||||
.where(eq(practiceAnswers.id, answerId))
|
.where(eq(practiceAnswers.id, answerId))
|
||||||
|
|
||||||
// 更新会话统计
|
// 5. 累加会话统计(基于步骤 1 已加锁的 session 行)
|
||||||
await updateSessionStats(sessionId, 0, false)
|
await tx
|
||||||
return { isCorrect: null, score: null }
|
.update(practiceSessions)
|
||||||
}
|
.set({
|
||||||
|
answeredQuestions: session.answeredQuestions + 1,
|
||||||
|
correctCount: session.correctCount + (isCorrect === true ? 1 : 0),
|
||||||
|
})
|
||||||
|
.where(eq(practiceSessions.id, sessionId))
|
||||||
|
|
||||||
// 自动判分:查询题目内容并提取正确答案
|
return { isCorrect, score }
|
||||||
const question = await db.query.questions.findFirst({
|
|
||||||
where: eq(questions.id, answerRecord.questionId),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!question) {
|
|
||||||
throw new Error("题目不存在")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果是变式题,使用变式题内容
|
|
||||||
const contentToUse = answerRecord.variantContent ?? question.content
|
|
||||||
const isCorrect = autoGradeAnswer(question.type, contentToUse, answer)
|
|
||||||
|
|
||||||
const score = isCorrect === true ? answerRecord.maxScore : (isCorrect === false ? 0 : null)
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(practiceAnswers)
|
|
||||||
.set({
|
|
||||||
status: "answered",
|
|
||||||
studentAnswer: answer,
|
|
||||||
isCorrect,
|
|
||||||
score,
|
|
||||||
answeredAt: now,
|
|
||||||
})
|
|
||||||
.where(eq(practiceAnswers.id, answerId))
|
|
||||||
|
|
||||||
// 更新会话统计
|
|
||||||
await updateSessionStats(
|
|
||||||
sessionId,
|
|
||||||
1,
|
|
||||||
isCorrect === true,
|
|
||||||
)
|
|
||||||
|
|
||||||
return { isCorrect, score }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 写入:完成/放弃练习会话
|
// 写入:完成/放弃练习会话
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 完成练习会话。
|
||||||
|
*
|
||||||
|
* 完整性校验:必须答完所有题目(answeredQuestions === totalQuestions)才能完成,
|
||||||
|
* 防止学生提前完成导致统计失真。
|
||||||
|
*
|
||||||
|
* 注意:跳过的题目也算"已作答"(status=skipped),与 answeredQuestions 累加逻辑一致。
|
||||||
|
*
|
||||||
|
* @throws {PracticeError} 会话不存在 → session_not_found;未答完 → session_not_complete
|
||||||
|
*/
|
||||||
export async function completePracticeSession(
|
export async function completePracticeSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
studentId: string,
|
studentId: string,
|
||||||
@@ -424,13 +468,19 @@ export async function completePracticeSession(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("练习会话不存在或无权访问")
|
throw practiceErrors.sessionNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.status !== "in_progress") {
|
if (session.status !== "in_progress") {
|
||||||
|
// 已完成或已放弃,幂等返回
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 完整性校验:必须答完所有题目
|
||||||
|
if (session.answeredQuestions !== session.totalQuestions) {
|
||||||
|
throw practiceErrors.sessionNotComplete()
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(practiceSessions)
|
.update(practiceSessions)
|
||||||
.set({
|
.set({
|
||||||
@@ -440,6 +490,13 @@ export async function completePracticeSession(
|
|||||||
.where(eq(practiceSessions.id, sessionId))
|
.where(eq(practiceSessions.id, sessionId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 放弃练习会话。
|
||||||
|
*
|
||||||
|
* 幂等:已完成或已放弃的会话再次调用不会报错。
|
||||||
|
*
|
||||||
|
* @throws {PracticeError} 会话不存在 → session_not_found
|
||||||
|
*/
|
||||||
export async function abandonPracticeSession(
|
export async function abandonPracticeSession(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
studentId: string,
|
studentId: string,
|
||||||
@@ -452,10 +509,11 @@ export async function abandonPracticeSession(
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
throw new Error("练习会话不存在或无权访问")
|
throw practiceErrors.sessionNotFound()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (session.status !== "in_progress") {
|
if (session.status !== "in_progress") {
|
||||||
|
// 已完成或已放弃,幂等返回
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,148 +530,5 @@ export async function abandonPracticeSession(
|
|||||||
// 内部辅助函数
|
// 内部辅助函数
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
// 自动判分、答案归一化等纯函数已抽取至 lib/grading.ts,便于单测与复用。
|
||||||
* 更新会话统计(已答题数、正确数)。
|
// 会话统计累加逻辑已内联到 submitPracticeSession 事务中,确保原子性。
|
||||||
*/
|
|
||||||
async function updateSessionStats(
|
|
||||||
sessionId: string,
|
|
||||||
newlyAnswered: number,
|
|
||||||
newlyCorrect: boolean,
|
|
||||||
): Promise<void> {
|
|
||||||
const session = await db.query.practiceSessions.findFirst({
|
|
||||||
where: eq(practiceSessions.id, sessionId),
|
|
||||||
columns: {
|
|
||||||
answeredQuestions: true,
|
|
||||||
correctCount: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!session) return
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(practiceSessions)
|
|
||||||
.set({
|
|
||||||
answeredQuestions: session.answeredQuestions + newlyAnswered,
|
|
||||||
correctCount: session.correctCount + (newlyCorrect ? 1 : 0),
|
|
||||||
})
|
|
||||||
.where(eq(practiceSessions.id, sessionId))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 自动判分:比对学生答案与正确答案。
|
|
||||||
*
|
|
||||||
* 支持题型:
|
|
||||||
* - single_choice: 比对选中选项 ID
|
|
||||||
* - multiple_choice: 比对选中选项 ID 集合(顺序无关)
|
|
||||||
* - judgment: 比对布尔值
|
|
||||||
* - text: 不自动判分(返回 null)
|
|
||||||
*
|
|
||||||
* @param questionType 题目类型
|
|
||||||
* @param content 题目内容(或变式题内容)
|
|
||||||
* @param studentAnswer 学生答案
|
|
||||||
* @returns 是否正确(null 表示无法自动判分)
|
|
||||||
*/
|
|
||||||
function autoGradeAnswer(
|
|
||||||
questionType: string,
|
|
||||||
content: unknown,
|
|
||||||
studentAnswer: unknown,
|
|
||||||
): boolean | null {
|
|
||||||
if (questionType === "single_choice" || questionType === "multiple_choice") {
|
|
||||||
const correctIds = extractChoiceCorrectIds(content)
|
|
||||||
if (correctIds.length === 0) return null
|
|
||||||
|
|
||||||
const studentIds = normalizeAnswerToIds(studentAnswer)
|
|
||||||
if (studentIds.length === 0) return false
|
|
||||||
|
|
||||||
if (questionType === "single_choice") {
|
|
||||||
return studentIds.length === 1 && studentIds[0] === correctIds[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// multiple_choice: 集合比对
|
|
||||||
if (studentIds.length !== correctIds.length) return false
|
|
||||||
const correctSet = new Set(correctIds)
|
|
||||||
return studentIds.every((id) => correctSet.has(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questionType === "judgment") {
|
|
||||||
const correctAnswer = extractJudgmentCorrectAnswer(content)
|
|
||||||
if (correctAnswer === null) return null
|
|
||||||
|
|
||||||
const studentBool = normalizeAnswerToBool(studentAnswer)
|
|
||||||
if (studentBool === null) return null
|
|
||||||
|
|
||||||
return studentBool === correctAnswer
|
|
||||||
}
|
|
||||||
|
|
||||||
// text 题型不自动判分
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从题目内容中提取选择题正确选项 ID 列表。
|
|
||||||
*/
|
|
||||||
function extractChoiceCorrectIds(content: unknown): string[] {
|
|
||||||
if (!isRecord(content)) return []
|
|
||||||
|
|
||||||
const options = content.options
|
|
||||||
if (!Array.isArray(options)) return []
|
|
||||||
|
|
||||||
return options
|
|
||||||
.filter((opt: unknown) => isRecord(opt) && opt.isCorrect === true)
|
|
||||||
.map((opt: unknown) => {
|
|
||||||
const record = opt as Record<string, unknown>
|
|
||||||
return typeof record.id === "string" ? record.id : ""
|
|
||||||
})
|
|
||||||
.filter((id: string) => id.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从题目内容中提取判断题正确答案。
|
|
||||||
*/
|
|
||||||
function extractJudgmentCorrectAnswer(content: unknown): boolean | null {
|
|
||||||
if (!isRecord(content)) return null
|
|
||||||
|
|
||||||
const answer = content.answer
|
|
||||||
if (typeof answer === "boolean") return answer
|
|
||||||
if (typeof answer === "string") {
|
|
||||||
const lower = answer.toLowerCase()
|
|
||||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
|
||||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将学生答案归一化为选项 ID 列表。
|
|
||||||
*/
|
|
||||||
function normalizeAnswerToIds(answer: unknown): string[] {
|
|
||||||
if (typeof answer === "string") return [answer]
|
|
||||||
if (Array.isArray(answer)) {
|
|
||||||
return answer.filter((v): v is string => typeof v === "string")
|
|
||||||
}
|
|
||||||
if (isRecord(answer)) {
|
|
||||||
const ids = answer.selectedIds
|
|
||||||
if (Array.isArray(ids)) {
|
|
||||||
return ids.filter((v): v is string => typeof v === "string")
|
|
||||||
}
|
|
||||||
if (typeof answer.id === "string") return [answer.id]
|
|
||||||
}
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将学生答案归一化为布尔值。
|
|
||||||
*/
|
|
||||||
function normalizeAnswerToBool(answer: unknown): boolean | null {
|
|
||||||
if (typeof answer === "boolean") return answer
|
|
||||||
if (typeof answer === "string") {
|
|
||||||
const lower = answer.toLowerCase()
|
|
||||||
if (lower === "true" || lower === "correct" || lower === "对" || lower === "正确") return true
|
|
||||||
if (lower === "false" || lower === "incorrect" || lower === "wrong" || lower === "错" || lower === "错误") return false
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null
|
|
||||||
}
|
|
||||||
|
|||||||
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"])
|
export const PracticeAnswerStatusSchema = z.enum(["pending", "answered", "skipped"])
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 来源元数据验证
|
// 来源元数据验证(判别式联合类型,替代 z.record(z.string(), z.unknown()))
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错题重做练习来源。
|
||||||
|
*
|
||||||
|
* practiceType="error_variant" 时使用。
|
||||||
|
*/
|
||||||
export const ErrorVariantSourceMetaSchema = z.object({
|
export const ErrorVariantSourceMetaSchema = z.object({
|
||||||
errorBookItemIds: z.array(z.string().min(1)).min(1),
|
errorBookItemIds: z.array(z.string().min(1)).min(1),
|
||||||
sourceQuestionIds: z.array(z.string().min(1)).min(1),
|
sourceQuestionIds: z.array(z.string().min(1)).min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 知识点专项练习来源。
|
||||||
|
*
|
||||||
|
* practiceType="knowledge_point" 时使用。
|
||||||
|
*/
|
||||||
export const KnowledgePointSourceMetaSchema = z.object({
|
export const KnowledgePointSourceMetaSchema = z.object({
|
||||||
knowledgePointIds: z.array(z.string().min(1)).min(1),
|
knowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||||
difficulty: z.number().int().min(1).max(5).optional(),
|
difficulty: z.number().int().min(1).max(5).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 薄弱章节练习来源。
|
||||||
|
*
|
||||||
|
* practiceType="weak_chapter" 时使用。
|
||||||
|
* chapterId 可选——未传时跨所有章节自动识别薄弱知识点。
|
||||||
|
* weakKnowledgePointIds 可选——未传时由后端自动识别薄弱知识点。
|
||||||
|
*/
|
||||||
export const WeakChapterSourceMetaSchema = z.object({
|
export const WeakChapterSourceMetaSchema = z.object({
|
||||||
chapterId: z.string().min(1),
|
chapterId: z.string().min(1).optional(),
|
||||||
weakKnowledgePointIds: z.array(z.string().min(1)).min(1),
|
weakKnowledgePointIds: z.array(z.string().min(1)).min(1).optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 推荐练习来源。
|
||||||
|
*
|
||||||
|
* practiceType="ai_recommended" 时使用。
|
||||||
|
* reason 为枚举字符串(业务数据,不应直接存翻译文本)。
|
||||||
|
*/
|
||||||
|
export const AiRecommendedReasonSchema = z.enum([
|
||||||
|
"student_initiated",
|
||||||
|
"teacher_assigned",
|
||||||
|
"parent_suggested",
|
||||||
|
])
|
||||||
|
|
||||||
export const AiRecommendedSourceMetaSchema = z.object({
|
export const AiRecommendedSourceMetaSchema = z.object({
|
||||||
recommendedKnowledgePointIds: z.array(z.string().min(1)).min(1),
|
recommendedKnowledgePointIds: z.array(z.string().min(1)).min(1),
|
||||||
reason: z.string().min(1),
|
reason: AiRecommendedReasonSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判别式联合类型 schema。
|
||||||
|
*
|
||||||
|
* 通过 practiceType 字段判别,确保 sourceMeta 结构与练习类型匹配。
|
||||||
|
* 替代原先的 z.record(z.string(), z.unknown()) 弱类型校验。
|
||||||
|
*/
|
||||||
|
export const PracticeSourceMetaSchema = z.discriminatedUnion("practiceType", [
|
||||||
|
z.object({
|
||||||
|
practiceType: z.literal("error_variant"),
|
||||||
|
sourceMeta: ErrorVariantSourceMetaSchema,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
practiceType: z.literal("knowledge_point"),
|
||||||
|
sourceMeta: KnowledgePointSourceMetaSchema,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
practiceType: z.literal("weak_chapter"),
|
||||||
|
sourceMeta: WeakChapterSourceMetaSchema,
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
practiceType: z.literal("ai_recommended"),
|
||||||
|
sourceMeta: AiRecommendedSourceMetaSchema,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Action 输入验证
|
// Action 输入验证
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -72,3 +126,4 @@ export type CreatePracticeSessionInput = z.infer<typeof CreatePracticeSessionSch
|
|||||||
export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema>
|
export type SubmitPracticeAnswerInput = z.infer<typeof SubmitPracticeAnswerSchema>
|
||||||
export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema>
|
export type CompletePracticeSessionInput = z.infer<typeof CompletePracticeSessionSchema>
|
||||||
export type AbandonPracticeSessionInput = z.infer<typeof AbandonPracticeSessionSchema>
|
export type AbandonPracticeSessionInput = z.infer<typeof AbandonPracticeSessionSchema>
|
||||||
|
export type AiRecommendedReason = z.infer<typeof AiRecommendedReasonSchema>
|
||||||
|
|||||||
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 {
|
export interface WeakChapterSourceMeta {
|
||||||
chapterId: string
|
/** 章节 ID(可选;未传时跨所有章节自动识别薄弱知识点) */
|
||||||
/** 自动识别的薄弱知识点 */
|
chapterId?: string
|
||||||
weakKnowledgePointIds: string[]
|
/** 自动识别的薄弱知识点(可选;未传时由 data-access 自动识别) */
|
||||||
|
weakKnowledgePointIds?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** AI 推荐练习来源 */
|
/** AI 推荐练习来源 */
|
||||||
export interface AiRecommendedSourceMeta {
|
export interface AiRecommendedSourceMeta {
|
||||||
/** AI 推荐的知识点列表 */
|
/** AI 推荐的知识点列表 */
|
||||||
recommendedKnowledgePointIds: string[]
|
recommendedKnowledgePointIds: string[]
|
||||||
/** 推荐理由 */
|
/** 推荐理由(枚举值,UI 层翻译) */
|
||||||
reason: string
|
reason: AiRecommendedReason
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 推荐理由枚举。
|
||||||
|
*
|
||||||
|
* 业务数据,存数据库,UI 层通过 t(`reasons.${reason}`) 查 i18n。
|
||||||
|
* 严禁存翻译文本到数据库。
|
||||||
|
*/
|
||||||
|
export type AiRecommendedReason = "student_initiated" | "teacher_assigned" | "parent_suggested"
|
||||||
|
|
||||||
export type PracticeSourceMeta =
|
export type PracticeSourceMeta =
|
||||||
| ErrorVariantSourceMeta
|
| ErrorVariantSourceMeta
|
||||||
| KnowledgePointSourceMeta
|
| KnowledgePointSourceMeta
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
WeaknessAnalysisInputSchema,
|
WeaknessAnalysisInputSchema,
|
||||||
ChildSummaryInputSchema,
|
ChildSummaryInputSchema,
|
||||||
StudyPathInputSchema,
|
StudyPathInputSchema,
|
||||||
|
ExplainErrorInputSchema,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
import type {
|
import type {
|
||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
@@ -37,6 +38,8 @@ import type {
|
|||||||
StudyPathInput,
|
StudyPathInput,
|
||||||
StudyPathResult,
|
StudyPathResult,
|
||||||
AiUsageStats,
|
AiUsageStats,
|
||||||
|
ExplainErrorInput,
|
||||||
|
ExplainErrorResult,
|
||||||
} from "./types"
|
} from "./types"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -327,16 +330,15 @@ export async function recommendStudyPathAction(
|
|||||||
|
|
||||||
// 同步填充 currentMastery(若未传入)
|
// 同步填充 currentMastery(若未传入)
|
||||||
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
|
if (!serviceInput.currentMastery || serviceInput.currentMastery.length === 0) {
|
||||||
serviceInput.currentMastery = kps
|
serviceInput.currentMastery = kps.flatMap((kp) => {
|
||||||
.filter((kp) => masteryMap.has(kp.id))
|
const m = masteryMap.get(kp.id)
|
||||||
.map((kp) => {
|
if (!m) return []
|
||||||
const m = masteryMap.get(kp.id)!
|
return [{
|
||||||
return {
|
knowledgePoint: kp.name,
|
||||||
knowledgePoint: kp.name,
|
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
|
||||||
masteryLevel: Math.round((m.masteryLevel / 100) * 5),
|
errorCount: m.totalQuestions - m.correctQuestions,
|
||||||
errorCount: m.totalQuestions - m.correctQuestions,
|
}]
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -376,6 +378,38 @@ export async function getAiUsageStatsAction(): Promise<ActionState<AiUsageStats>
|
|||||||
if (error instanceof PermissionDeniedError) {
|
if (error instanceof PermissionDeniedError) {
|
||||||
return { success: false, message: error.message }
|
return { success: false, message: error.message }
|
||||||
}
|
}
|
||||||
return { success: false, message: t("error.chatFailed") }
|
return { success: false, message: t("error.statsFailed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 错题 AI 解释(P2-1 新增)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function explainErrorAction(
|
||||||
|
input: ExplainErrorInput
|
||||||
|
): Promise<ActionState<ExplainErrorResult>> {
|
||||||
|
const t = await getTranslations("ai")
|
||||||
|
try {
|
||||||
|
const ctx = await requireAiPermission(
|
||||||
|
Permissions.AI_CHAT,
|
||||||
|
Permissions.ERROR_BOOK_READ
|
||||||
|
)
|
||||||
|
const parsed = ExplainErrorInputSchema.safeParse(input)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: t("error.invalidInput") }
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createAiService(ctx.userId)
|
||||||
|
const result = await safeAiCall(() => service.explainError(parsed.data))
|
||||||
|
if (!result.ok) {
|
||||||
|
return { success: false, message: result.message }
|
||||||
|
}
|
||||||
|
return { success: true, data: result.data }
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PermissionDeniedError) {
|
||||||
|
return { success: false, message: error.message }
|
||||||
|
}
|
||||||
|
return { success: false, message: t("error.analysisFailed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,11 +229,11 @@ function inferContextFromPath(
|
|||||||
return {
|
return {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
|
"You are an AI grading assistant for teachers. Help with evaluating student submissions, providing feedback suggestions, and identifying common mistakes. Be concise and constructive.",
|
||||||
contextMessage: "Current page: Homework grading view",
|
contextMessage: t("chat.contextMessage.teacherGrading"),
|
||||||
suggestedPrompts: [
|
suggestedPrompts: [
|
||||||
t("chat.suggestedPrompts.teacher.0"),
|
t("chat.suggestedPrompts.teacher.0"),
|
||||||
"What are common mistakes in this type of question?",
|
t("chat.suggestedPrompts.context.teacherGrading.0"),
|
||||||
"How should I give constructive feedback?",
|
t("chat.suggestedPrompts.context.teacherGrading.1"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,11 +243,11 @@ function inferContextFromPath(
|
|||||||
return {
|
return {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
|
"You are an AI lesson planning assistant. Help teachers design lessons, create activities, generate discussion questions, and align with curriculum standards.",
|
||||||
contextMessage: "Current page: Lesson plan editor",
|
contextMessage: t("chat.contextMessage.teacherLesson"),
|
||||||
suggestedPrompts: [
|
suggestedPrompts: [
|
||||||
t("chat.suggestedPrompts.teacher.1"),
|
t("chat.suggestedPrompts.teacher.1"),
|
||||||
"Suggest a hook for this lesson",
|
t("chat.suggestedPrompts.context.teacherLesson.0"),
|
||||||
"What are some differentiation strategies?",
|
t("chat.suggestedPrompts.context.teacherLesson.1"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -257,11 +257,11 @@ function inferContextFromPath(
|
|||||||
return {
|
return {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
|
"You are an AI exam design assistant. Help create questions, generate variants, analyze difficulty distribution, and ensure knowledge point coverage.",
|
||||||
contextMessage: "Current page: Exam builder",
|
contextMessage: t("chat.contextMessage.teacherExam"),
|
||||||
suggestedPrompts: [
|
suggestedPrompts: [
|
||||||
t("chat.suggestedPrompts.teacher.2"),
|
t("chat.suggestedPrompts.teacher.2"),
|
||||||
"Generate a question on this topic",
|
t("chat.suggestedPrompts.context.teacherExam.0"),
|
||||||
"Analyze the difficulty distribution",
|
t("chat.suggestedPrompts.context.teacherExam.1"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,7 +271,7 @@ function inferContextFromPath(
|
|||||||
return {
|
return {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
|
"You are a Socratic tutor for K12 students. Guide the student to find answers themselves. Do NOT give direct answers. Use questions and hints to help them understand their mistakes.",
|
||||||
contextMessage: "Current page: Error book (student view)",
|
contextMessage: t("chat.contextMessage.studentErrorBook"),
|
||||||
suggestedPrompts: [
|
suggestedPrompts: [
|
||||||
t("chat.suggestedPrompts.student.0"),
|
t("chat.suggestedPrompts.student.0"),
|
||||||
t("chat.suggestedPrompts.student.1"),
|
t("chat.suggestedPrompts.student.1"),
|
||||||
@@ -285,11 +285,11 @@ function inferContextFromPath(
|
|||||||
return {
|
return {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
|
"You are a homework helper for K12 students. Use the Socratic method. Do NOT give direct answers. Guide the student through hints and questions.",
|
||||||
contextMessage: "Current page: Student homework view",
|
contextMessage: t("chat.contextMessage.studentHomework"),
|
||||||
suggestedPrompts: [
|
suggestedPrompts: [
|
||||||
t("chat.suggestedPrompts.student.0"),
|
t("chat.suggestedPrompts.student.0"),
|
||||||
"Give me a hint, not the answer",
|
t("chat.suggestedPrompts.context.studentHomework.0"),
|
||||||
"Help me understand this concept",
|
t("chat.suggestedPrompts.context.studentHomework.1"),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,7 +299,7 @@ function inferContextFromPath(
|
|||||||
return {
|
return {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
|
"You are a family education advisor. Help parents understand their child's learning progress, suggest home tutoring strategies, and provide educational guidance.",
|
||||||
contextMessage: "Current page: Parent dashboard",
|
contextMessage: t("chat.contextMessage.parent"),
|
||||||
suggestedPrompts: [
|
suggestedPrompts: [
|
||||||
t("chat.suggestedPrompts.parent.0"),
|
t("chat.suggestedPrompts.parent.0"),
|
||||||
t("chat.suggestedPrompts.parent.1"),
|
t("chat.suggestedPrompts.parent.1"),
|
||||||
@@ -312,7 +312,7 @@ function inferContextFromPath(
|
|||||||
return {
|
return {
|
||||||
systemPrompt:
|
systemPrompt:
|
||||||
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
|
"You are an AI education administration assistant. Help administrators monitor AI usage, analyze school-wide trends, and optimize resource allocation.",
|
||||||
contextMessage: "Current page: Admin dashboard",
|
contextMessage: t("chat.contextMessage.admin"),
|
||||||
suggestedPrompts: [
|
suggestedPrompts: [
|
||||||
t("chat.suggestedPrompts.admin.0"),
|
t("chat.suggestedPrompts.admin.0"),
|
||||||
t("chat.suggestedPrompts.admin.1"),
|
t("chat.suggestedPrompts.admin.1"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useMemo } from "react"
|
import { useMemo } from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
type ChartConfig,
|
type ChartConfig,
|
||||||
} from "@/shared/components/ui/chart"
|
} from "@/shared/components/ui/chart"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
|
import { AiChartSpecSchema } from "../schema"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 图表渲染器
|
* AI 图表渲染器
|
||||||
@@ -111,14 +113,15 @@ interface AiChartRendererProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析 JSON 规格,失败时返回 null
|
* 解析 JSON 规格,失败时返回 null
|
||||||
|
*
|
||||||
|
* 使用 Zod schema 校验,避免 as 断言。
|
||||||
*/
|
*/
|
||||||
function parseSpec(spec: string): AiChartSpec | null {
|
function parseSpec(spec: string): AiChartSpec | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(spec) as AiChartSpec
|
const parsed: unknown = JSON.parse(spec)
|
||||||
if (!parsed || !Array.isArray(parsed.data) || !Array.isArray(parsed.series)) {
|
const result = AiChartSpecSchema.safeParse(parsed)
|
||||||
return null
|
if (!result.success) return null
|
||||||
}
|
return result.data
|
||||||
return parsed
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -139,12 +142,13 @@ export function AiChartRenderer({
|
|||||||
spec,
|
spec,
|
||||||
className,
|
className,
|
||||||
}: AiChartRendererProps): React.ReactNode {
|
}: AiChartRendererProps): React.ReactNode {
|
||||||
|
const t = useTranslations("ai")
|
||||||
const parsed = useMemo(() => parseSpec(spec), [spec])
|
const parsed = useMemo(() => parseSpec(spec), [spec])
|
||||||
|
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive">
|
||||||
图表数据格式错误,无法渲染
|
{t("chart.parseError")}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { Component, type ReactNode } from "react"
|
/**
|
||||||
import { AlertCircle, RefreshCw } from "lucide-react"
|
* AI 专用 Error Boundary
|
||||||
import { useTranslations } from "next-intl"
|
*
|
||||||
|
* 薄包装:委托给共享 SectionErrorBoundary,使用 ai 命名空间。
|
||||||
|
* 保留同名导出以兼容现有 import。
|
||||||
|
*/
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import type { ReactNode } from "react"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { SectionErrorBoundary } from "@/shared/components/section-error-boundary"
|
||||||
|
|
||||||
type AiErrorBoundaryProps = {
|
type AiErrorBoundaryProps = {
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
@@ -15,74 +18,14 @@ type AiErrorBoundaryProps = {
|
|||||||
onError?: (error: Error, info: unknown) => void
|
onError?: (error: Error, info: unknown) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type AiErrorBoundaryState = {
|
export function AiErrorBoundary({
|
||||||
error: Error | null
|
children,
|
||||||
}
|
fallback,
|
||||||
|
onError,
|
||||||
/**
|
}: AiErrorBoundaryProps): ReactNode {
|
||||||
* AI 专用 Error Boundary
|
|
||||||
*
|
|
||||||
* 包裹所有 AI 数据区块,防止单个 AI 调用失败导致整页崩溃。
|
|
||||||
* 提供重试按钮与友好的错误提示。
|
|
||||||
*/
|
|
||||||
export class AiErrorBoundary extends Component<
|
|
||||||
AiErrorBoundaryProps,
|
|
||||||
AiErrorBoundaryState
|
|
||||||
> {
|
|
||||||
constructor(props: AiErrorBoundaryProps) {
|
|
||||||
super(props)
|
|
||||||
this.state = { error: null }
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error: Error): AiErrorBoundaryState {
|
|
||||||
return { error }
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidCatch(error: Error, info: unknown): void {
|
|
||||||
if (this.props.onError) {
|
|
||||||
this.props.onError(error, info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleReset = (): void => {
|
|
||||||
this.setState({ error: null })
|
|
||||||
}
|
|
||||||
|
|
||||||
render(): ReactNode {
|
|
||||||
if (this.state.error) {
|
|
||||||
if (this.props.fallback) {
|
|
||||||
return this.props.fallback(this.state.error, this.handleReset)
|
|
||||||
}
|
|
||||||
return <DefaultAiErrorFallback error={this.state.error} onReset={this.handleReset} />
|
|
||||||
}
|
|
||||||
return this.props.children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function DefaultAiErrorFallback({
|
|
||||||
error,
|
|
||||||
onReset,
|
|
||||||
}: {
|
|
||||||
error: Error
|
|
||||||
onReset: () => void
|
|
||||||
}): ReactNode {
|
|
||||||
const t = useTranslations("ai")
|
|
||||||
return (
|
return (
|
||||||
<Card className="border-destructive/30">
|
<SectionErrorBoundary namespace="ai" fallback={fallback} onError={onError}>
|
||||||
<CardHeader>
|
{children}
|
||||||
<CardTitle className="flex items-center gap-2 text-destructive">
|
</SectionErrorBoundary>
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
{t("error.boundaryTitle")}
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3">
|
|
||||||
<p className="text-sm text-muted-foreground">{t("error.boundaryDescription")}</p>
|
|
||||||
<p className="text-xs text-muted-foreground/70 font-mono">{error.message}</p>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
|
||||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
|
||||||
{t("error.retry")}
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,28 @@ const CHART_TYPES: Record<string, AiChartType> = {
|
|||||||
"chart:radar": "radar",
|
"chart:radar": "radar",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 类型守卫:判断字符串是否为合法的 AiChartType
|
||||||
|
*/
|
||||||
|
function isAiChartType(value: string): value is AiChartType {
|
||||||
|
return value === "bar" || value === "line" || value === "pie" || value === "radar"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 language 标识中解析图表类型
|
||||||
|
*/
|
||||||
|
function resolveChartType(lang: string): AiChartType | undefined {
|
||||||
|
// 优先查表(兼容 "chart:bar" 形式)
|
||||||
|
const fromTable = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
|
||||||
|
if (fromTable) return fromTable
|
||||||
|
// 兼容 "chart:bar" 前缀形式
|
||||||
|
if (lang.startsWith(CHART_LANG_PREFIX)) {
|
||||||
|
const suffix = lang.slice(CHART_LANG_PREFIX.length)
|
||||||
|
return isAiChartType(suffix) ? suffix : undefined
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI Markdown 渲染器
|
* AI Markdown 渲染器
|
||||||
*
|
*
|
||||||
@@ -87,12 +109,9 @@ function AiMarkdownRendererImpl({
|
|||||||
|
|
||||||
// 检测图表代码块:language-chart:bar / chart:line / chart:pie / chart:radar
|
// 检测图表代码块:language-chart:bar / chart:line / chart:pie / chart:radar
|
||||||
const lang = codeClass?.replace("language-", "").trim() ?? ""
|
const lang = codeClass?.replace("language-", "").trim() ?? ""
|
||||||
const chartType = CHART_TYPES[`${CHART_LANG_PREFIX}${lang}`]
|
const chartType = resolveChartType(lang)
|
||||||
?? (lang.startsWith(CHART_LANG_PREFIX)
|
|
||||||
? (lang.slice(CHART_LANG_PREFIX.length) as AiChartType)
|
|
||||||
: undefined)
|
|
||||||
|
|
||||||
if (chartType && (chartType === "bar" || chartType === "line" || chartType === "pie" || chartType === "radar")) {
|
if (chartType) {
|
||||||
const raw = String(children).replace(/\n$/, "")
|
const raw = String(children).replace(/\n$/, "")
|
||||||
return <AiChartRenderer type={chartType} spec={raw} />
|
return <AiChartRenderer type={chartType} spec={raw} />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export function AiProviderSelector({
|
|||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("provider.label")}</FormLabel>
|
<FormLabel>{t("provider.label")}</FormLabel>
|
||||||
<Select value={field.value as string} onValueChange={field.onChange} disabled={loading}>
|
<Select value={typeof field.value === "string" ? field.value : ""} onValueChange={field.onChange} disabled={loading}>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect, useCallback } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
|
import { Activity, Users, AlertTriangle, Clock } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
@@ -33,7 +33,7 @@ export function AiUsageDashboard(): React.ReactNode {
|
|||||||
const [stats, setStats] = useState<AiUsageStats | null>(null)
|
const [stats, setStats] = useState<AiUsageStats | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
const loadStats = async (): Promise<void> => {
|
const loadStats = useCallback(async (): Promise<void> => {
|
||||||
if (!aiClient.getAiUsageStats) return
|
if (!aiClient.getAiUsageStats) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
@@ -41,19 +41,18 @@ export function AiUsageDashboard(): React.ReactNode {
|
|||||||
if (result.success && result.data) {
|
if (result.success && result.data) {
|
||||||
setStats(result.data)
|
setStats(result.data)
|
||||||
} else {
|
} else {
|
||||||
toast.error(result.message ?? t("error.chatFailed"))
|
toast.error(result.message ?? t("error.statsFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t("error.chatFailed"))
|
toast.error(t("error.statsFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}, [aiClient, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadStats()
|
void loadStats()
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [loadStats])
|
||||||
}, [])
|
|
||||||
|
|
||||||
const statCards = stats
|
const statCards = stats
|
||||||
? [
|
? [
|
||||||
|
|||||||
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"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
|
|
||||||
type Position = { x: number; y: number }
|
import {
|
||||||
|
BALL_SIZE,
|
||||||
|
HIDE_THRESHOLD,
|
||||||
|
MARGIN,
|
||||||
|
type Position,
|
||||||
|
clampPosition,
|
||||||
|
getDefaultPosition,
|
||||||
|
savePosition,
|
||||||
|
usePositionPersistence,
|
||||||
|
} from "./use-position-persistence"
|
||||||
|
import { useDragPosition } from "./use-drag-position"
|
||||||
|
|
||||||
const STORAGE_KEY = "ai-widget-position"
|
/**
|
||||||
const HIDE_THRESHOLD = 0.55
|
* 计算吸附到最近边缘后的 X 坐标
|
||||||
const BALL_SIZE = 56
|
*/
|
||||||
const MARGIN = 16
|
function snapToEdge(x: number): number {
|
||||||
|
if (typeof window === "undefined") return x
|
||||||
function clampPosition(pos: Position): Position {
|
const w = window.innerWidth
|
||||||
if (typeof window === "undefined") return pos
|
const centerX = x + BALL_SIZE / 2
|
||||||
const maxX = window.innerWidth - BALL_SIZE - MARGIN
|
const distanceToLeft = centerX
|
||||||
const maxY = window.innerHeight - BALL_SIZE - MARGIN
|
const distanceToRight = w - centerX
|
||||||
return {
|
return distanceToLeft < distanceToRight ? MARGIN : w - BALL_SIZE - MARGIN
|
||||||
x: Math.min(Math.max(pos.x, MARGIN), Math.max(maxX, MARGIN)),
|
|
||||||
y: Math.min(Math.max(pos.y, MARGIN), Math.max(maxY, MARGIN)),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPosition(): Position {
|
/**
|
||||||
if (typeof window === "undefined") {
|
* 计算半隐藏时的视觉偏移量
|
||||||
return { x: 9999, y: 9999 }
|
*/
|
||||||
}
|
function calculateHiddenOffset(
|
||||||
try {
|
position: Position,
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
hidden: boolean,
|
||||||
if (raw) {
|
hovered: boolean,
|
||||||
const parsed = JSON.parse(raw) as Partial<Position>
|
dragging: boolean
|
||||||
if (typeof parsed.x === "number" && typeof parsed.y === "number") {
|
): number {
|
||||||
return clampPosition({ x: parsed.x, y: parsed.y })
|
if (!hidden || hovered || dragging) return 0
|
||||||
}
|
return position.x <= MARGIN + 2
|
||||||
}
|
? -(BALL_SIZE * HIDE_THRESHOLD)
|
||||||
} catch {
|
: BALL_SIZE * HIDE_THRESHOLD
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
const x = window.innerWidth - BALL_SIZE - MARGIN * 2
|
|
||||||
const y = window.innerHeight - BALL_SIZE - MARGIN * 4
|
|
||||||
return clampPosition({ x, y })
|
|
||||||
}
|
|
||||||
|
|
||||||
function savePosition(pos: Position): void {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
try {
|
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(pos))
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,135 +50,69 @@ function savePosition(pos: Position): void {
|
|||||||
* - 单击(未发生拖动)触发 onClick
|
* - 单击(未发生拖动)触发 onClick
|
||||||
* - 位置持久化到 localStorage
|
* - 位置持久化到 localStorage
|
||||||
* - 窗口 resize 时自动校正位置
|
* - 窗口 resize 时自动校正位置
|
||||||
|
*
|
||||||
|
* V3:拆分为 use-position-persistence + use-drag-position + 本 hook 组合
|
||||||
*/
|
*/
|
||||||
export function useFloatingBall(onClick: () => void) {
|
export function useFloatingBall(onClick: () => void): {
|
||||||
// 服务端与客户端首次渲染一致(position 在屏幕外,不渲染按钮)
|
position: Position
|
||||||
// 在 useEffect 中加载真实位置,避免 hydration mismatch
|
hidden: boolean
|
||||||
const [position, setPosition] = useState<Position>({ x: 9999, y: 9999 })
|
dragging: boolean
|
||||||
|
hovered: boolean
|
||||||
|
hiddenOffset: number
|
||||||
|
handlers: {
|
||||||
|
onPointerDown: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||||
|
onPointerMove: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||||
|
onPointerUp: (e: React.PointerEvent<HTMLButtonElement>) => void
|
||||||
|
onPointerCancel: () => void
|
||||||
|
onMouseEnter: () => void
|
||||||
|
onMouseLeave: () => void
|
||||||
|
}
|
||||||
|
show: () => void
|
||||||
|
resetPosition: () => void
|
||||||
|
} {
|
||||||
|
const { position, setPosition } = usePositionPersistence()
|
||||||
const [hidden, setHidden] = useState(false)
|
const [hidden, setHidden] = useState(false)
|
||||||
const [dragging, setDragging] = useState(false)
|
|
||||||
const [hovered, setHovered] = useState(false)
|
const [hovered, setHovered] = useState(false)
|
||||||
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
|
// 拖拽释放后标记"刚隐藏",阻止 mouseEnter 立即展开
|
||||||
const justHiddenRef = useRef(false)
|
const justHiddenRef = useRef(false)
|
||||||
|
|
||||||
const dragStateRef = useRef({
|
|
||||||
active: false,
|
|
||||||
moved: false,
|
|
||||||
startX: 0,
|
|
||||||
startY: 0,
|
|
||||||
originX: 0,
|
|
||||||
originY: 0,
|
|
||||||
pointerId: -1,
|
|
||||||
})
|
|
||||||
const onClickRef = useRef(onClick)
|
const onClickRef = useRef(onClick)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onClickRef.current = onClick
|
onClickRef.current = onClick
|
||||||
}, [onClick])
|
}, [onClick])
|
||||||
|
|
||||||
// 初始化位置:在客户端 mount 后加载真实位置
|
const handleDragStart = useCallback((): void => {
|
||||||
// 避免 hydration mismatch(服务端与客户端位置不同)
|
setHidden(false)
|
||||||
useEffect(() => {
|
|
||||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
||||||
setPosition(loadPosition())
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// 窗口 resize 时校正
|
const handleRelease = useCallback(
|
||||||
useEffect(() => {
|
(moved: boolean): void => {
|
||||||
const handleResize = () => {
|
|
||||||
setPosition((prev) => clampPosition(prev))
|
|
||||||
}
|
|
||||||
window.addEventListener("resize", handleResize)
|
|
||||||
return () => window.removeEventListener("resize", handleResize)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
|
||||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
||||||
// 仅主键响应拖拽
|
|
||||||
if (e.button !== 0 && e.pointerType === "mouse") return
|
|
||||||
const state = dragStateRef.current
|
|
||||||
state.active = true
|
|
||||||
state.moved = false
|
|
||||||
state.startX = e.clientX
|
|
||||||
state.startY = e.clientY
|
|
||||||
state.originX = position.x
|
|
||||||
state.originY = position.y
|
|
||||||
state.pointerId = e.pointerId
|
|
||||||
try {
|
|
||||||
e.currentTarget.setPointerCapture(e.pointerId)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
setHidden(false)
|
|
||||||
setDragging(true)
|
|
||||||
},
|
|
||||||
[position]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePointerMove = useCallback(
|
|
||||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
||||||
const state = dragStateRef.current
|
|
||||||
if (!state.active || e.pointerId !== state.pointerId) return
|
|
||||||
const dx = e.clientX - state.startX
|
|
||||||
const dy = e.clientY - state.startY
|
|
||||||
if (!state.moved && Math.abs(dx) + Math.abs(dy) < 4) return
|
|
||||||
state.moved = true
|
|
||||||
const next = clampPosition({
|
|
||||||
x: state.originX + dx,
|
|
||||||
y: state.originY + dy,
|
|
||||||
})
|
|
||||||
setPosition(next)
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handlePointerUp = useCallback(
|
|
||||||
(e: React.PointerEvent<HTMLButtonElement>) => {
|
|
||||||
const state = dragStateRef.current
|
|
||||||
if (!state.active || e.pointerId !== state.pointerId) return
|
|
||||||
state.active = false
|
|
||||||
setDragging(false)
|
|
||||||
try {
|
|
||||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
// 未移动 → 视为点击
|
// 未移动 → 视为点击
|
||||||
if (!state.moved) {
|
if (!moved) {
|
||||||
onClickRef.current()
|
onClickRef.current()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移动了 → 吸附到最近边缘
|
// 移动了 → 吸附到最近边缘
|
||||||
const w = window.innerWidth
|
const snappedX = snapToEdge(position.x)
|
||||||
const centerX = position.x + BALL_SIZE / 2
|
|
||||||
const distanceToLeft = centerX
|
|
||||||
const distanceToRight = w - centerX
|
|
||||||
const snapLeft = distanceToLeft < distanceToRight
|
|
||||||
const snappedX = snapLeft ? MARGIN : w - BALL_SIZE - MARGIN
|
|
||||||
|
|
||||||
// 判断是否半隐藏:吸附后位置贴近边缘
|
|
||||||
const shouldHide = true
|
|
||||||
|
|
||||||
const finalPos = clampPosition({ x: snappedX, y: position.y })
|
const finalPos = clampPosition({ x: snappedX, y: position.y })
|
||||||
setPosition(finalPos)
|
setPosition(finalPos)
|
||||||
savePosition(finalPos)
|
savePosition(finalPos)
|
||||||
setHidden(shouldHide)
|
setHidden(true)
|
||||||
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
|
// 标记刚隐藏,阻止后续 mouseEnter 立即展开
|
||||||
justHiddenRef.current = shouldHide
|
justHiddenRef.current = true
|
||||||
// 清除 hovered,确保 hiddenOffset 生效
|
// 清除 hovered,确保 hiddenOffset 生效
|
||||||
setHovered(false)
|
setHovered(false)
|
||||||
},
|
},
|
||||||
[position]
|
[position, setPosition]
|
||||||
)
|
)
|
||||||
|
|
||||||
const handlePointerCancel = useCallback(() => {
|
const { dragging, handlers: dragHandlers } = useDragPosition(
|
||||||
const state = dragStateRef.current
|
position,
|
||||||
state.active = false
|
setPosition,
|
||||||
setDragging(false)
|
{ onDragStart: handleDragStart, onRelease: handleRelease }
|
||||||
}, [])
|
)
|
||||||
|
|
||||||
const handleMouseEnter = useCallback(() => {
|
const handleMouseEnter = useCallback((): void => {
|
||||||
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
|
// 如果刚通过拖拽隐藏,不立即展开(需先离开再进入才展开)
|
||||||
if (justHiddenRef.current) {
|
if (justHiddenRef.current) {
|
||||||
justHiddenRef.current = false
|
justHiddenRef.current = false
|
||||||
@@ -195,33 +122,26 @@ export function useFloatingBall(onClick: () => void) {
|
|||||||
if (hidden) setHidden(false)
|
if (hidden) setHidden(false)
|
||||||
}, [hidden])
|
}, [hidden])
|
||||||
|
|
||||||
const handleMouseLeave = useCallback(() => {
|
const handleMouseLeave = useCallback((): void => {
|
||||||
setHovered(false)
|
setHovered(false)
|
||||||
// 离开后清除 justHidden 标记,下次进入可正常展开
|
// 离开后清除 justHidden 标记,下次进入可正常展开
|
||||||
justHiddenRef.current = false
|
justHiddenRef.current = false
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const show = useCallback(() => {
|
const show = useCallback((): void => {
|
||||||
justHiddenRef.current = false
|
justHiddenRef.current = false
|
||||||
setHidden(false)
|
setHidden(false)
|
||||||
}, [])
|
}, [])
|
||||||
const resetPosition = useCallback(() => {
|
|
||||||
justHiddenRef.current = false
|
|
||||||
const fresh = typeof window === "undefined"
|
|
||||||
? loadPosition()
|
|
||||||
: clampPosition({
|
|
||||||
x: window.innerWidth - BALL_SIZE - MARGIN * 2,
|
|
||||||
y: window.innerHeight - BALL_SIZE - MARGIN * 4,
|
|
||||||
})
|
|
||||||
setPosition(fresh)
|
|
||||||
savePosition(fresh)
|
|
||||||
setHidden(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
// 半隐藏时的视觉偏移量
|
const resetPosition = useCallback((): void => {
|
||||||
const hiddenOffset = hidden && !hovered && !dragging
|
justHiddenRef.current = false
|
||||||
? (position.x <= MARGIN + 2 ? -(BALL_SIZE * HIDE_THRESHOLD) : BALL_SIZE * HIDE_THRESHOLD)
|
const fresh = getDefaultPosition()
|
||||||
: 0
|
setPosition(fresh)
|
||||||
|
savePosition(fresh)
|
||||||
|
setHidden(false)
|
||||||
|
}, [setPosition])
|
||||||
|
|
||||||
|
const hiddenOffset = calculateHiddenOffset(position, hidden, hovered, dragging)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
position,
|
position,
|
||||||
@@ -230,10 +150,7 @@ export function useFloatingBall(onClick: () => void) {
|
|||||||
hovered,
|
hovered,
|
||||||
hiddenOffset,
|
hiddenOffset,
|
||||||
handlers: {
|
handlers: {
|
||||||
onPointerDown: handlePointerDown,
|
...dragHandlers,
|
||||||
onPointerMove: handlePointerMove,
|
|
||||||
onPointerUp: handlePointerUp,
|
|
||||||
onPointerCancel: handlePointerCancel,
|
|
||||||
onMouseEnter: handleMouseEnter,
|
onMouseEnter: handleMouseEnter,
|
||||||
onMouseLeave: handleMouseLeave,
|
onMouseLeave: handleMouseLeave,
|
||||||
},
|
},
|
||||||
|
|||||||
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),
|
summary: z.string().min(1),
|
||||||
motivation: z.string().min(1),
|
motivation: z.string().min(1),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 错题 AI 解释校验
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ExplainErrorInputSchema = z.object({
|
||||||
|
questionText: z.string().min(1).max(4000),
|
||||||
|
questionType: z.string().min(1),
|
||||||
|
studentAnswer: z.string().min(1).max(8000),
|
||||||
|
correctAnswer: z.string().optional(),
|
||||||
|
subject: z.string().optional(),
|
||||||
|
knowledgePointIds: z.array(z.string()).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ExplainErrorResultSchema = z.object({
|
||||||
|
errorAnalysis: z.string().min(1),
|
||||||
|
correctApproach: z.string().min(1),
|
||||||
|
keyConcepts: z.array(z.string().min(1)),
|
||||||
|
preventionTips: z.array(z.string().min(1)),
|
||||||
|
practiceSuggestion: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI 图表规格校验(用于 ai-chart-renderer.tsx 解析 AI 返回的图表 JSON)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const AiChartSeriesSchema = z.object({
|
||||||
|
dataKey: z.string().min(1),
|
||||||
|
name: z.string().min(1),
|
||||||
|
color: z.string().optional(),
|
||||||
|
fillOpacity: z.number().optional(),
|
||||||
|
strokeWidth: z.number().optional(),
|
||||||
|
strokeDasharray: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const AiChartSpecSchema = z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
type: z.enum(["bar", "line", "pie", "radar"]).optional(),
|
||||||
|
data: z.array(z.record(z.string(), z.union([z.string(), z.number()]))),
|
||||||
|
xKey: z.string().optional(),
|
||||||
|
angleKey: z.string().optional(),
|
||||||
|
series: z.array(AiChartSeriesSchema),
|
||||||
|
yDomain: z.tuple([z.number(), z.number()]).optional(),
|
||||||
|
height: z.number().optional(),
|
||||||
|
showLegend: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
WEAKNESS_ANALYSIS_SYSTEM_PROMPT,
|
||||||
CHILD_SUMMARY_SYSTEM_PROMPT,
|
CHILD_SUMMARY_SYSTEM_PROMPT,
|
||||||
STUDY_PATH_SYSTEM_PROMPT,
|
STUDY_PATH_SYSTEM_PROMPT,
|
||||||
|
EXPLAIN_ERROR_SYSTEM_PROMPT,
|
||||||
} from "./prompt-templates"
|
} from "./prompt-templates"
|
||||||
import { withAiTracking } from "./usage-tracker"
|
import { withAiTracking } from "./usage-tracker"
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
WeaknessAnalysisResultSchema,
|
WeaknessAnalysisResultSchema,
|
||||||
ChildSummaryResultSchema,
|
ChildSummaryResultSchema,
|
||||||
StudyPathResultSchema,
|
StudyPathResultSchema,
|
||||||
|
ExplainErrorResultSchema,
|
||||||
} from "../schema"
|
} from "../schema"
|
||||||
import type {
|
import type {
|
||||||
AiChatMessage,
|
AiChatMessage,
|
||||||
@@ -41,6 +43,8 @@ import type {
|
|||||||
ChildSummaryResult,
|
ChildSummaryResult,
|
||||||
StudyPathInput,
|
StudyPathInput,
|
||||||
StudyPathResult,
|
StudyPathResult,
|
||||||
|
ExplainErrorInput,
|
||||||
|
ExplainErrorResult,
|
||||||
} from "../types"
|
} from "../types"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -144,9 +148,10 @@ const callAi = async (
|
|||||||
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
|
...(typeof options?.maxTokens === "number" ? { maxTokens: options.maxTokens } : {}),
|
||||||
...(options?.providerId ? { providerId: options.providerId } : {}),
|
...(options?.providerId ? { providerId: options.providerId } : {}),
|
||||||
})
|
})
|
||||||
|
// 从 unknown 类型安全提取 total_tokens(避免 as 断言)
|
||||||
const tokenUsage =
|
const tokenUsage =
|
||||||
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
|
result.usage && typeof result.usage === "object" && "total_tokens" in result.usage
|
||||||
? Number((result.usage as unknown as Record<string, unknown>).total_tokens ?? 0)
|
? Number(result.usage.total_tokens ?? 0)
|
||||||
: undefined
|
: undefined
|
||||||
return { content: result.content, tokenUsage }
|
return { content: result.content, tokenUsage }
|
||||||
}
|
}
|
||||||
@@ -169,7 +174,11 @@ export class DefaultAiService implements AiService {
|
|||||||
...options,
|
...options,
|
||||||
temperature: options?.temperature ?? 0.7,
|
temperature: options?.temperature ?? 0.7,
|
||||||
})
|
})
|
||||||
return { result: { content, usage: null }, tokenUsage }
|
// usage 字段返回 token 用量对象(unknown 类型),便于调用方按需类型缩小
|
||||||
|
return {
|
||||||
|
result: { content, usage: tokenUsage !== undefined ? { total_tokens: tokenUsage } : null },
|
||||||
|
tokenUsage,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +201,10 @@ export class DefaultAiService implements AiService {
|
|||||||
{ temperature: 0.5, maxTokens: 3000 }
|
{ temperature: 0.5, maxTokens: 3000 }
|
||||||
)
|
)
|
||||||
const parsed = extractJson(content)
|
const parsed = extractJson(content)
|
||||||
|
// 安全提取 questions 字段(使用 in 操作符类型缩小,无需 as 断言)
|
||||||
const list =
|
const list =
|
||||||
parsed && typeof parsed === "object" && "questions" in parsed
|
parsed && typeof parsed === "object" && "questions" in parsed
|
||||||
? (parsed as Record<string, unknown>).questions
|
? parsed.questions
|
||||||
: parsed
|
: parsed
|
||||||
const validated = SimilarQuestionListSchema.safeParse(list)
|
const validated = SimilarQuestionListSchema.safeParse(list)
|
||||||
if (!validated.success) return { result: [] }
|
if (!validated.success) return { result: [] }
|
||||||
@@ -413,6 +423,39 @@ export class DefaultAiService implements AiService {
|
|||||||
return { result: validated.data }
|
return { result: validated.data }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async explainError(input: ExplainErrorInput): Promise<ExplainErrorResult> {
|
||||||
|
return withAiTracking(this.userId, "explain_error", undefined, async () => {
|
||||||
|
const userLines = [
|
||||||
|
`Question Type: ${input.questionType}`,
|
||||||
|
input.subject ? `Subject: ${input.subject}` : "",
|
||||||
|
input.knowledgePointIds?.length
|
||||||
|
? `Knowledge Points: ${input.knowledgePointIds.join(", ")}`
|
||||||
|
: "",
|
||||||
|
`Question:\n${input.questionText}`,
|
||||||
|
`Student Answer:\n${input.studentAnswer}`,
|
||||||
|
input.correctAnswer ? `Correct Answer:\n${input.correctAnswer}` : "",
|
||||||
|
].filter((line) => line.length > 0)
|
||||||
|
const { content } = await callAi(
|
||||||
|
buildChatMessages(EXPLAIN_ERROR_SYSTEM_PROMPT, userLines.join("\n\n")),
|
||||||
|
{ temperature: 0.4, maxTokens: 2000 }
|
||||||
|
)
|
||||||
|
const parsed = extractJson(content)
|
||||||
|
const validated = ExplainErrorResultSchema.safeParse(parsed)
|
||||||
|
if (!validated.success) {
|
||||||
|
return {
|
||||||
|
result: {
|
||||||
|
errorAnalysis: "Unable to analyze the error at this time.",
|
||||||
|
correctApproach: "Please consult your teacher for help.",
|
||||||
|
keyConcepts: [],
|
||||||
|
preventionTips: [],
|
||||||
|
practiceSuggestion: "Review the relevant chapter and try again.",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { result: validated.data }
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -275,3 +275,26 @@ export const STUDY_PATH_SYSTEM_PROMPT = [
|
|||||||
"- motivation should be age-appropriate and encouraging.",
|
"- motivation should be age-appropriate and encouraging.",
|
||||||
"Never output placeholders.",
|
"Never output placeholders.",
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
|
export const EXPLAIN_ERROR_SYSTEM_PROMPT = [
|
||||||
|
"You are an expert K12 tutor specializing in helping students understand their mistakes.",
|
||||||
|
"Analyze the student's error and provide a clear, encouraging explanation.",
|
||||||
|
"Return JSON only without markdown.",
|
||||||
|
"Output schema:",
|
||||||
|
"{",
|
||||||
|
' "errorAnalysis": "detailed analysis of why the student made this error",',
|
||||||
|
' "correctApproach": "step-by-step correct solution approach",',
|
||||||
|
' "keyConcepts": ["list of key concepts the student needs to review"],',
|
||||||
|
' "preventionTips": ["tips to avoid similar mistakes in the future"],',
|
||||||
|
' "practiceSuggestion": "specific practice recommendation"',
|
||||||
|
"}",
|
||||||
|
"Rules:",
|
||||||
|
"- Use age-appropriate language for K12 students.",
|
||||||
|
"- Be encouraging and constructive, never dismissive.",
|
||||||
|
"- errorAnalysis should identify the specific misconception, not just say 'wrong'.",
|
||||||
|
"- correctApproach should be step-by-step and easy to follow.",
|
||||||
|
"- keyConcepts should list 2-5 fundamental concepts.",
|
||||||
|
"- preventionTips should be actionable and specific.",
|
||||||
|
"- practiceSuggestion should recommend a specific type of practice problem.",
|
||||||
|
"Never output placeholders.",
|
||||||
|
].join("\n")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { recordAiEvent } from "../data-access"
|
|||||||
|
|
||||||
export type AiUsageEvent = {
|
export type AiUsageEvent = {
|
||||||
userId: string
|
userId: string
|
||||||
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path"
|
capability: "chat" | "similar_question" | "grading_assist" | "lesson_content" | "question_variant" | "weakness_analysis" | "child_summary" | "study_path" | "explain_error"
|
||||||
providerId?: string
|
providerId?: string
|
||||||
model?: string
|
model?: string
|
||||||
success: boolean
|
success: boolean
|
||||||
@@ -23,6 +23,7 @@ const AI_EVENT_MAP: Record<AiUsageEvent["capability"], EventName> = {
|
|||||||
weakness_analysis: "ai.weakness_analysis",
|
weakness_analysis: "ai.weakness_analysis",
|
||||||
child_summary: "ai.child_summary",
|
child_summary: "ai.child_summary",
|
||||||
study_path: "ai.study_path",
|
study_path: "ai.study_path",
|
||||||
|
explain_error: "ai.explain_error",
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -221,6 +221,36 @@ export type AiUsageStats = {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 错题 AI 解释输入 */
|
||||||
|
export type ExplainErrorInput = {
|
||||||
|
/** 题目文本 */
|
||||||
|
questionText: string
|
||||||
|
/** 题目类型 */
|
||||||
|
questionType: string
|
||||||
|
/** 学生错误答案 */
|
||||||
|
studentAnswer: string
|
||||||
|
/** 正确答案 */
|
||||||
|
correctAnswer?: string
|
||||||
|
/** 学科 */
|
||||||
|
subject?: string
|
||||||
|
/** 知识点 ID 列表 */
|
||||||
|
knowledgePointIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错题 AI 解释结果 */
|
||||||
|
export type ExplainErrorResult = {
|
||||||
|
/** 错误原因分析 */
|
||||||
|
errorAnalysis: string
|
||||||
|
/** 正确解题思路 */
|
||||||
|
correctApproach: string
|
||||||
|
/** 关键知识点 */
|
||||||
|
keyConcepts: string[]
|
||||||
|
/** 类似错误防范建议 */
|
||||||
|
preventionTips: string[]
|
||||||
|
/** 练习建议 */
|
||||||
|
practiceSuggestion: string
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AI 能力配置(角色驱动)
|
// AI 能力配置(角色驱动)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -236,6 +266,7 @@ export type AiCapability =
|
|||||||
| "study-path"
|
| "study-path"
|
||||||
| "child-summary"
|
| "child-summary"
|
||||||
| "usage-stats"
|
| "usage-stats"
|
||||||
|
| "explain-error"
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 服务接口
|
// 服务接口
|
||||||
@@ -257,6 +288,7 @@ export interface AiService {
|
|||||||
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
|
analyzeWeakness(input: WeaknessAnalysisInput): Promise<WeaknessAnalysisResult>
|
||||||
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
|
generateChildSummary(input: ChildSummaryInput): Promise<ChildSummaryResult>
|
||||||
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
|
recommendStudyPath(input: StudyPathInput): Promise<StudyPathResult>
|
||||||
|
explainError(input: ExplainErrorInput): Promise<ExplainErrorResult>
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,6 +322,9 @@ export interface AiClientService {
|
|||||||
input: StudyPathInput
|
input: StudyPathInput
|
||||||
) => Promise<ActionState<StudyPathResult>>
|
) => Promise<ActionState<StudyPathResult>>
|
||||||
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
|
getAiUsageStats?: () => Promise<ActionState<AiUsageStats>>
|
||||||
|
explainError?: (
|
||||||
|
input: ExplainErrorInput
|
||||||
|
) => Promise<ActionState<ExplainErrorResult>>
|
||||||
/** 预留埋点接口 */
|
/** 预留埋点接口 */
|
||||||
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
trackEvent?: (event: string, payload?: Record<string, unknown>) => void
|
||||||
}
|
}
|
||||||
|
|||||||
382
src/modules/auth/actions.ts
Normal file
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 { GraduationCap } from "lucide-react"
|
||||||
|
|
||||||
|
import { DEFAULT_BRAND_CONFIG, type BrandConfig } from "@/modules/settings/brand-config"
|
||||||
|
|
||||||
interface AuthLayoutProps {
|
interface AuthLayoutProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
/** audit-P2-6: 品牌配置(由 Server Component layout 注入,未配置时使用默认值) */
|
||||||
|
brand?: BrandConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthLayout({ children }: AuthLayoutProps) {
|
/**
|
||||||
|
* 认证页面布局(audit-P2-6: 支持品牌配置注入)
|
||||||
|
*
|
||||||
|
* 品牌配置通过 props 注入,来源:
|
||||||
|
* - `app/(auth)/layout.tsx`(Server Component)调用 `getBrandConfig()` 获取
|
||||||
|
* - 未配置时使用 `DEFAULT_BRAND_CONFIG` 默认值
|
||||||
|
*/
|
||||||
|
export function AuthLayout({ children, brand = DEFAULT_BRAND_CONFIG }: AuthLayoutProps) {
|
||||||
|
const { schoolName, logoUrl, testimonialQuote, testimonialAuthor } = brand
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex">
|
<div className="relative hidden h-full flex-col bg-muted p-10 text-white dark:border-r lg:flex">
|
||||||
<div className="absolute inset-0 bg-zinc-900" />
|
<div className="absolute inset-0 bg-zinc-900" />
|
||||||
<div className="relative z-20 flex items-center text-lg font-medium">
|
<div className="relative z-20 flex items-center text-lg font-medium">
|
||||||
<GraduationCap className="mr-2 h-6 w-6" />
|
{logoUrl ? (
|
||||||
Next_Edu
|
// eslint-disable-next-line @next/next/no-img-element -- brand logo from admin config
|
||||||
|
<img src={logoUrl} alt={schoolName} className="mr-2 h-6 w-6 object-contain" />
|
||||||
|
) : (
|
||||||
|
<GraduationCap className="mr-2 h-6 w-6" />
|
||||||
|
)}
|
||||||
|
{schoolName}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-20 mt-auto">
|
<div className="relative z-20 mt-auto">
|
||||||
<blockquote className="space-y-2">
|
<blockquote className="space-y-2">
|
||||||
<p className="text-lg">
|
<p className="text-lg">
|
||||||
“This platform has completely transformed how we deliver education to our students. The attention to detail and performance is unmatched.”
|
“{testimonialQuote}”
|
||||||
</p>
|
</p>
|
||||||
<footer className="text-sm">Sofia Davis</footer>
|
<footer className="text-sm">{testimonialAuthor}</footer>
|
||||||
</blockquote>
|
</blockquote>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,34 +4,71 @@ import * as React from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
import { useRouter, useSearchParams } from "next/navigation"
|
||||||
import { signIn } from "next-auth/react"
|
import { signIn } from "next-auth/react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import { Label } from "@/shared/components/ui/label"
|
import { Label } from "@/shared/components/ui/label"
|
||||||
|
import { Alert, AlertDescription } from "@/shared/components/ui/alert"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Loader2, Github, ShieldCheck } from "lucide-react"
|
import { Loader2, Github, ShieldCheck, AlertCircle } from "lucide-react"
|
||||||
import { preflightTwoFactorAction } from "@/modules/settings/actions-security"
|
import { preflightTwoFactorAction } from "@/modules/auth/actions"
|
||||||
|
|
||||||
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
|
type LoginFormProps = React.HTMLAttributes<HTMLDivElement>
|
||||||
|
|
||||||
|
/** 触发账户锁定提示的失败次数阈值(与后端 PASSWORD_RULES.maxFailedAttempts 对齐) */
|
||||||
|
const LOCK_HINT_THRESHOLD = 3
|
||||||
|
|
||||||
|
/** 简单邮箱格式校验(与服务端 Zod 校验对齐,仅用于前置拦截) */
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
|
|
||||||
export function LoginForm({ className, ...props }: LoginFormProps) {
|
export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||||
|
const t = useTranslations("auth.login")
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||||
const [requiresTwoFactor, setRequiresTwoFactor] = React.useState<boolean>(false)
|
const [requiresTwoFactor, setRequiresTwoFactor] = React.useState<boolean>(false)
|
||||||
const [totpCode, setTotpCode] = React.useState<string>("")
|
const [totpCode, setTotpCode] = React.useState<string>("")
|
||||||
const [error, setError] = React.useState<string>("")
|
const [error, setError] = React.useState<string>("")
|
||||||
|
const [failedAttempts, setFailedAttempts] = React.useState<number>(0)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
/** 清除错误并重置为初始状态(用于输入变更时) */
|
||||||
|
const clearError = React.useCallback(() => {
|
||||||
|
setError("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function validateCredentials(email: string, password: string): string | null {
|
||||||
|
if (!email.trim()) return t("errors.emailRequired")
|
||||||
|
if (!EMAIL_RE.test(email)) return t("errors.emailFormat")
|
||||||
|
if (!password) return t("errors.passwordRequired")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
async function onSubmit(event: React.SyntheticEvent) {
|
async function onSubmit(event: React.SyntheticEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setIsLoading(true)
|
|
||||||
setError("")
|
setError("")
|
||||||
|
|
||||||
const form = event.currentTarget as HTMLFormElement
|
const form = event.currentTarget as HTMLFormElement
|
||||||
const formData = new FormData(form)
|
const formData = new FormData(form)
|
||||||
const email = String(formData.get("email") ?? "")
|
const email = String(formData.get("email") ?? "").trim()
|
||||||
const password = String(formData.get("password") ?? "")
|
const password = String(formData.get("password") ?? "")
|
||||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
|
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard"
|
||||||
|
|
||||||
|
// 客户端校验:2FA 模式下校验 totpCode,否则校验 email/password
|
||||||
|
if (requiresTwoFactor) {
|
||||||
|
if (!totpCode.trim()) {
|
||||||
|
setError(t("errors.totpRequired"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const validationError = validateCredentials(email, password)
|
||||||
|
if (validationError) {
|
||||||
|
setError(validationError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
// 首次提交:检查是否需要 2FA
|
// 首次提交:检查是否需要 2FA
|
||||||
if (!requiresTwoFactor) {
|
if (!requiresTwoFactor) {
|
||||||
try {
|
try {
|
||||||
@@ -60,25 +97,39 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
router.push(result?.url ?? callbackUrl)
|
router.push(result?.url ?? callbackUrl)
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
// 2FA 验证码错误时保留 2FA 输入框,允许用户重新输入
|
// 失败计数:仅在非 2FA 模式下累计(2FA 失败不计入账户锁定计数)
|
||||||
|
const nextAttempts = requiresTwoFactor ? failedAttempts : failedAttempts + 1
|
||||||
|
setFailedAttempts(nextAttempts)
|
||||||
|
|
||||||
|
// 根据失败次数与上下文选择错误消息
|
||||||
if (requiresTwoFactor) {
|
if (requiresTwoFactor) {
|
||||||
setError("Invalid 2FA code. Please try again.")
|
setError(t("errors.invalid2fa"))
|
||||||
|
} else if (nextAttempts >= LOCK_HINT_THRESHOLD) {
|
||||||
|
// 达到阈值后切换为更严重的提示(账户可能已被锁定)
|
||||||
|
setError(t("errors.tooManyAttempts"))
|
||||||
} else {
|
} else {
|
||||||
setError("Invalid email or password.")
|
setError(t("errors.invalidCredentials"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 切换回普通登录模式时重置 2FA 相关状态与失败计数 */
|
||||||
|
function handleBackToLogin() {
|
||||||
|
setRequiresTwoFactor(false)
|
||||||
|
setTotpCode("")
|
||||||
|
setError("")
|
||||||
|
}
|
||||||
|
|
||||||
|
const showLockHint = failedAttempts >= LOCK_HINT_THRESHOLD && !requiresTwoFactor
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Welcome back
|
{t("title")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{requiresTwoFactor
|
{requiresTwoFactor ? t("subtitle2fa") : t("subtitle")}
|
||||||
? "Enter the 6-digit code from your authenticator app"
|
|
||||||
: "Enter your email to sign in to your account"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
@@ -86,7 +137,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
{!requiresTwoFactor ? (
|
{!requiresTwoFactor ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">{t("email")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
@@ -96,16 +147,17 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
onChange={clearError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label htmlFor="password">Password</Label>
|
<Label htmlFor="password">{t("password")}</Label>
|
||||||
<Link
|
<Link
|
||||||
href="/forgot-password"
|
href="/forgot-password"
|
||||||
className="text-sm font-medium text-muted-foreground hover:underline"
|
className="text-sm font-medium text-muted-foreground hover:underline"
|
||||||
>
|
>
|
||||||
Forgot password?
|
{t("forgotPassword")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@@ -114,6 +166,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
type="password"
|
type="password"
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
onChange={clearError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -121,7 +174,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="totpCode" className="flex items-center gap-1.5">
|
<Label htmlFor="totpCode" className="flex items-center gap-1.5">
|
||||||
<ShieldCheck className="h-4 w-4" />
|
<ShieldCheck className="h-4 w-4" />
|
||||||
2FA Code
|
{t("totpLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="totpCode"
|
id="totpCode"
|
||||||
@@ -132,35 +185,47 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
placeholder="123456"
|
placeholder="123456"
|
||||||
maxLength={8}
|
maxLength={8}
|
||||||
value={totpCode}
|
value={totpCode}
|
||||||
onChange={(e) => setTotpCode(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setTotpCode(e.target.value)
|
||||||
|
clearError()
|
||||||
|
}}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Enter your 6-digit authenticator code or an 8-character backup code.
|
{t("totpHint")}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={handleBackToLogin}
|
||||||
setRequiresTwoFactor(false)
|
|
||||||
setTotpCode("")
|
|
||||||
setError("")
|
|
||||||
}}
|
|
||||||
className="text-xs text-muted-foreground hover:underline justify-self-start"
|
className="text-xs text-muted-foreground hover:underline justify-self-start"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
← Back to login
|
← {t("backToLogin")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<Alert className="border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* 账户锁定提示:达到失败阈值后展示,引导用户重置密码 */}
|
||||||
|
{showLockHint && !error ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{t("errors.accountLockedHint")}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Button disabled={isLoading}>
|
<Button disabled={isLoading}>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
{requiresTwoFactor ? "Verify & Sign In" : "Sign In with Email"}
|
{requiresTwoFactor ? t("verifyAndSignIn") : t("signIn")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -170,7 +235,7 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="relative flex justify-center text-xs uppercase">
|
<div className="relative flex justify-center text-xs uppercase">
|
||||||
<span className="bg-background px-2 text-muted-foreground">
|
<span className="bg-background px-2 text-muted-foreground">
|
||||||
Or continue with
|
{t("orContinueWith")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,12 +248,12 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
|||||||
GitHub
|
GitHub
|
||||||
</Button>
|
</Button>
|
||||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||||
Don't have an account?{" "}
|
{t("noAccount")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/register"
|
href="/register"
|
||||||
className="underline underline-offset-4 hover:text-primary"
|
className="underline underline-offset-4 hover:text-primary"
|
||||||
>
|
>
|
||||||
Sign up
|
{t("register")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { Loader2 } from "lucide-react"
|
import { Loader2 } from "lucide-react"
|
||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
|
import type { RegisterResult } from "@/modules/auth/types"
|
||||||
|
|
||||||
const ADULT_AGE = 18
|
const ADULT_AGE = 18
|
||||||
|
|
||||||
@@ -35,10 +37,11 @@ function calcAge(birth: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
|
type RegisterFormProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
registerAction: (formData: FormData) => Promise<ActionState>
|
registerAction: (formData: FormData) => Promise<ActionState<RegisterResult>>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
|
export function RegisterForm({ className, registerAction, ...props }: RegisterFormProps) {
|
||||||
|
const t = useTranslations("auth.register")
|
||||||
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
const [isLoading, setIsLoading] = React.useState<boolean>(false)
|
||||||
const [birthDate, setBirthDate] = React.useState<string>("")
|
const [birthDate, setBirthDate] = React.useState<string>("")
|
||||||
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
|
const [agreedTerms, setAgreedTerms] = React.useState<boolean>(false)
|
||||||
@@ -53,15 +56,15 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (!agreedTerms) {
|
if (!agreedTerms) {
|
||||||
toast.error("请阅读并同意《隐私政策》和《用户协议》后再注册")
|
toast.error(t("toast.needAgreeTerms"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isMinor && !agreedGuardian) {
|
if (isMinor && !agreedGuardian) {
|
||||||
toast.error("未成年人注册须确认已获得监护人同意")
|
toast.error(t("toast.needGuardianConsent"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isMinor && !guardianRelation) {
|
if (isMinor && !guardianRelation) {
|
||||||
toast.error("请选择监护人与您的关系")
|
toast.error(t("toast.needGuardianRelation"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,14 +75,14 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
const res = await registerAction(formData)
|
const res = await registerAction(formData)
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success(res.message || "账户创建成功")
|
toast.success(res.message || t("toast.createSuccess"))
|
||||||
router.push("/login")
|
router.push("/login")
|
||||||
router.refresh()
|
router.refresh()
|
||||||
} else {
|
} else {
|
||||||
toast.error(res.message || "注册失败")
|
toast.error(res.message || t("toast.createFailed"))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("注册失败")
|
toast.error(t("toast.createFailed"))
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
@@ -88,19 +91,19 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
return (
|
return (
|
||||||
<div className={cn("grid gap-6", className)} {...props}>
|
<div className={cn("grid gap-6", className)} {...props}>
|
||||||
<div className="flex flex-col space-y-2 text-center">
|
<div className="flex flex-col space-y-2 text-center">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">创建账户</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">{t("createAccount")}</h1>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
填写以下信息以创建您的账户
|
{t("subtitleAlt")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={onSubmit}>
|
<form onSubmit={onSubmit}>
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="name">姓名</Label>
|
<Label htmlFor="name">{t("name")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="请输入姓名"
|
placeholder={t("namePlaceholder")}
|
||||||
type="text"
|
type="text"
|
||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
autoComplete="name"
|
autoComplete="name"
|
||||||
@@ -109,7 +112,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<Label htmlFor="email">{t("email")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
name="email"
|
name="email"
|
||||||
@@ -122,7 +125,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="password">密码</Label>
|
<Label htmlFor="password">{t("password")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="password"
|
id="password"
|
||||||
name="password"
|
name="password"
|
||||||
@@ -132,7 +135,7 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="birthDate">出生日期</Label>
|
<Label htmlFor="birthDate">{t("birthDate")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="birthDate"
|
id="birthDate"
|
||||||
name="birthDate"
|
name="birthDate"
|
||||||
@@ -142,47 +145,62 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
onChange={(e) => setBirthDate(e.target.value)}
|
onChange={(e) => setBirthDate(e.target.value)}
|
||||||
/>
|
/>
|
||||||
{age !== null && (
|
{age !== null && (
|
||||||
<p className="text-xs text-muted-foreground">当前年龄:{age} 岁</p>
|
<p className="text-xs text-muted-foreground">{t("currentAge", { age })}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* audit-P2-3: 邀请码(可选,提供时覆盖默认 student 角色) */}
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="invitationCode">{t("invitationCodeLabel")}</Label>
|
||||||
|
<Input
|
||||||
|
id="invitationCode"
|
||||||
|
name="invitationCode"
|
||||||
|
type="text"
|
||||||
|
placeholder={t("invitationCodePlaceholder")}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect="off"
|
||||||
|
disabled={isLoading}
|
||||||
|
maxLength={64}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isMinor && (
|
{isMinor && (
|
||||||
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
<div className="grid gap-4 rounded-md border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/50 dark:bg-amber-950/30">
|
||||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">
|
||||||
检测到您是未成年人,请填写监护人信息
|
{t("guardianSectionTitle")}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="guardianName">监护人姓名</Label>
|
<Label htmlFor="guardianName">{t("guardianName")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="guardianName"
|
id="guardianName"
|
||||||
name="guardianName"
|
name="guardianName"
|
||||||
placeholder="请输入监护人姓名"
|
placeholder={t("guardianNamePlaceholder")}
|
||||||
type="text"
|
type="text"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
required={isMinor}
|
required={isMinor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="guardianPhone">监护人电话</Label>
|
<Label htmlFor="guardianPhone">{t("guardianPhone")}</Label>
|
||||||
<Input
|
<Input
|
||||||
id="guardianPhone"
|
id="guardianPhone"
|
||||||
name="guardianPhone"
|
name="guardianPhone"
|
||||||
placeholder="请输入监护人手机号"
|
placeholder={t("guardianPhonePlaceholder")}
|
||||||
type="tel"
|
type="tel"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
required={isMinor}
|
required={isMinor}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="guardianRelation">监护人与您的关系</Label>
|
<Label htmlFor="guardianRelation">{t("guardianRelation")}</Label>
|
||||||
<Select value={guardianRelation} onValueChange={setGuardianRelation}>
|
<Select value={guardianRelation} onValueChange={setGuardianRelation}>
|
||||||
<SelectTrigger id="guardianRelation">
|
<SelectTrigger id="guardianRelation">
|
||||||
<SelectValue placeholder="请选择关系" />
|
<SelectValue placeholder={t("guardianRelationPlaceholder")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="父亲">父亲</SelectItem>
|
<SelectItem value="父亲">{t("guardianRelationFather")}</SelectItem>
|
||||||
<SelectItem value="母亲">母亲</SelectItem>
|
<SelectItem value="母亲">{t("guardianRelationMother")}</SelectItem>
|
||||||
<SelectItem value="其他法定监护人">其他法定监护人</SelectItem>
|
<SelectItem value="其他法定监护人">{t("guardianRelationOther")}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<input
|
<input
|
||||||
@@ -201,25 +219,30 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
onCheckedChange={(v) => setAgreedTerms(v === true)}
|
onCheckedChange={(v) => setAgreedTerms(v === true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
<input type="hidden" name="agreedTerms" value={agreedTerms ? "true" : "false"} />
|
||||||
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
|
<Label htmlFor="agreeTerms" className="text-sm leading-relaxed font-normal">
|
||||||
我已阅读并同意
|
{t.rich("agreeTermsRich", {
|
||||||
<Link
|
privacy: (chunks) => (
|
||||||
href="/privacy"
|
<Link
|
||||||
target="_blank"
|
href="/privacy"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
rel="noopener noreferrer"
|
||||||
>
|
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
||||||
《隐私政策》
|
>
|
||||||
</Link>
|
{chunks}
|
||||||
和
|
</Link>
|
||||||
<Link
|
),
|
||||||
href="/terms"
|
terms: (chunks) => (
|
||||||
target="_blank"
|
<Link
|
||||||
rel="noopener noreferrer"
|
href="/terms"
|
||||||
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
target="_blank"
|
||||||
>
|
rel="noopener noreferrer"
|
||||||
《用户协议》
|
className="mx-1 text-primary underline underline-offset-4 hover:opacity-80"
|
||||||
</Link>
|
>
|
||||||
|
{chunks}
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -231,8 +254,9 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
onCheckedChange={(v) => setAgreedGuardian(v === true)}
|
onCheckedChange={(v) => setAgreedGuardian(v === true)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
|
<input type="hidden" name="agreedGuardian" value={agreedGuardian ? "true" : "false"} />
|
||||||
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
|
<Label htmlFor="agreeGuardian" className="text-sm leading-relaxed font-normal">
|
||||||
我确认已获得监护人同意使用本服务
|
{t("agreeGuardianLabel")}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -241,17 +265,17 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
)}
|
)}
|
||||||
创建账户
|
{t("createAccount")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||||
已有账户?{" "}
|
{t("alreadyHaveAccount")}{" "}
|
||||||
<Link
|
<Link
|
||||||
href="/login"
|
href="/login"
|
||||||
className="underline underline-offset-4 hover:text-primary"
|
className="underline underline-offset-4 hover:text-primary"
|
||||||
>
|
>
|
||||||
立即登录
|
{t("loginNow")}
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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"
|
"use client"
|
||||||
|
|
||||||
import { useMemo, useState } from "react"
|
import { useTranslations } from "next-intl"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
|
import { Files, Search, Trash2, HardDrive, FileWarning } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
|
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -15,13 +16,14 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/shared/components/ui/select"
|
} from "@/shared/components/ui/select"
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { WidgetBoundary } from "@/shared/components/widget-boundary"
|
||||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
|
||||||
import { formatDate } from "@/shared/lib/utils"
|
import { formatDate } from "@/shared/lib/utils"
|
||||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||||
|
|
||||||
import { FileIcon } from "./file-icon"
|
import { FileIcon } from "./file-icon"
|
||||||
import { FileUpload } from "./file-upload"
|
|
||||||
import { FilePreviewDialog } from "./file-preview-dialog"
|
import { FilePreviewDialog } from "./file-preview-dialog"
|
||||||
|
import { FileUpload } from "./file-upload"
|
||||||
|
import { useFileBatchOperations } from "../hooks/use-file-batch-operations"
|
||||||
import type { FileAttachment, FileStats } from "../types"
|
import type { FileAttachment, FileStats } from "../types"
|
||||||
|
|
||||||
interface AdminFilesViewProps {
|
interface AdminFilesViewProps {
|
||||||
@@ -29,174 +31,159 @@ interface AdminFilesViewProps {
|
|||||||
stats: FileStats
|
stats: FileStats
|
||||||
}
|
}
|
||||||
|
|
||||||
// 文件类型分组选项
|
interface TypeOption {
|
||||||
const TYPE_OPTIONS: Array<{ value: string; label: string }> = [
|
value: string
|
||||||
{ value: "all", label: "All Types" },
|
labelKey:
|
||||||
{ value: "image/", label: "Images" },
|
| "allTypes"
|
||||||
{ value: "application/pdf", label: "PDF" },
|
| "images"
|
||||||
{ value: "application/msword", label: "Word" },
|
| "pdf"
|
||||||
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", label: "Word (docx)" },
|
| "word"
|
||||||
{ value: "application/vnd.ms-excel", label: "Excel" },
|
| "wordDocx"
|
||||||
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", label: "Excel (xlsx)" },
|
| "excel"
|
||||||
{ value: "application/vnd.ms-powerpoint", label: "PowerPoint" },
|
| "excelXlsx"
|
||||||
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", label: "PowerPoint (pptx)" },
|
| "powerpoint"
|
||||||
{ value: "text/", label: "Text" },
|
| "powerpointPptx"
|
||||||
{ value: "application/zip", label: "ZIP" },
|
| "text"
|
||||||
]
|
| "zip"
|
||||||
|
}
|
||||||
|
|
||||||
export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
const TYPE_OPTIONS = [
|
||||||
|
{ value: "all", labelKey: "allTypes" },
|
||||||
|
{ value: "image/", labelKey: "images" },
|
||||||
|
{ value: "application/pdf", labelKey: "pdf" },
|
||||||
|
{ value: "application/msword", labelKey: "word" },
|
||||||
|
{ value: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", labelKey: "wordDocx" },
|
||||||
|
{ value: "application/vnd.ms-excel", labelKey: "excel" },
|
||||||
|
{ value: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", labelKey: "excelXlsx" },
|
||||||
|
{ value: "application/vnd.ms-powerpoint", labelKey: "powerpoint" },
|
||||||
|
{ value: "application/vnd.openxmlformats-officedocument.presentationml.presentation", labelKey: "powerpointPptx" },
|
||||||
|
{ value: "text/", labelKey: "text" },
|
||||||
|
{ value: "application/zip", labelKey: "zip" },
|
||||||
|
] as const satisfies TypeOption[]
|
||||||
|
|
||||||
|
type TypeLabelKey = TypeOption["labelKey"]
|
||||||
|
|
||||||
|
export function AdminFilesView({
|
||||||
|
files,
|
||||||
|
stats,
|
||||||
|
}: AdminFilesViewProps): React.ReactElement {
|
||||||
|
const t = useTranslations("files.admin")
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("all")
|
|
||||||
const [search, setSearch] = useState<string>("")
|
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
|
||||||
const [deleting, setDeleting] = useState(false)
|
|
||||||
|
|
||||||
// 客户端二次筛选(与 server 端筛选互补,提升交互即时性)
|
const renderTypeLabel = (key: TypeLabelKey): string => {
|
||||||
const filteredFiles = useMemo(() => {
|
switch (key) {
|
||||||
return files.filter((f) => {
|
case "allTypes": return t("filter.allTypes")
|
||||||
if (typeFilter !== "all") {
|
case "images": return t("filter.images")
|
||||||
if (typeFilter.endsWith("/")) {
|
case "pdf": return t("filter.pdf")
|
||||||
if (!f.mimeType.startsWith(typeFilter)) return false
|
case "word": return t("filter.word")
|
||||||
} else if (f.mimeType !== typeFilter) {
|
case "wordDocx": return t("filter.wordDocx")
|
||||||
return false
|
case "excel": return t("filter.excel")
|
||||||
}
|
case "excelXlsx": return t("filter.excelXlsx")
|
||||||
}
|
case "powerpoint": return t("filter.powerpoint")
|
||||||
if (search.trim()) {
|
case "powerpointPptx": return t("filter.powerpointPptx")
|
||||||
const kw = search.trim().toLowerCase()
|
case "text": return t("filter.text")
|
||||||
if (
|
case "zip": return t("filter.zip")
|
||||||
!f.originalName.toLowerCase().includes(kw) &&
|
|
||||||
!f.filename.toLowerCase().includes(kw)
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
}, [files, typeFilter, search])
|
|
||||||
|
|
||||||
const allSelected = filteredFiles.length > 0 && selectedIds.size === filteredFiles.length
|
|
||||||
const someSelected = selectedIds.size > 0 && !allSelected
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
if (allSelected) {
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
} else {
|
|
||||||
setSelectedIds(new Set(filteredFiles.map((f) => f.id)))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleOne = (id: string) => {
|
const {
|
||||||
setSelectedIds((prev) => {
|
selectedIds,
|
||||||
const next = new Set(prev)
|
typeFilter,
|
||||||
if (next.has(id)) next.delete(id)
|
search,
|
||||||
else next.add(id)
|
deleting,
|
||||||
return next
|
setTypeFilter,
|
||||||
})
|
setSearch,
|
||||||
}
|
filteredFiles,
|
||||||
|
allSelected,
|
||||||
const handleUploaded = () => {
|
someSelected,
|
||||||
router.refresh()
|
toggleAll,
|
||||||
}
|
toggleOne,
|
||||||
|
handleBatchDelete,
|
||||||
const handleDeleted = () => {
|
} = useFileBatchOperations({
|
||||||
router.refresh()
|
files,
|
||||||
setSelectedIds(new Set())
|
onAfterDelete: () => router.refresh(),
|
||||||
}
|
})
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
|
||||||
if (selectedIds.size === 0) return
|
|
||||||
const ids = Array.from(selectedIds)
|
|
||||||
setDeleting(true)
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/files/batch-delete", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ ids }),
|
|
||||||
})
|
|
||||||
const body = await res.json().catch(() => null)
|
|
||||||
if (!res.ok || !body?.success) {
|
|
||||||
toast.error(body?.message || "Failed to delete files")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
toast.success(`Deleted ${body.deletedCount} file(s)`)
|
|
||||||
handleDeleted()
|
|
||||||
} catch {
|
|
||||||
toast.error("Failed to delete files")
|
|
||||||
} finally {
|
|
||||||
setDeleting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col space-y-6 p-8">
|
<div className="flex h-full flex-col space-y-6 p-8">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
<h2 className="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
<Files className="h-6 w-6" />
|
<Files className="h-6 w-6" aria-hidden="true" />
|
||||||
Files
|
{t("title")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||||
Upload and manage all files in the system.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
<WidgetBoundary title={t("stats.totalFiles")} skeletonHeight={120}>
|
||||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<div className="rounded-md border p-4">
|
<div className="rounded-md border p-4">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<Files className="h-3.5 w-3.5" />
|
|
||||||
Total Files
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border p-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<HardDrive className="h-3.5 w-3.5" />
|
|
||||||
Total Size
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
|
|
||||||
</div>
|
|
||||||
{stats.byType.slice(0, 2).map((t) => (
|
|
||||||
<div key={t.mimeType} className="rounded-md border p-4">
|
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
<FileIcon mimeType={t.mimeType} className="h-3.5 w-3.5" />
|
<Files className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
<span className="truncate" title={t.mimeType}>{t.mimeType}</span>
|
{t("stats.totalFiles")}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-2xl font-bold">{t.count}</p>
|
<p className="mt-1 text-2xl font-bold">{stats.totalCount}</p>
|
||||||
<p className="text-xs text-muted-foreground">{formatFileSize(t.size)}</p>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="rounded-md border p-4">
|
||||||
</div>
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<HardDrive className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
|
{t("stats.totalSize")}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{formatFileSize(stats.totalSize)}</p>
|
||||||
|
</div>
|
||||||
|
{stats.byType.slice(0, 2).map((typeStat) => (
|
||||||
|
<div key={typeStat.mimeType} className="rounded-md border p-4">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<FileIcon mimeType={typeStat.mimeType} className="h-3.5 w-3.5" />
|
||||||
|
<span className="truncate" title={typeStat.mimeType}>
|
||||||
|
{typeStat.mimeType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-2xl font-bold">{typeStat.count}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(typeStat.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</WidgetBoundary>
|
||||||
|
|
||||||
<FileUpload onUploaded={handleUploaded} />
|
<WidgetBoundary title={t("title")} skeletonHeight={160}>
|
||||||
|
<FileUpload onUploaded={() => router.refresh()} />
|
||||||
|
</WidgetBoundary>
|
||||||
|
|
||||||
{/* 筛选与批量操作工具栏 */}
|
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||||
<SelectTrigger className="w-full sm:w-[200px]">
|
<SelectTrigger className="w-full sm:w-[200px]">
|
||||||
<SelectValue placeholder="Filter by type" />
|
<SelectValue placeholder={t("filter.byType")} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{TYPE_OPTIONS.map((opt) => (
|
{TYPE_OPTIONS.map((opt) => (
|
||||||
<SelectItem key={opt.value} value={opt.value}>
|
<SelectItem key={opt.value} value={opt.value}>
|
||||||
{opt.label}
|
{renderTypeLabel(opt.labelKey)}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
<Search
|
||||||
|
className="absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search by file name..."
|
placeholder={t("filter.search")}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="pl-8"
|
className="pl-8"
|
||||||
|
aria-label={t("filter.search")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{selectedIds.size > 0 ? (
|
{selectedIds.size > 0 ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant="secondary">{selectedIds.size} selected</Badge>
|
<Badge variant="secondary">
|
||||||
|
{t("selection.selected", { count: selectedIds.size })}
|
||||||
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -204,86 +191,91 @@ export function AdminFilesView({ files, stats }: AdminFilesViewProps) {
|
|||||||
disabled={deleting}
|
disabled={deleting}
|
||||||
onClick={() => void handleBatchDelete()}
|
onClick={() => void handleBatchDelete()}
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
{deleting ? "Deleting..." : "Delete Selected"}
|
{deleting ? t("selection.deleting") : t("selection.deleteSelected")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 文件列表 */}
|
<WidgetBoundary title={t("title")} skeletonHeight={400}>
|
||||||
{filteredFiles.length === 0 ? (
|
{filteredFiles.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No files found"
|
title={t("empty.title")}
|
||||||
description="Try adjusting your filters or upload a new file."
|
description={t("empty.description")}
|
||||||
icon={FileWarning}
|
icon={FileWarning}
|
||||||
className="h-auto border-none shadow-none"
|
className="h-auto border-none shadow-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
<div className="flex items-center gap-3 border-b bg-muted/40 px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={toggleAll}
|
||||||
aria-label="Select all"
|
aria-label={t("columns.file")}
|
||||||
/>
|
/>
|
||||||
<span className="flex-1">File</span>
|
<span className="flex-1">{t("columns.file")}</span>
|
||||||
<span className="hidden w-24 sm:block">Size</span>
|
<span className="hidden w-24 sm:block">{t("columns.size")}</span>
|
||||||
<span className="hidden w-32 md:block">Type</span>
|
<span className="hidden w-32 md:block">{t("columns.type")}</span>
|
||||||
<span className="hidden w-32 md:block">Uploaded</span>
|
<span className="hidden w-32 md:block">{t("columns.uploaded")}</span>
|
||||||
<span className="w-24 text-right">Actions</span>
|
<span className="w-24 text-right">{t("columns.actions")}</span>
|
||||||
</div>
|
</div>
|
||||||
<ul className="divide-y">
|
<ul className="divide-y">
|
||||||
{filteredFiles.map((file) => {
|
{filteredFiles.map((file) => {
|
||||||
const checked = selectedIds.has(file.id)
|
const checked = selectedIds.has(file.id)
|
||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
|
className="flex items-center gap-3 p-3 transition-colors hover:bg-accent/40"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={() => toggleOne(file.id)}
|
onCheckedChange={() => toggleOne(file.id)}
|
||||||
aria-label={`Select ${file.originalName}`}
|
aria-label={`Select ${file.originalName}`}
|
||||||
/>
|
|
||||||
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<a
|
|
||||||
href={file.url ?? "#"}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="truncate text-sm font-medium hover:underline"
|
|
||||||
title={file.originalName}
|
|
||||||
>
|
|
||||||
{file.originalName}
|
|
||||||
</a>
|
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
|
|
||||||
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
|
|
||||||
{formatFileSize(file.size)}
|
|
||||||
</span>
|
|
||||||
<span className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block" title={file.mimeType}>
|
|
||||||
{file.mimeType}
|
|
||||||
</span>
|
|
||||||
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
|
|
||||||
{formatDate(file.createdAt, "zh-CN")}
|
|
||||||
</span>
|
|
||||||
<div className="flex w-24 shrink-0 justify-end gap-1">
|
|
||||||
<FilePreviewDialog
|
|
||||||
file={file}
|
|
||||||
triggerLabel=""
|
|
||||||
triggerVariant="ghost"
|
|
||||||
triggerSize="icon"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<FileIcon mimeType={file.mimeType} className="h-6 w-6" />
|
||||||
</li>
|
<div className="min-w-0 flex-1">
|
||||||
)
|
<a
|
||||||
})}
|
href={file.url ?? "#"}
|
||||||
</ul>
|
target="_blank"
|
||||||
</div>
|
rel="noopener noreferrer"
|
||||||
)}
|
className="truncate text-sm font-medium hover:underline"
|
||||||
|
title={file.originalName}
|
||||||
|
aria-label={`${t("columns.file")}: ${file.originalName}`}
|
||||||
|
>
|
||||||
|
{file.originalName}
|
||||||
|
</a>
|
||||||
|
<p className="mt-0.5 text-xs text-muted-foreground sm:hidden">
|
||||||
|
{formatFileSize(file.size)} · {formatDate(file.createdAt, "zh-CN")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="hidden w-24 shrink-0 text-xs text-muted-foreground sm:block">
|
||||||
|
{formatFileSize(file.size)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="hidden w-32 shrink-0 truncate text-xs text-muted-foreground md:block"
|
||||||
|
title={file.mimeType}
|
||||||
|
>
|
||||||
|
{file.mimeType}
|
||||||
|
</span>
|
||||||
|
<span className="hidden w-32 shrink-0 text-xs text-muted-foreground md:block">
|
||||||
|
{formatDate(file.createdAt, "zh-CN")}
|
||||||
|
</span>
|
||||||
|
<div className="flex w-24 shrink-0 justify-end gap-1">
|
||||||
|
<FilePreviewDialog
|
||||||
|
file={file}
|
||||||
|
triggerLabel=""
|
||||||
|
triggerVariant="ghost"
|
||||||
|
triggerSize="icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</WidgetBoundary>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
import { Eye } from "lucide-react"
|
import { Eye } from "lucide-react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -26,17 +27,26 @@ interface FilePreviewDialogProps {
|
|||||||
export function FilePreviewDialog({
|
export function FilePreviewDialog({
|
||||||
file,
|
file,
|
||||||
trigger,
|
trigger,
|
||||||
triggerLabel = "Preview",
|
triggerLabel,
|
||||||
triggerVariant = "outline",
|
triggerVariant = "outline",
|
||||||
triggerSize = "sm",
|
triggerSize = "sm",
|
||||||
}: FilePreviewDialogProps) {
|
}: FilePreviewDialogProps): React.ReactElement {
|
||||||
|
const t = useTranslations("files.preview")
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
{trigger ?? (
|
{trigger ?? (
|
||||||
<Button type="button" variant={triggerVariant} size={triggerSize}>
|
<Button type="button" variant={triggerVariant} size={triggerSize}>
|
||||||
<Eye className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"} />
|
<Eye
|
||||||
{triggerLabel ? <span>{triggerLabel}</span> : <span className="sr-only">Preview</span>}
|
className={triggerLabel ? "mr-2 h-4 w-4" : "h-4 w-4"}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
{triggerLabel ? (
|
||||||
|
<span>{triggerLabel}</span>
|
||||||
|
) : (
|
||||||
|
<span className="sr-only">{t("trigger")}</span>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -44,7 +54,7 @@ export function FilePreviewDialog({
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
|
<DialogTitle className="truncate">{file.originalName}</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
File preview · {file.mimeType}
|
{t("title")} · {file.mimeType}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="max-h-[75vh] overflow-auto">
|
<div className="max-h-[75vh] overflow-auto">
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useTranslations } from "next-intl"
|
||||||
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
|
import { Download, ZoomIn, ZoomOut, FileText } from "lucide-react"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { FileIcon } from "./file-icon"
|
import { FileIcon } from "./file-icon"
|
||||||
import { formatFileSize } from "@/shared/lib/file-storage"
|
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||||
|
import { useFilePreview, useImageZoom } from "../hooks/use-file-preview"
|
||||||
import type { FileAttachment } from "../types"
|
import type { FileAttachment } from "../types"
|
||||||
|
|
||||||
interface FilePreviewProps {
|
interface FilePreviewProps {
|
||||||
@@ -42,7 +43,8 @@ function classify(mimeType: string): PreviewKind {
|
|||||||
return "other"
|
return "other"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FilePreview({ file, className }: FilePreviewProps) {
|
export function FilePreview({ file, className }: FilePreviewProps): React.ReactElement {
|
||||||
|
const t = useTranslations("files.preview")
|
||||||
const kind = classify(file.mimeType)
|
const kind = classify(file.mimeType)
|
||||||
const url = file.url ?? "#"
|
const url = file.url ?? "#"
|
||||||
|
|
||||||
@@ -66,9 +68,10 @@ export function FilePreview({ file, className }: FilePreviewProps) {
|
|||||||
download={file.originalName}
|
download={file.originalName}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`${t("download")} ${file.originalName}`}
|
||||||
>
|
>
|
||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
Download
|
{t("download")}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,7 +89,7 @@ function PreviewBody({
|
|||||||
kind: PreviewKind
|
kind: PreviewKind
|
||||||
file: FileAttachment
|
file: FileAttachment
|
||||||
url: string
|
url: string
|
||||||
}) {
|
}): React.ReactElement {
|
||||||
if (kind === "image") {
|
if (kind === "image") {
|
||||||
return <ImagePreview url={url} alt={file.originalName} />
|
return <ImagePreview url={url} alt={file.originalName} />
|
||||||
}
|
}
|
||||||
@@ -96,6 +99,7 @@ function PreviewBody({
|
|||||||
<iframe
|
<iframe
|
||||||
src={url}
|
src={url}
|
||||||
title={file.originalName}
|
title={file.originalName}
|
||||||
|
aria-label={`PDF preview: ${file.originalName}`}
|
||||||
className="h-[70vh] w-full rounded-md border"
|
className="h-[70vh] w-full rounded-md border"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -105,35 +109,12 @@ function PreviewBody({
|
|||||||
return <TextPreview url={url} />
|
return <TextPreview url={url} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Office / other: show info card + download button
|
return <OtherPreview kind={kind} file={file} url={url} />
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
|
||||||
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
|
|
||||||
<p className="mt-3 text-sm font-medium">
|
|
||||||
{kind === "office" ? "Office file preview not available" : "Preview not available"}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
{kind === "office"
|
|
||||||
? "Download the file to view its contents in your Office application."
|
|
||||||
: "Download the file to view its contents."}
|
|
||||||
</p>
|
|
||||||
<Button asChild variant="outline" size="sm" className="mt-4">
|
|
||||||
<a
|
|
||||||
href={url}
|
|
||||||
download={file.originalName}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Download className="mr-2 h-4 w-4" />
|
|
||||||
Download
|
|
||||||
</a>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
function ImagePreview({ url, alt }: { url: string; alt: string }): React.ReactElement {
|
||||||
const [zoom, setZoom] = useState(1)
|
const t = useTranslations("files.preview")
|
||||||
|
const { zoom, zoomIn, zoomOut, canZoomIn, canZoomOut } = useImageZoom()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -143,13 +124,13 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => setZoom((z) => Math.max(0.25, z - 0.25))}
|
onClick={zoomOut}
|
||||||
disabled={zoom <= 0.25}
|
disabled={!canZoomOut}
|
||||||
|
aria-label={t("zoomOut")}
|
||||||
>
|
>
|
||||||
<ZoomOut className="h-4 w-4" />
|
<ZoomOut className="h-4 w-4" aria-hidden="true" />
|
||||||
<span className="sr-only">Zoom out</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
<span className="text-xs text-muted-foreground w-12 text-center">
|
<span className="w-12 text-center text-xs text-muted-foreground" aria-live="polite">
|
||||||
{Math.round(zoom * 100)}%
|
{Math.round(zoom * 100)}%
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<Button
|
||||||
@@ -157,14 +138,17 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => setZoom((z) => Math.min(4, z + 0.25))}
|
onClick={zoomIn}
|
||||||
disabled={zoom >= 4}
|
disabled={!canZoomIn}
|
||||||
|
aria-label={t("zoomIn")}
|
||||||
>
|
>
|
||||||
<ZoomIn className="h-4 w-4" />
|
<ZoomIn className="h-4 w-4" aria-hidden="true" />
|
||||||
<span className="sr-only">Zoom in</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-auto rounded-md border bg-muted/30 p-2" style={{ maxHeight: "70vh" }}>
|
<div
|
||||||
|
className="overflow-auto rounded-md border bg-muted/30 p-2"
|
||||||
|
style={{ maxHeight: "70vh" }}
|
||||||
|
>
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
@@ -177,34 +161,23 @@ function ImagePreview({ url, alt }: { url: string; alt: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TextPreview({ url }: { url: string }) {
|
function TextPreview({ url }: { url: string }): React.ReactElement {
|
||||||
const [content, setContent] = useState<string | null>(null)
|
const t = useTranslations("files.preview.text")
|
||||||
const [error, setError] = useState<string | null>(null)
|
const { content, error, loading, load } = useFilePreview()
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
|
|
||||||
const load = async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const res = await fetch(url)
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
||||||
const text = await res.text()
|
|
||||||
setContent(text)
|
|
||||||
} catch (e) {
|
|
||||||
setError(e instanceof Error ? e.message : "Failed to load text")
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content === null && !error && !loading) {
|
if (content === null && !error && !loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||||
<FileText className="h-12 w-12 text-muted-foreground" />
|
<FileText className="h-12 w-12 text-muted-foreground" aria-hidden="true" />
|
||||||
<p className="mt-3 text-sm font-medium">Text file</p>
|
<p className="mt-3 text-sm font-medium">{t("title")}</p>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">Click below to load the content.</p>
|
<p className="mt-1 text-xs text-muted-foreground">{t("hint")}</p>
|
||||||
<Button variant="outline" size="sm" className="mt-4" onClick={() => void load()}>
|
<Button
|
||||||
Load preview
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="mt-4"
|
||||||
|
onClick={() => void load(url)}
|
||||||
|
>
|
||||||
|
{t("load")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -212,16 +185,29 @@ function TextPreview({ url }: { url: string }) {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground">
|
<div
|
||||||
Loading...
|
role="status"
|
||||||
|
className="rounded-md border bg-muted/30 p-12 text-center text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("loading")}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
|
<div
|
||||||
Failed to load text: {error}
|
role="alert"
|
||||||
|
className="flex flex-col items-center justify-center gap-3 rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive"
|
||||||
|
>
|
||||||
|
<span>{t("error", { message: error })}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void load(url)}
|
||||||
|
>
|
||||||
|
{t("load")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -232,3 +218,38 @@ function TextPreview({ url }: { url: string }) {
|
|||||||
</pre>
|
</pre>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function OtherPreview({
|
||||||
|
kind,
|
||||||
|
file,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
kind: PreviewKind
|
||||||
|
file: FileAttachment
|
||||||
|
url: string
|
||||||
|
}): React.ReactElement {
|
||||||
|
const t = useTranslations("files.preview")
|
||||||
|
const isOffice = kind === "office"
|
||||||
|
const title = isOffice ? t("office.title") : t("other.title")
|
||||||
|
const hint = isOffice ? t("office.hint") : t("other.hint")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-md border border-dashed p-12 text-center">
|
||||||
|
<FileIcon mimeType={file.mimeType} className="h-12 w-12" />
|
||||||
|
<p className="mt-3 text-sm font-medium">{title}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
|
||||||
|
<Button asChild variant="outline" size="sm" className="mt-4">
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
download={file.originalName}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`${t("download")} ${file.originalName}`}
|
||||||
|
>
|
||||||
|
<Download className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||||
|
{t("download")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useCallback, useRef, useState } from "react"
|
import { useTranslations } from "next-intl"
|
||||||
import { UploadCloud, X } from "lucide-react"
|
import { UploadCloud, X } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Progress } from "@/shared/components/ui/progress"
|
import { Progress } from "@/shared/components/ui/progress"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import {
|
import { formatFileSize } from "@/shared/lib/file-storage"
|
||||||
ALLOWED_MIME_TYPES,
|
|
||||||
formatFileSize,
|
|
||||||
MAX_FILE_SIZE,
|
|
||||||
} from "@/shared/lib/file-storage"
|
|
||||||
import { FileIcon } from "./file-icon"
|
import { FileIcon } from "./file-icon"
|
||||||
|
import { useFileUpload } from "../hooks/use-file-upload"
|
||||||
import type { FileTargetType, FileUploadResult } from "../types"
|
import type { FileTargetType, FileUploadResult } from "../types"
|
||||||
|
|
||||||
interface FileUploadProps {
|
interface FileUploadProps {
|
||||||
@@ -23,146 +20,31 @@ interface FileUploadProps {
|
|||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UploadTask {
|
|
||||||
file: File
|
|
||||||
progress: number
|
|
||||||
status: "uploading" | "success" | "error"
|
|
||||||
message?: string
|
|
||||||
result?: FileUploadResult
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACCEPT_ATTR = (ALLOWED_MIME_TYPES as readonly string[]).join(",")
|
|
||||||
|
|
||||||
export function FileUpload({
|
export function FileUpload({
|
||||||
targetType,
|
targetType,
|
||||||
targetId,
|
targetId,
|
||||||
onUploaded,
|
onUploaded,
|
||||||
multiple = true,
|
multiple = true,
|
||||||
className,
|
className,
|
||||||
}: FileUploadProps) {
|
}: FileUploadProps): React.ReactElement {
|
||||||
const inputRef = useRef<HTMLInputElement>(null)
|
const t = useTranslations("files.upload")
|
||||||
const [isDragging, setIsDragging] = useState(false)
|
const {
|
||||||
const [tasks, setTasks] = useState<UploadTask[]>([])
|
tasks,
|
||||||
|
isDragging,
|
||||||
const validateFile = (file: File): string | null => {
|
inputRef,
|
||||||
if (file.size === 0) return "File is empty"
|
setIsDragging,
|
||||||
if (file.size > MAX_FILE_SIZE) return "File size exceeds 10MB limit"
|
handleFiles,
|
||||||
if (!(ALLOWED_MIME_TYPES as readonly string[]).includes(file.type)) {
|
removeTask,
|
||||||
return `File type ${file.type || "unknown"} is not allowed`
|
acceptAttr,
|
||||||
}
|
maxFileSize,
|
||||||
return null
|
} = useFileUpload({ targetType, targetId, multiple, onUploaded })
|
||||||
}
|
|
||||||
|
|
||||||
const uploadOne = useCallback(
|
|
||||||
async (file: File): Promise<void> => {
|
|
||||||
const taskId = `${file.name}-${file.size}-${Date.now()}`
|
|
||||||
setTasks((prev) => [
|
|
||||||
...prev,
|
|
||||||
{ file, progress: 0, status: "uploading" },
|
|
||||||
])
|
|
||||||
|
|
||||||
const validationError = validateFile(file)
|
|
||||||
if (validationError) {
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((t) =>
|
|
||||||
t.file === file
|
|
||||||
? { ...t, status: "error", message: validationError, progress: 100 }
|
|
||||||
: t
|
|
||||||
)
|
|
||||||
)
|
|
||||||
toast.error(`${file.name}: ${validationError}`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append("file", file)
|
|
||||||
if (targetType) formData.append("targetType", targetType)
|
|
||||||
if (targetId) formData.append("targetId", targetId)
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest()
|
|
||||||
const result = await new Promise<FileUploadResult>((resolve, reject) => {
|
|
||||||
xhr.open("POST", "/api/upload")
|
|
||||||
xhr.upload.onprogress = (e) => {
|
|
||||||
if (e.lengthComputable) {
|
|
||||||
const pct = Math.round((e.loaded / e.total) * 100)
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((t) =>
|
|
||||||
t.file === file ? { ...t, progress: pct } : t
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xhr.onload = () => {
|
|
||||||
try {
|
|
||||||
const body = JSON.parse(xhr.responseText)
|
|
||||||
if (xhr.status >= 200 && xhr.status < 300 && body.success) {
|
|
||||||
resolve(body as FileUploadResult)
|
|
||||||
} else {
|
|
||||||
reject(new Error(body.message || "Upload failed"))
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
reject(new Error("Invalid response"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
xhr.onerror = () => reject(new Error("Network error"))
|
|
||||||
xhr.send(formData)
|
|
||||||
})
|
|
||||||
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((t) =>
|
|
||||||
t.file === file
|
|
||||||
? { ...t, status: "success", progress: 100, result }
|
|
||||||
: t
|
|
||||||
)
|
|
||||||
)
|
|
||||||
onUploaded?.(result)
|
|
||||||
toast.success(`${file.name} uploaded`)
|
|
||||||
void taskId
|
|
||||||
} catch (e) {
|
|
||||||
const message = e instanceof Error ? e.message : "Upload failed"
|
|
||||||
setTasks((prev) =>
|
|
||||||
prev.map((t) =>
|
|
||||||
t.file === file ? { ...t, status: "error", message } : t
|
|
||||||
)
|
|
||||||
)
|
|
||||||
toast.error(`${file.name}: ${message}`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[targetType, targetId, onUploaded]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
|
||||||
(fileList: FileList | null) => {
|
|
||||||
if (!fileList || fileList.length === 0) return
|
|
||||||
const files = Array.from(fileList)
|
|
||||||
if (!multiple) {
|
|
||||||
void uploadOne(files[0])
|
|
||||||
} else {
|
|
||||||
files.forEach((f) => void uploadOne(f))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[uploadOne, multiple]
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleDrop = useCallback(
|
|
||||||
(e: React.DragEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault()
|
|
||||||
setIsDragging(false)
|
|
||||||
handleFiles(e.dataTransfer.files)
|
|
||||||
},
|
|
||||||
[handleFiles]
|
|
||||||
)
|
|
||||||
|
|
||||||
const removeTask = (task: UploadTask) => {
|
|
||||||
setTasks((prev) => prev.filter((t) => t !== task))
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-4", className)}>
|
<div className={cn("space-y-4", className)}>
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
aria-describedby="file-upload-hint"
|
||||||
onClick={() => inputRef.current?.click()}
|
onClick={() => inputRef.current?.click()}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
@@ -175,7 +57,11 @@ export function FileUpload({
|
|||||||
setIsDragging(true)
|
setIsDragging(true)
|
||||||
}}
|
}}
|
||||||
onDragLeave={() => setIsDragging(false)}
|
onDragLeave={() => setIsDragging(false)}
|
||||||
onDrop={handleDrop}
|
onDrop={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsDragging(false)
|
||||||
|
handleFiles(e.dataTransfer.files)
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
"flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 text-center transition-colors",
|
||||||
isDragging
|
isDragging
|
||||||
@@ -183,19 +69,18 @@ export function FileUpload({
|
|||||||
: "border-input hover:border-primary/50 hover:bg-accent/50"
|
: "border-input hover:border-primary/50 hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<UploadCloud className="h-10 w-10 text-muted-foreground" />
|
<UploadCloud className="h-10 w-10 text-muted-foreground" aria-hidden="true" />
|
||||||
<p className="mt-2 text-sm font-medium">
|
<p className="mt-2 text-sm font-medium">{t("title")}</p>
|
||||||
Click to upload or drag and drop
|
<p id="file-upload-hint" className="mt-1 text-xs text-muted-foreground">
|
||||||
</p>
|
{t("hint", { size: formatFileSize(maxFileSize) })}
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
|
||||||
Images, PDF, Word, Excel, PPT, Text, ZIP / RAR · up to {formatFileSize(MAX_FILE_SIZE)}
|
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept={ACCEPT_ATTR}
|
accept={acceptAttr}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
|
aria-label={t("title")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
handleFiles(e.target.files)
|
handleFiles(e.target.files)
|
||||||
e.target.value = ""
|
e.target.value = ""
|
||||||
@@ -204,7 +89,7 @@ export function FileUpload({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{tasks.length > 0 ? (
|
{tasks.length > 0 ? (
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2" aria-live="polite">
|
||||||
{tasks.map((task, idx) => (
|
{tasks.map((task, idx) => (
|
||||||
<li
|
<li
|
||||||
key={`${task.file.name}-${idx}`}
|
key={`${task.file.name}-${idx}`}
|
||||||
@@ -221,13 +106,17 @@ export function FileUpload({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{task.status === "uploading" ? (
|
{task.status === "uploading" ? (
|
||||||
<Progress value={task.progress} className="h-1.5" />
|
<Progress
|
||||||
|
value={task.progress}
|
||||||
|
className="h-1.5"
|
||||||
|
aria-label={`Uploading ${task.file.name}`}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{task.status === "error" ? (
|
{task.status === "error" ? (
|
||||||
<p className="text-xs text-destructive">{task.message}</p>
|
<p className="text-xs text-destructive">{task.message}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{task.status === "success" ? (
|
{task.status === "success" ? (
|
||||||
<p className="text-xs text-green-600">Uploaded</p>
|
<p className="text-xs text-green-600">{t("uploaded")}</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -236,9 +125,9 @@ export function FileUpload({
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={() => removeTask(task)}
|
onClick={() => removeTask(task)}
|
||||||
aria-label="Remove"
|
aria-label={t("remove")}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" aria-hidden="true" />
|
||||||
</Button>
|
</Button>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
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 表示)
|
// 文件附件记录(DB 行的 TypeScript 表示)
|
||||||
export interface FileAttachment {
|
export interface FileAttachment {
|
||||||
|
|||||||
@@ -30,7 +30,12 @@ import {
|
|||||||
getUnreadNotificationCount,
|
getUnreadNotificationCount,
|
||||||
archiveNotification,
|
archiveNotification,
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
|
import {
|
||||||
|
getNotificationPreferences,
|
||||||
|
upsertNotificationPreferences,
|
||||||
|
} from "./preferences"
|
||||||
|
import { UpdateNotificationPreferencesSchema } from "./schema"
|
||||||
|
import type { NotificationPayload, ChannelSendResult, Notification, NotificationPreferences, UpdateNotificationPreferencesInput } from "./types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zod 校验:通知负载(sendNotificationAction 入参)
|
* Zod 校验:通知负载(sendNotificationAction 入参)
|
||||||
@@ -298,3 +303,81 @@ export async function archiveNotificationAction(
|
|||||||
return { success: false, message: "Unexpected error" }
|
return { success: false, message: "Unexpected error" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 通知偏好 Server Actions
|
||||||
|
//
|
||||||
|
// V3-P1-3: 从 messaging/actions.ts 迁移至 notifications/actions.ts,
|
||||||
|
// 使通知偏好的 data-access、schema、actions 位于同一模块。
|
||||||
|
// 权限复用 MESSAGE_READ(任何能读消息的用户都能管理通知偏好)。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户的通知偏好。
|
||||||
|
*/
|
||||||
|
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||||
|
const prefs = await getNotificationPreferences(ctx.userId)
|
||||||
|
return { success: true, data: prefs }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新当前用户的通知偏好。
|
||||||
|
*
|
||||||
|
* 从 FormData 中解析布尔值(checkbox 提交 "on" 或不提交)和时间字符串。
|
||||||
|
*/
|
||||||
|
export async function updateNotificationPreferencesAction(
|
||||||
|
prevState: ActionState<NotificationPreferences> | null,
|
||||||
|
formData: FormData
|
||||||
|
): Promise<ActionState<NotificationPreferences>> {
|
||||||
|
try {
|
||||||
|
const ctx = await requirePermission(Permissions.MESSAGE_READ)
|
||||||
|
|
||||||
|
const parseBool = (key: string): boolean => formData.get(key) === "on"
|
||||||
|
const parseTime = (key: string): string | null => {
|
||||||
|
const v = formData.get(key)
|
||||||
|
if (typeof v !== "string") return null
|
||||||
|
const trimmed = v.trim()
|
||||||
|
return trimmed.length > 0 ? trimmed : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = UpdateNotificationPreferencesSchema.safeParse({
|
||||||
|
emailEnabled: parseBool("emailEnabled"),
|
||||||
|
smsEnabled: parseBool("smsEnabled"),
|
||||||
|
pushEnabled: parseBool("pushEnabled"),
|
||||||
|
homeworkNotifications: parseBool("homeworkNotifications"),
|
||||||
|
gradeNotifications: parseBool("gradeNotifications"),
|
||||||
|
announcementNotifications: parseBool("announcementNotifications"),
|
||||||
|
messageNotifications: parseBool("messageNotifications"),
|
||||||
|
attendanceNotifications: parseBool("attendanceNotifications"),
|
||||||
|
quietHoursEnabled: parseBool("quietHoursEnabled"),
|
||||||
|
quietHoursStart: parseTime("quietHoursStart"),
|
||||||
|
quietHoursEnd: parseTime("quietHoursEnd"),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
|
||||||
|
}
|
||||||
|
|
||||||
|
const input: UpdateNotificationPreferencesInput = parsed.data
|
||||||
|
|
||||||
|
const updated = await upsertNotificationPreferences(ctx.userId, input)
|
||||||
|
if (!updated) {
|
||||||
|
return { success: false, message: "Failed to update notification preferences" }
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath("/settings")
|
||||||
|
|
||||||
|
return { success: true, message: "Notification preferences updated", data: updated }
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||||
|
if (e instanceof Error) return { success: false, message: e.message }
|
||||||
|
return { success: false, message: "Unexpected error" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ function getSmsConfig(): {
|
|||||||
accessKeySecret: string | undefined
|
accessKeySecret: string | undefined
|
||||||
signName: string | undefined
|
signName: string | undefined
|
||||||
templateCode: string | undefined
|
templateCode: string | undefined
|
||||||
|
smsSdkAppId: string | undefined
|
||||||
} {
|
} {
|
||||||
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
|
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
|
||||||
return {
|
return {
|
||||||
@@ -46,6 +47,7 @@ function getSmsConfig(): {
|
|||||||
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
|
||||||
signName: process.env.SMS_SIGN_NAME,
|
signName: process.env.SMS_SIGN_NAME,
|
||||||
templateCode: process.env.SMS_TEMPLATE_CODE,
|
templateCode: process.env.SMS_TEMPLATE_CODE,
|
||||||
|
smsSdkAppId: process.env.SMS_SDK_APP_ID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +200,7 @@ class TencentSmsSender implements NotificationChannelSender {
|
|||||||
const params = buildTemplateParams(payload)
|
const params = buildTemplateParams(payload)
|
||||||
const response = await client.SendSms({
|
const response = await client.SendSms({
|
||||||
PhoneNumberSet: [`+86${recipient.phone}`],
|
PhoneNumberSet: [`+86${recipient.phone}`],
|
||||||
SmsSdkAppId: this.config.templateCode ?? "",
|
SmsSdkAppId: this.config.smsSdkAppId ?? "",
|
||||||
SignName: this.config.signName ?? "",
|
SignName: this.config.signName ?? "",
|
||||||
TemplateId: this.config.templateCode ?? "",
|
TemplateId: this.config.templateCode ?? "",
|
||||||
TemplateParamSet: [params.title, params.content],
|
TemplateParamSet: [params.title, params.content],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
|
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -32,6 +32,7 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
|||||||
announcement: Megaphone,
|
announcement: Megaphone,
|
||||||
homework: PenTool,
|
homework: PenTool,
|
||||||
grade: GraduationCap,
|
grade: GraduationCap,
|
||||||
|
diagnostic: Stethoscope,
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 轮询降级间隔(毫秒) */
|
/** 轮询降级间隔(毫秒) */
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import Link from "next/link"
|
|||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
|
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
|
||||||
|
|
||||||
import { Badge } from "@/shared/components/ui/badge"
|
import { Badge } from "@/shared/components/ui/badge"
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
@@ -21,8 +21,11 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
|||||||
announcement: Megaphone,
|
announcement: Megaphone,
|
||||||
homework: PenTool,
|
homework: PenTool,
|
||||||
grade: GraduationCap,
|
grade: GraduationCap,
|
||||||
|
diagnostic: Stethoscope,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TYPE_KEYS: NotificationType[] = ["message", "announcement", "homework", "grade", "diagnostic"]
|
||||||
|
|
||||||
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
|
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
|
||||||
low: "bg-muted text-muted-foreground",
|
low: "bg-muted text-muted-foreground",
|
||||||
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
|
||||||
@@ -103,7 +106,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
|||||||
>
|
>
|
||||||
{t("filter.all")}
|
{t("filter.all")}
|
||||||
</Button>
|
</Button>
|
||||||
{(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => (
|
{TYPE_KEYS.map((type) => (
|
||||||
<Button
|
<Button
|
||||||
key={type}
|
key={type}
|
||||||
variant={filterType === type ? "default" : "outline"}
|
variant={filterType === type ? "default" : "outline"}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import type {
|
|||||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||||
|
|
||||||
const isNotificationType = (v: unknown): v is NotificationType =>
|
const isNotificationType = (v: unknown): v is NotificationType =>
|
||||||
v === "message" || v === "announcement" || v === "homework" || v === "grade"
|
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"
|
||||||
|
|
||||||
const toNotificationType = (v: string): NotificationType =>
|
const toNotificationType = (v: string): NotificationType =>
|
||||||
isNotificationType(v) ? v : "message"
|
isNotificationType(v) ? v : "message"
|
||||||
@@ -207,6 +207,7 @@ export async function logNotificationSend(
|
|||||||
const errorPart = result.error ? ` error="${result.error}"` : ""
|
const errorPart = result.error ? ` error="${result.error}"` : ""
|
||||||
|
|
||||||
// 始终输出 console 日志(便于开发调试)
|
// 始终输出 console 日志(便于开发调试)
|
||||||
|
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.info
|
||||||
console.info(
|
console.info(
|
||||||
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
|
||||||
)
|
)
|
||||||
@@ -227,6 +228,7 @@ export async function logNotificationSend(
|
|||||||
})
|
})
|
||||||
} catch (dbError) {
|
} catch (dbError) {
|
||||||
// DB 写入失败不阻塞通知流程,仅记录错误
|
// DB 写入失败不阻塞通知流程,仅记录错误
|
||||||
|
// TODO V3-P2-8: 接入统一日志服务(shared/lib/logger),替换 console.error
|
||||||
console.error("[NotificationLog] Failed to persist log:", dbError)
|
console.error("[NotificationLog] Failed to persist log:", dbError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -141,12 +141,6 @@ export async function sendNotification(
|
|||||||
export async function sendBatchNotifications(
|
export async function sendBatchNotifications(
|
||||||
payloads: NotificationPayload[]
|
payloads: NotificationPayload[]
|
||||||
): Promise<ChannelSendResult[][]> {
|
): Promise<ChannelSendResult[][]> {
|
||||||
// 并行处理每个 payload
|
// 并行处理每个 payload(每条通知的日志已在 sendNotification 内部记录)
|
||||||
const results = await Promise.all(payloads.map((p) => sendNotification(p)))
|
return Promise.all(payloads.map((p) => sendNotification(p)))
|
||||||
|
|
||||||
// 汇总日志
|
|
||||||
const flatResults = results.flat()
|
|
||||||
logNotificationSendBatch(flatResults)
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ export {
|
|||||||
markNotificationAsReadAction,
|
markNotificationAsReadAction,
|
||||||
markAllNotificationsAsReadAction,
|
markAllNotificationsAsReadAction,
|
||||||
archiveNotificationAction,
|
archiveNotificationAction,
|
||||||
|
getNotificationPreferencesAction,
|
||||||
|
updateNotificationPreferencesAction,
|
||||||
} from "./actions"
|
} from "./actions"
|
||||||
|
export { UpdateNotificationPreferencesSchema } from "./schema"
|
||||||
|
export type { UpdateNotificationPreferencesFormInput } from "./schema"
|
||||||
export { NotificationList, NotificationDropdown } from "./components"
|
export { NotificationList, NotificationDropdown } from "./components"
|
||||||
export type {
|
export type {
|
||||||
NotificationChannel,
|
NotificationChannel,
|
||||||
|
|||||||
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"
|
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
|
||||||
|
|
||||||
/** 站内通知类型(message_notifications.type 列) */
|
/** 站内通知类型(message_notifications.type 列) */
|
||||||
export type NotificationType = "message" | "announcement" | "homework" | "grade"
|
export type NotificationType = "message" | "announcement" | "homework" | "grade" | "diagnostic"
|
||||||
|
|
||||||
/** 通知优先级(message_notifications.priority 列) */
|
/** 通知优先级(message_notifications.priority 列) */
|
||||||
export type NotificationPriority = "low" | "normal" | "high" | "urgent"
|
export type NotificationPriority = "low" | "normal" | "high" | "urgent"
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { eq } from "drizzle-orm"
|
|||||||
import type { ActionState } from "@/shared/types/action-state"
|
import type { ActionState } from "@/shared/types/action-state"
|
||||||
import { requireAuth } from "@/shared/lib/auth-guard"
|
import { requireAuth } from "@/shared/lib/auth-guard"
|
||||||
import { logAudit } from "@/shared/lib/audit-logger"
|
import { logAudit } from "@/shared/lib/audit-logger"
|
||||||
|
import { resolveDefaultPath } from "@/shared/lib/route-resolver"
|
||||||
|
import {
|
||||||
|
rateLimit,
|
||||||
|
rateLimitKey,
|
||||||
|
RATE_LIMIT_RULES,
|
||||||
|
} from "@/shared/lib/rate-limit"
|
||||||
import { db } from "@/shared/db"
|
import { db } from "@/shared/db"
|
||||||
import { users } from "@/shared/db/schema"
|
import { users } from "@/shared/db/schema"
|
||||||
import {
|
import {
|
||||||
@@ -18,7 +24,6 @@ import {
|
|||||||
getOnboardingStatus,
|
getOnboardingStatus,
|
||||||
updateUserProfile,
|
updateUserProfile,
|
||||||
bindParentToChild,
|
bindParentToChild,
|
||||||
resolveDefaultPathByRoles,
|
|
||||||
} from "./data-access"
|
} from "./data-access"
|
||||||
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
|
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
|
||||||
|
|
||||||
@@ -74,7 +79,7 @@ export async function completeOnboardingAction(
|
|||||||
const roleNames = ctx.roles
|
const roleNames = ctx.roles
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: { defaultPath: resolveDefaultPathByRoles(roleNames) },
|
data: { defaultPath: resolveDefaultPath(roleNames) },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +111,36 @@ export async function completeOnboardingAction(
|
|||||||
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s)
|
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// audit-P1-8:家长绑定子女独立速率限制。
|
||||||
|
// 防止被撤销子女关系的家长反复尝试枚举三因子(生日 × 手机后4)。
|
||||||
|
// 限制每小时 5 次完整 onboarding 提交,远低于 3.65M 组合枚举所需量级。
|
||||||
|
// 仅在家长角色且实际提交子女绑定时检查,避免误伤其他角色。
|
||||||
|
if (normalizedRoles.includes("parent") && input.children.length > 0) {
|
||||||
|
const bindLimit = await rateLimit({
|
||||||
|
key: rateLimitKey("onboarding:bind", userId),
|
||||||
|
limit: RATE_LIMIT_RULES.ONBOARDING_BIND.limit,
|
||||||
|
windowMs: RATE_LIMIT_RULES.ONBOARDING_BIND.windowMs,
|
||||||
|
})
|
||||||
|
if (!bindLimit.success) {
|
||||||
|
await logAudit({
|
||||||
|
action: "onboarding.bind_rate_limited",
|
||||||
|
module: "onboarding",
|
||||||
|
targetId: userId,
|
||||||
|
targetType: "user",
|
||||||
|
detail: {
|
||||||
|
childrenCount: input.children.length,
|
||||||
|
retryAfterMs: bindLimit.retryAfterMs,
|
||||||
|
},
|
||||||
|
status: "failure",
|
||||||
|
})
|
||||||
|
const retryMinutes = Math.ceil(bindLimit.retryAfterMs / 60_000)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `子女绑定尝试过于频繁,请 ${retryMinutes} 分钟后再试`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 收集局部失败项(P1-2):班级码/子女绑定失败不回滚整个事务
|
// 收集局部失败项(P1-2):班级码/子女绑定失败不回滚整个事务
|
||||||
const failures: OnboardingFailureItem[] = []
|
const failures: OnboardingFailureItem[] = []
|
||||||
|
|
||||||
@@ -190,7 +225,7 @@ export async function completeOnboardingAction(
|
|||||||
.set({ onboardedAt: new Date() })
|
.set({ onboardedAt: new Date() })
|
||||||
.where(eq(users.id, userId))
|
.where(eq(users.id, userId))
|
||||||
|
|
||||||
return { defaultPath: resolveDefaultPathByRoles(normalizedRoles) }
|
return { defaultPath: resolveDefaultPath(normalizedRoles) }
|
||||||
})
|
})
|
||||||
|
|
||||||
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross)
|
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross)
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation"
|
|
||||||
import { useSession } from "next-auth/react"
|
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations } from "next-intl"
|
||||||
import { toast } from "sonner"
|
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Input } from "@/shared/components/ui/input"
|
import { Input } from "@/shared/components/ui/input"
|
||||||
@@ -12,9 +8,10 @@ import { Label } from "@/shared/components/ui/label"
|
|||||||
import { Textarea } from "@/shared/components/ui/textarea"
|
import { Textarea } from "@/shared/components/ui/textarea"
|
||||||
import { Checkbox } from "@/shared/components/ui/checkbox"
|
import { Checkbox } from "@/shared/components/ui/checkbox"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import { DEFAULT_CLASS_SUBJECTS, type ClassSubject } from "@/modules/classes/types"
|
import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
|
||||||
import { completeOnboardingAction } from "@/modules/onboarding/actions"
|
|
||||||
import type { OnboardingStatus } from "@/modules/onboarding/types"
|
import type { OnboardingStatus } from "@/modules/onboarding/types"
|
||||||
|
import { useOnboardingForm } from "@/modules/onboarding/hooks/use-onboarding-form"
|
||||||
|
import { ParentChildrenForm } from "@/modules/onboarding/components/parent-children-form"
|
||||||
|
|
||||||
interface OnboardingStepperProps {
|
interface OnboardingStepperProps {
|
||||||
initialStatus: OnboardingStatus
|
initialStatus: OnboardingStatus
|
||||||
@@ -22,169 +19,56 @@ interface OnboardingStepperProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* v3 i18n:所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。
|
* v3 i18n:所有文案通过 useTranslations 读取,支持 zh-CN / en 切换。
|
||||||
|
*
|
||||||
|
* P1-2 重构后结构:
|
||||||
|
* - 状态与提交逻辑:useOnboardingForm 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) {
|
export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
||||||
const router = useRouter()
|
|
||||||
const searchParams = useSearchParams()
|
|
||||||
const { update } = useSession()
|
|
||||||
const t = useTranslations("onboarding")
|
const t = useTranslations("onboarding")
|
||||||
const tCommon = useTranslations("common.actions")
|
const tCommon = useTranslations("common.actions")
|
||||||
|
|
||||||
// P1-1:URL query 参数持久化当前步骤
|
const form = useOnboardingForm(initialStatus)
|
||||||
const initialStep = clampStep(Number(searchParams.get("step") ?? "0"))
|
const {
|
||||||
const [step, setStep] = React.useState(initialStep)
|
step,
|
||||||
const [isSubmitting, setIsSubmitting] = React.useState(false)
|
isSubmitting,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
address,
|
||||||
|
classCodes,
|
||||||
|
teacherSubjects,
|
||||||
|
children,
|
||||||
|
primaryRole,
|
||||||
|
isStudent,
|
||||||
|
isTeacher,
|
||||||
|
isParent,
|
||||||
|
stepKeys,
|
||||||
|
currentStepId,
|
||||||
|
maxStep,
|
||||||
|
canSkip,
|
||||||
|
setName,
|
||||||
|
setPhone,
|
||||||
|
setAddress,
|
||||||
|
setClassCodes,
|
||||||
|
toggleSubject,
|
||||||
|
addChildRow,
|
||||||
|
removeChildRow,
|
||||||
|
updateChildRow,
|
||||||
|
onNext,
|
||||||
|
onBack,
|
||||||
|
onSkip,
|
||||||
|
onFinish,
|
||||||
|
} = form
|
||||||
|
|
||||||
const [name, setName] = React.useState(initialStatus.name ?? "")
|
// audit-P1-13:标题/描述/内容均按 currentStepId 渲染,不再依赖数字下标。
|
||||||
const [phone, setPhone] = React.useState("")
|
const title = t(`steps.${currentStepId}`)
|
||||||
const [address, setAddress] = React.useState("")
|
|
||||||
const [classCodes, setClassCodes] = React.useState("")
|
|
||||||
const [teacherSubjects, setTeacherSubjects] = React.useState<ClassSubject[]>([])
|
|
||||||
const [children, setChildren] = React.useState<ChildRow[]>([{ ...EMPTY_CHILD_ROW }])
|
|
||||||
|
|
||||||
const primaryRole = initialStatus.roles.primary
|
|
||||||
const isAdmin = primaryRole === "admin"
|
|
||||||
const isStudent = primaryRole === "student"
|
|
||||||
const isTeacher = primaryRole === "teacher"
|
|
||||||
const isParent = primaryRole === "parent"
|
|
||||||
|
|
||||||
const stepKeys = isAdmin ? STEPS_KEYS.filter((_, i) => i !== 2) : STEPS_KEYS
|
|
||||||
const maxStep = stepKeys.length - 1
|
|
||||||
|
|
||||||
const canNext = React.useMemo(() => {
|
|
||||||
if (step === 1) {
|
|
||||||
return name.trim().length > 0 && phone.trim().length > 0
|
|
||||||
}
|
|
||||||
if (step === 2 && isParent) {
|
|
||||||
const validChildren = children.filter(
|
|
||||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
|
||||||
)
|
|
||||||
return validChildren.length > 0
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}, [step, name, phone, isParent, children])
|
|
||||||
|
|
||||||
const toggleSubject = (subject: ClassSubject) => {
|
|
||||||
setTeacherSubjects((prev) =>
|
|
||||||
prev.includes(subject) ? prev.filter((s) => s !== subject) : [...prev, subject]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const goToStep = React.useCallback(
|
|
||||||
(next: number) => {
|
|
||||||
const clamped = Math.max(0, Math.min(maxStep, next))
|
|
||||||
const params = new URLSearchParams(searchParams.toString())
|
|
||||||
params.set("step", String(clamped))
|
|
||||||
router.replace(`/onboarding?${params.toString()}`, { scroll: false })
|
|
||||||
setStep(clamped)
|
|
||||||
},
|
|
||||||
[maxStep, router, searchParams]
|
|
||||||
)
|
|
||||||
|
|
||||||
const onNext = () => {
|
|
||||||
if (step === 1 && !canNext) {
|
|
||||||
toast.error(t("validation.needNamePhone"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (step === 2 && isParent && !canNext) {
|
|
||||||
toast.error(t("validation.needOneChild"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
goToStep(step + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onBack = () => {
|
|
||||||
goToStep(step - 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSkipStep2 = isAdmin || isStudent || isTeacher
|
|
||||||
const onSkip = () => {
|
|
||||||
if (isParent) return
|
|
||||||
goToStep(isAdmin ? 2 : 3)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addChildRow = () => {
|
|
||||||
setChildren((prev) => [...prev, { ...EMPTY_CHILD_ROW }])
|
|
||||||
}
|
|
||||||
const removeChildRow = (idx: number) => {
|
|
||||||
setChildren((prev) => (prev.length === 1 ? prev : prev.filter((_, i) => i !== idx)))
|
|
||||||
}
|
|
||||||
const updateChildRow = (idx: number, patch: Partial<ChildRow>) => {
|
|
||||||
setChildren((prev) => prev.map((row, i) => (i === idx ? { ...row, ...patch } : row)))
|
|
||||||
}
|
|
||||||
|
|
||||||
const onFinish = async () => {
|
|
||||||
if (isParent) {
|
|
||||||
const validChildren = children.filter(
|
|
||||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
|
||||||
)
|
|
||||||
if (validChildren.length === 0) {
|
|
||||||
toast.error(t("validation.needOneChild"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true)
|
|
||||||
try {
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.set("name", name.trim())
|
|
||||||
formData.set("phone", phone.trim())
|
|
||||||
formData.set("address", address.trim())
|
|
||||||
// v3:邀请码统一大写化后提交
|
|
||||||
formData.set("classCodes", classCodes.trim().toUpperCase())
|
|
||||||
formData.set("teacherSubjects", JSON.stringify(teacherSubjects))
|
|
||||||
const validChildren = children.filter(
|
|
||||||
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim()
|
|
||||||
)
|
|
||||||
formData.set("children", JSON.stringify(validChildren))
|
|
||||||
|
|
||||||
const result = await completeOnboardingAction(null, formData)
|
|
||||||
if (!result.success) {
|
|
||||||
toast.error(result.message ?? t("toast.submitFailed"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.message && result.message.includes("绑定失败")) {
|
|
||||||
toast.warning(result.message)
|
|
||||||
} else {
|
|
||||||
toast.success(t("toast.completeSuccess"))
|
|
||||||
}
|
|
||||||
|
|
||||||
await update?.()
|
|
||||||
const target = result.data?.defaultPath ?? "/dashboard"
|
|
||||||
router.push(target)
|
|
||||||
router.refresh()
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
|
|
||||||
toast.error(msg)
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const title = t(`steps.${stepKeys[step]}`)
|
|
||||||
const description =
|
const description =
|
||||||
step === 0
|
currentStepId === "roleConfirm"
|
||||||
? t("role.adminAssigned")
|
? t("role.adminAssigned")
|
||||||
: step === 1
|
: currentStepId === "basicInfo"
|
||||||
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
|
? t("form.name") + " · " + t("form.phone") + " · " + t("form.address")
|
||||||
: step === 2
|
: currentStepId === "roleInfo"
|
||||||
? isParent
|
? isParent
|
||||||
? t("parent.bindHint")
|
? t("parent.bindHint")
|
||||||
: t("steps.roleInfo")
|
: t("steps.roleInfo")
|
||||||
@@ -216,8 +100,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Step 0: 角色确认(只读) */}
|
{/* Step: 角色确认(只读) */}
|
||||||
{step === 0 ? (
|
{currentStepId === "roleConfirm" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label>{t("role.yourRole")}</Label>
|
<Label>{t("role.yourRole")}</Label>
|
||||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm">
|
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm">
|
||||||
@@ -232,8 +116,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Step 1: 基础信息 */}
|
{/* Step: 基础信息 */}
|
||||||
{step === 1 ? (
|
{currentStepId === "basicInfo" ? (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<Label htmlFor="onb_name">{t("form.name")} *</Label>
|
<Label htmlFor="onb_name">{t("form.name")} *</Label>
|
||||||
@@ -268,8 +152,8 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Step 2: 角色信息 */}
|
{/* Step: 角色信息 */}
|
||||||
{step === 2 ? (
|
{currentStepId === "roleInfo" ? (
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
{isTeacher ? (
|
{isTeacher ? (
|
||||||
<>
|
<>
|
||||||
@@ -315,91 +199,19 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{isParent ? (
|
{isParent ? (
|
||||||
<div className="grid gap-4">
|
<ParentChildrenForm
|
||||||
<div className="rounded-md border bg-muted/30 px-3 py-2.5 text-sm text-muted-foreground">
|
childRows={children}
|
||||||
{t("parent.bindHint")}
|
isSubmitting={isSubmitting}
|
||||||
</div>
|
onUpdate={updateChildRow}
|
||||||
{children.map((row, idx) => (
|
onAdd={addChildRow}
|
||||||
<div key={idx} className="grid gap-3 rounded-md border p-3">
|
onRemove={removeChildRow}
|
||||||
<div className="flex items-center justify-between">
|
/>
|
||||||
<span className="text-sm font-medium">{t("parent.childN", { index: idx + 1 })}</span>
|
|
||||||
{children.length > 1 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => removeChildRow(idx)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{tCommon("remove")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`onb_child_email_${idx}`}>{t("parent.childEmail")} *</Label>
|
|
||||||
<Input
|
|
||||||
id={`onb_child_email_${idx}`}
|
|
||||||
type="email"
|
|
||||||
value={row.childEmail}
|
|
||||||
onChange={(e) => updateChildRow(idx, { childEmail: e.target.value })}
|
|
||||||
placeholder="student@example.com"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`onb_child_birth_${idx}`}>{t("parent.childBirthDate")} *</Label>
|
|
||||||
<Input
|
|
||||||
id={`onb_child_birth_${idx}`}
|
|
||||||
type="date"
|
|
||||||
value={row.childBirthDate}
|
|
||||||
onChange={(e) => updateChildRow(idx, { childBirthDate: e.target.value })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`onb_child_phone_${idx}`}>{t("parent.childPhoneSuffix")} *</Label>
|
|
||||||
<Input
|
|
||||||
id={`onb_child_phone_${idx}`}
|
|
||||||
value={row.childPhoneSuffix}
|
|
||||||
onChange={(e) =>
|
|
||||||
updateChildRow(idx, {
|
|
||||||
childPhoneSuffix: e.target.value.replace(/\D/g, "").slice(0, 4),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
placeholder="4 位数字"
|
|
||||||
inputMode="numeric"
|
|
||||||
maxLength={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-2">
|
|
||||||
<Label htmlFor={`onb_child_relation_${idx}`}>{t("parent.childRelation")}</Label>
|
|
||||||
<Input
|
|
||||||
id={`onb_child_relation_${idx}`}
|
|
||||||
value={row.childRelation}
|
|
||||||
onChange={(e) => updateChildRow(idx, { childRelation: e.target.value })}
|
|
||||||
placeholder={t("parent.childRelationPlaceholder")}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{children.length < 10 ? (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={addChildRow}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("parent.addChild")}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Step 3: 完成 */}
|
{/* Step: 完成 */}
|
||||||
{step === (isAdmin ? 2 : 3) ? (
|
{currentStepId === "complete" ? (
|
||||||
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
|
<div className="rounded-md border bg-muted/30 px-4 py-4 text-sm">
|
||||||
<div className="font-medium">{t("complete.ready")}</div>
|
<div className="font-medium">{t("complete.ready")}</div>
|
||||||
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
|
<div className="mt-1 text-muted-foreground">{t("complete.readyHint")}</div>
|
||||||
@@ -417,7 +229,7 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
|||||||
>
|
>
|
||||||
{tCommon("previous")}
|
{tCommon("previous")}
|
||||||
</Button>
|
</Button>
|
||||||
{step === 2 && canSkipStep2 ? (
|
{canSkip ? (
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
@@ -444,8 +256,3 @@ export function OnboardingStepper({ initialStatus }: OnboardingStepperProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampStep(value: number): number {
|
|
||||||
if (!Number.isFinite(value)) return 0
|
|
||||||
return Math.max(0, Math.min(3, Math.floor(value)))
|
|
||||||
}
|
|
||||||
|
|||||||
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,
|
parentStudentRelations,
|
||||||
} from "@/shared/db/schema"
|
} from "@/shared/db/schema"
|
||||||
import type { Role } from "@/shared/types/permissions"
|
import type { Role } from "@/shared/types/permissions"
|
||||||
import { normalizeRole, resolvePrimaryRole } from "@/shared/lib/role-utils"
|
import { resolvePrimaryRole } from "@/shared/lib/role-utils"
|
||||||
import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } from "./types"
|
import type { OnboardingRoleInfo, OnboardingStatus, BindParentToChildParams } from "./types"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -125,15 +125,3 @@ export async function bindParentToChild(
|
|||||||
|
|
||||||
return { studentId: child.id }
|
return { studentId: child.id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 按角色解析默认跳转路径(与 proxy.ts 的 resolveDefaultPath 保持一致)。
|
|
||||||
*/
|
|
||||||
export function resolveDefaultPathByRoles(roleNames: string[]): string {
|
|
||||||
const normalized = roleNames.map((r) => normalizeRole(r))
|
|
||||||
if (normalized.includes("admin")) return "/admin/dashboard"
|
|
||||||
if (normalized.includes("teacher")) return "/teacher/dashboard"
|
|
||||||
if (normalized.includes("student")) return "/student/dashboard"
|
|
||||||
if (normalized.includes("parent")) return "/parent/dashboard"
|
|
||||||
return "/dashboard"
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
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 步骤中展示给用户的角色信息。
|
* Onboarding 步骤中展示给用户的角色信息。
|
||||||
* 角色来源于 usersToRoles(管理员预分配),用户不可修改。
|
* 角色来源于 usersToRoles(管理员预分配),用户不可修改。
|
||||||
*/
|
*/
|
||||||
export type OnboardingRoleInfo = {
|
export type OnboardingRoleInfo = {
|
||||||
/** 规范化后的主角色(admin/teacher/student/parent) */
|
/** 规范化后的主角色(admin/grade_head/teaching_head/teacher/student/parent) */
|
||||||
primary: "admin" | "teacher" | "student" | "parent"
|
primary: NormalizedRole
|
||||||
/** 用户拥有的全部角色名(含 grade_head/teaching_head 等) */
|
/** 用户拥有的全部角色名(含 grade_head/teaching_head 等) */
|
||||||
all: Role[]
|
all: Role[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Mail,
|
Mail,
|
||||||
Stethoscope,
|
Stethoscope,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
import { useTranslations } from "next-intl"
|
||||||
|
|
||||||
import { Button } from "@/shared/components/ui/button"
|
import { Button } from "@/shared/components/ui/button"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||||
@@ -26,7 +27,7 @@ export type ChildDetailTab = "overview" | "homework" | "grades" | "exams" | "sch
|
|||||||
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
|
const VALID_TABS: ChildDetailTab[] = ["overview", "homework", "grades", "exams", "schedule", "attendance", "diagnostic"]
|
||||||
|
|
||||||
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
|
const isTab = (v: string | undefined | null): v is ChildDetailTab =>
|
||||||
typeof v === "string" && (VALID_TABS as string[]).includes(v)
|
typeof v === "string" && VALID_TABS.some((tab) => tab === v)
|
||||||
|
|
||||||
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
|
const resolveTab = (v: string | undefined | null): ChildDetailTab =>
|
||||||
isTab(v) ? v : "overview"
|
isTab(v) ? v : "overview"
|
||||||
@@ -40,40 +41,47 @@ export function ChildDetailPanel({
|
|||||||
initialTab?: string
|
initialTab?: string
|
||||||
siblingSwitcher?: React.ReactNode
|
siblingSwitcher?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("parent")
|
||||||
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
|
const { basicInfo, todaySchedule, weeklySchedule, homeworkSummary, gradeTrend, examResults } = child
|
||||||
const childName = basicInfo.name ?? "Child"
|
const childName = basicInfo.name ?? t("childDetail.defaultName")
|
||||||
|
|
||||||
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
|
const [tab, setTab] = useState<ChildDetailTab>(resolveTab(initialTab))
|
||||||
|
|
||||||
const tabs = useMemo(
|
const tabs = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ id: "overview" as const, label: "Overview", icon: ClipboardList },
|
{ id: "overview" as const, label: t("childDetail.tabs.overview"), icon: ClipboardList },
|
||||||
{ id: "homework" as const, label: "Homework", icon: ClipboardList },
|
{ id: "homework" as const, label: t("childDetail.tabs.homework"), icon: ClipboardList },
|
||||||
{ id: "grades" as const, label: "Grades", icon: BarChart3 },
|
{ id: "grades" as const, label: t("childDetail.tabs.grades"), icon: BarChart3 },
|
||||||
{ id: "exams" as const, label: "Exams", icon: GraduationCap },
|
{ id: "exams" as const, label: t("childDetail.tabs.exams"), icon: GraduationCap },
|
||||||
{ id: "schedule" as const, label: "Schedule", icon: CalendarDays },
|
{ id: "schedule" as const, label: t("childDetail.tabs.schedule"), icon: CalendarDays },
|
||||||
{ id: "attendance" as const, label: "Attendance", icon: CalendarDays },
|
{ id: "attendance" as const, label: t("childDetail.tabs.attendance"), icon: CalendarDays },
|
||||||
{ id: "diagnostic" as const, label: "Diagnostic", icon: Stethoscope },
|
{ id: "diagnostic" as const, label: t("childDetail.tabs.diagnostic"), icon: Stethoscope },
|
||||||
],
|
],
|
||||||
[],
|
[t],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{siblingSwitcher}
|
{siblingSwitcher}
|
||||||
|
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ChildDetailTab)} className="w-full">
|
<Tabs
|
||||||
|
value={tab}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (isTab(v)) setTab(v)
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<TabsList className="w-full justify-start">
|
<TabsList className="w-full justify-start">
|
||||||
{tabs.map((t) => (
|
{tabs.map((tab) => (
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
key={t.id}
|
key={tab.id}
|
||||||
value={t.id}
|
value={tab.id}
|
||||||
className="gap-1.5"
|
className="gap-1.5"
|
||||||
aria-label={`${t.label} tab`}
|
aria-label={t("childDetail.tabAriaLabel", { label: tab.label })}
|
||||||
>
|
>
|
||||||
<t.icon className="h-3.5 w-3.5" />
|
<tab.icon className="h-3.5 w-3.5" />
|
||||||
{t.label}
|
{tab.label}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
))}
|
))}
|
||||||
</TabsList>
|
</TabsList>
|
||||||
@@ -108,7 +116,7 @@ export function ChildDetailPanel({
|
|||||||
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
|
<ChildGradeSummary grades={gradeTrend} childId={basicInfo.id} childName={childName} />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
|
<h3 className="text-sm font-medium uppercase text-muted-foreground mb-3">
|
||||||
Subject Analysis
|
{t("childDetail.subjectAnalysis")}
|
||||||
</h3>
|
</h3>
|
||||||
<ChildGradeDetail grades={gradeTrend} />
|
<ChildGradeDetail grades={gradeTrend} />
|
||||||
</div>
|
</div>
|
||||||
@@ -134,14 +142,16 @@ export function ChildDetailPanel({
|
|||||||
<TabsContent value="attendance" className="mt-6">
|
<TabsContent value="attendance" className="mt-6">
|
||||||
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Attendance details are available on the{" "}
|
{t.rich("childDetail.attendanceHint", {
|
||||||
<a
|
link: (chunks) => (
|
||||||
href="/parent/attendance"
|
<a
|
||||||
className="font-medium text-foreground underline-offset-4 hover:underline"
|
href="/parent/attendance"
|
||||||
>
|
className="font-medium text-foreground underline-offset-4 hover:underline"
|
||||||
Attendance page
|
>
|
||||||
</a>
|
{chunks}
|
||||||
.
|
</a>
|
||||||
|
),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -149,7 +159,7 @@ export function ChildDetailPanel({
|
|||||||
<TabsContent value="diagnostic" className="mt-6">
|
<TabsContent value="diagnostic" className="mt-6">
|
||||||
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
<div className="rounded-md border bg-muted/30 p-6 text-center">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Diagnostic reports will be available here once published by the school.
|
{t("childDetail.diagnosticHint")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -157,9 +167,12 @@ export function ChildDetailPanel({
|
|||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button asChild variant="ghost" size="sm" className="gap-2">
|
<Button asChild variant="ghost" size="sm" className="gap-2">
|
||||||
<a href={`/messages?studentId=${basicInfo.id}`} aria-label={`Contact teacher about ${childName}`}>
|
<a
|
||||||
|
href={`/messages?studentId=${basicInfo.id}`}
|
||||||
|
aria-label={t("childDetail.contactTeacherAria", { name: childName })}
|
||||||
|
>
|
||||||
<Mail className="h-4 w-4" />
|
<Mail className="h-4 w-4" />
|
||||||
Contact Teacher
|
{t("childDetail.contactTeacher")}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,21 +188,24 @@ export function SiblingSwitcher({
|
|||||||
current: { id: string; name: string | null }
|
current: { id: string; name: string | null }
|
||||||
siblings: Array<{ id: string; name: string | null }>
|
siblings: Array<{ id: string; name: string | null }>
|
||||||
}) {
|
}) {
|
||||||
|
const t = useTranslations("parent")
|
||||||
if (siblings.length <= 1) return null
|
if (siblings.length <= 1) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-muted/30 p-2">
|
||||||
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">Switch child</span>
|
<span className="px-2 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
{t("childDetail.switchChild")}
|
||||||
|
</span>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{siblings.map((s) => {
|
{siblings.map((s) => {
|
||||||
const isActive = s.id === current.id
|
const isActive = s.id === current.id
|
||||||
const label = s.name ?? "Child"
|
const label = s.name ?? t("childDetail.defaultName")
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
key={s.id}
|
key={s.id}
|
||||||
href={`/parent/children/${s.id}`}
|
href={`/parent/children/${s.id}`}
|
||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
aria-label={`View ${label}'s details`}
|
aria-label={t("childDetail.viewDetailsAria", { name: label })}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
|
"inline-flex min-h-[40px] items-center rounded-md px-3 text-sm font-medium transition-colors",
|
||||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { JSX } from "react"
|
import type { JSX } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { GraduationCap, TrendingUp, Award, BookOpen } from "lucide-react"
|
import { useTranslations } from "next-intl"
|
||||||
|
import { GraduationCap, TrendingUp, Award, BookOpen, ChevronRight } from "lucide-react"
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -32,14 +33,19 @@ interface ChildExamDetailProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V3-11: 家长端子女考试详情视图
|
* V3-11 / P3-4: 家长端子女考试详情视图
|
||||||
*
|
*
|
||||||
* 对标智学网家长端,展示:
|
* 对标智学网家长端,展示:
|
||||||
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
|
* - 考试成绩汇总卡片(已参加考试数、平均分、最高分)
|
||||||
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
|
* - 考试成绩列表(考试标题、分数、得分率、提交时间)
|
||||||
* - 成绩趋势可视化
|
*
|
||||||
|
* P3-4 改进:
|
||||||
|
* - 所有用户可见文本使用 i18n 翻译键(next-intl)
|
||||||
|
* - 触控目标 ≥ 44px(min-h-[44px])
|
||||||
|
* - 语义化标签 + ARIA 属性
|
||||||
*/
|
*/
|
||||||
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
|
export function ChildExamDetail({ examResults, childId, childName }: ChildExamDetailProps): JSX.Element {
|
||||||
|
const t = useTranslations("examHomework")
|
||||||
const hasResults = examResults.length > 0
|
const hasResults = examResults.length > 0
|
||||||
|
|
||||||
const examCount = examResults.length
|
const examCount = examResults.length
|
||||||
@@ -60,33 +66,39 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
|||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 pt-6">
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
||||||
<GraduationCap className="h-5 w-5 text-primary" />
|
<GraduationCap className="h-5 w-5 text-primary" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
|
<p className="text-2xl font-bold tabular-nums">{examCount}</p>
|
||||||
<p className="text-xs text-muted-foreground">Exams Taken</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("homework.parentExam.examsTaken")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 pt-6">
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-500/10">
|
||||||
<TrendingUp className="h-5 w-5 text-blue-500" />
|
<TrendingUp className="h-5 w-5 text-blue-500" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
|
<p className="text-2xl font-bold tabular-nums">{averageScore.toFixed(1)}%</p>
|
||||||
<p className="text-xs text-muted-foreground">Average Score</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("homework.parentExam.averageScore")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="flex items-center gap-3 pt-6">
|
<CardContent className="flex items-center gap-3 pt-6">
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500/10">
|
||||||
<Award className="h-5 w-5 text-green-500" />
|
<Award className="h-5 w-5 text-green-500" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
|
<p className="text-2xl font-bold tabular-nums">{bestScore.toFixed(1)}%</p>
|
||||||
<p className="text-xs text-muted-foreground">Best Score</p>
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("homework.parentExam.bestScore")}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -97,54 +109,60 @@ export function ChildExamDetail({ examResults, childId, childName }: ChildExamDe
|
|||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
|
<BookOpen className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
{childName}'s Exam Results
|
{t("homework.parentExam.examResults", { name: childName })}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>Recent exam scores and performance trends</CardDescription>
|
<CardDescription>
|
||||||
|
{t("homework.parentExam.examResultsDescription")}
|
||||||
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{!hasResults ? (
|
{!hasResults ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={GraduationCap}
|
icon={GraduationCap}
|
||||||
title="No exam results"
|
title={t("homework.parentExam.noResults")}
|
||||||
description="Exam results will appear here once available."
|
description={t("homework.parentExam.noResultsHint")}
|
||||||
className="border-none h-48"
|
className="border-none h-48"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<ul className="space-y-3" aria-label={t("homework.parentExam.examResults", { name: childName })}>
|
||||||
{examResults.map((r) => {
|
{examResults.map((r) => {
|
||||||
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
|
const scoreRate = r.maxScore > 0 ? (r.score / r.maxScore) * 100 : 0
|
||||||
const isPass = scoreRate >= 60
|
const isPass = scoreRate >= 60
|
||||||
return (
|
return (
|
||||||
<Link
|
<li key={r.submissionId}>
|
||||||
key={r.submissionId}
|
<Link
|
||||||
href={`/parent/children/${childId}?tab=grades`}
|
href={`/parent/children/${childId}?tab=grades`}
|
||||||
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
className="flex min-h-[44px] items-center justify-between rounded-md border bg-card p-3 hover:bg-muted/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-1 space-y-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-medium text-sm truncate">{r.examTitle}</div>
|
<div className="font-medium text-sm truncate">{r.examTitle}</div>
|
||||||
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
|
<Badge variant={isPass ? "default" : "destructive"} className="text-[10px] shrink-0">
|
||||||
{isPass ? "Pass" : "Below 60%"}
|
{isPass ? t("homework.parentExam.pass") : t("homework.parentExam.belowPass")}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{r.submittedAt ? (
|
||||||
|
<span>{formatDate(r.submittedAt)}</span>
|
||||||
|
) : null}
|
||||||
|
<span aria-hidden="true">•</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{r.score} / {r.maxScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={scoreRate} className="h-1.5 mt-1" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
{r.submittedAt ? (
|
<span className="text-sm font-semibold tabular-nums">
|
||||||
<span>{formatDate(r.submittedAt)}</span>
|
{scoreRate.toFixed(0)}%
|
||||||
) : null}
|
|
||||||
<span aria-hidden="true">•</span>
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{r.score} / {r.maxScore}
|
|
||||||
</span>
|
</span>
|
||||||
|
<ChevronRight className="h-4 w-4 text-muted-foreground" aria-hidden />
|
||||||
</div>
|
</div>
|
||||||
<Progress value={scoreRate} className="h-1.5 mt-1" />
|
</Link>
|
||||||
</div>
|
</li>
|
||||||
<div className="text-sm font-semibold tabular-nums shrink-0 ml-2">
|
|
||||||
{scoreRate.toFixed(0)}%
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||||
import { useState, useRef, type KeyboardEvent } from "react"
|
import { useState, useRef, type KeyboardEvent } from "react"
|
||||||
import { useTranslations } from "next-intl"
|
import { useTranslations, useLocale } from "next-intl"
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
import {
|
import {
|
||||||
ATTENDANCE_STATUS_DOT_COLORS,
|
ATTENDANCE_STATUS_DOT_COLORS,
|
||||||
ATTENDANCE_STATUS_LABEL_KEYS,
|
ATTENDANCE_STATUS_LABEL_KEYS,
|
||||||
} from "@/modules/attendance/constants"
|
} from "@/shared/constants/attendance-status"
|
||||||
import type {
|
import type {
|
||||||
ParentAttendanceListItem,
|
ParentAttendanceListItem,
|
||||||
ParentAttendanceStatus,
|
ParentAttendanceStatus,
|
||||||
@@ -86,6 +86,7 @@ export function ParentAttendanceCalendar({
|
|||||||
summary: ParentStudentAttendanceSummary
|
summary: ParentStudentAttendanceSummary
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("attendance")
|
const t = useTranslations("attendance")
|
||||||
|
const locale = useLocale()
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const [viewYear, setViewYear] = useState(now.getFullYear())
|
const [viewYear, setViewYear] = useState(now.getFullYear())
|
||||||
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
const [viewMonth, setViewMonth] = useState(now.getMonth())
|
||||||
@@ -99,7 +100,7 @@ export function ParentAttendanceCalendar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const days = buildCalendarDays(viewYear, viewMonth)
|
const days = buildCalendarDays(viewYear, viewMonth)
|
||||||
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString("en-US", {
|
const monthLabel = new Date(viewYear, viewMonth, 1).toLocaleDateString(locale, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
|
import { CalendarCheck, CalendarX, Clock, TrendingUp } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Card } from "@/shared/components/ui/card"
|
import { Card } from "@/shared/components/ui/card"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
@@ -50,15 +48,17 @@ const TONE_STYLES: Record<"good" | "warn" | "bad", string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 家长考勤页顶部的出勤率汇总卡片。
|
* 家长考勤页顶部的出勤率汇总卡片(RSC)。
|
||||||
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
|
* 聚合所有子女的出勤率、缺勤、迟到总数,让家长一眼掌握整体情况。
|
||||||
|
*
|
||||||
|
* P2-8 修复:从 client component 降级为 RSC,使用 `getTranslations`。
|
||||||
*/
|
*/
|
||||||
export function ParentAttendanceRateCard({
|
export async function ParentAttendanceRateCard({
|
||||||
summaries,
|
summaries,
|
||||||
}: {
|
}: {
|
||||||
summaries: ParentStudentAttendanceSummary[]
|
summaries: ParentStudentAttendanceSummary[]
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("attendance")
|
const t = await getTranslations("attendance")
|
||||||
const stats = aggregateStats(summaries)
|
const stats = aggregateStats(summaries)
|
||||||
if (stats.totalStudents === 0) return null
|
if (stats.totalStudents === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import { AlertTriangle, Phone } from "lucide-react"
|
import { AlertTriangle, Phone } from "lucide-react"
|
||||||
import { useTranslations } from "next-intl"
|
import { getTranslations } from "next-intl/server"
|
||||||
|
|
||||||
import { Card } from "@/shared/components/ui/card"
|
import { Card } from "@/shared/components/ui/card"
|
||||||
import { cn } from "@/shared/lib/utils"
|
import { cn } from "@/shared/lib/utils"
|
||||||
@@ -14,8 +12,11 @@ type Warning = {
|
|||||||
severity: "high" | "medium"
|
severity: "high" | "medium"
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 翻译函数类型(与 `useTranslations("attendance")` 返回值兼容) */
|
/** 翻译函数类型(与 useTranslations / getTranslations 返回值兼容) */
|
||||||
type Translator = ReturnType<typeof useTranslations>
|
type Translator = (
|
||||||
|
key: string,
|
||||||
|
values?: Record<string, string | number | Date>,
|
||||||
|
) => string
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建考勤异常预警列表(纯函数,便于测试)。
|
* 构建考勤异常预警列表(纯函数,便于测试)。
|
||||||
@@ -67,15 +68,17 @@ export function buildWarnings(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 家长视角的考勤异常预警横幅。
|
* 家长视角的考勤异常预警横幅(RSC)。
|
||||||
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
|
* 聚合所有子女的考勤异常(缺勤、迟到、低出勤率),提醒家长及时关注。
|
||||||
|
*
|
||||||
|
* P2-8 修复:从 client component 降级为 RSC,使用 `getTranslations`。
|
||||||
*/
|
*/
|
||||||
export function ParentAttendanceWarning({
|
export async function ParentAttendanceWarning({
|
||||||
summaries,
|
summaries,
|
||||||
}: {
|
}: {
|
||||||
summaries: ParentStudentAttendanceSummary[]
|
summaries: ParentStudentAttendanceSummary[]
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("attendance")
|
const t = await getTranslations("attendance")
|
||||||
const warnings = buildWarnings(summaries, t)
|
const warnings = buildWarnings(summaries, t)
|
||||||
if (warnings.length === 0) return null
|
if (warnings.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
getStudentSchedule,
|
||||||
} from "@/modules/classes/data-access"
|
} from "@/modules/classes/data-access"
|
||||||
import {
|
import {
|
||||||
getStudentDashboardGrades,
|
|
||||||
getStudentHomeworkAssignments,
|
getStudentHomeworkAssignments,
|
||||||
getStudentExamResults,
|
getStudentExamResults,
|
||||||
} from "@/modules/homework/data-access"
|
} from "@/modules/homework/data-access-student"
|
||||||
|
import { getStudentDashboardGrades } from "@/modules/homework/stats-service"
|
||||||
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
import { getStudentGradeSummary } from "@/modules/grades/data-access"
|
||||||
import { getGradeNameById } from "@/modules/school/data-access"
|
import { getGradeNameById } from "@/modules/school/data-access"
|
||||||
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/data-access"
|
import { getUserBasicInfo, getUserNamesByIds } from "@/modules/users/data-access"
|
||||||
@@ -283,3 +283,22 @@ export const getParentIdsByStudentIds = cache(
|
|||||||
return Array.from(new Set(rows.map((r) => r.parentId)))
|
return Array.from(new Set(rows.map((r) => r.parentId)))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L-2: 批量查询学生→家长映射(保留关联关系)。
|
||||||
|
* 用于考勤通知等需要按家长-学生分组发送的场景,避免 N+1 查询。
|
||||||
|
* 返回数组形如 [{ parentId, studentId }],同一家长多个学生会出现多条。
|
||||||
|
*/
|
||||||
|
export const getParentStudentMapByStudentIds = cache(
|
||||||
|
async (studentIds: string[]): Promise<Array<{ parentId: string; studentId: string }>> => {
|
||||||
|
if (studentIds.length === 0) return []
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
parentId: parentStudentRelations.parentId,
|
||||||
|
studentId: parentStudentRelations.studentId,
|
||||||
|
})
|
||||||
|
.from(parentStudentRelations)
|
||||||
|
.where(inArray(parentStudentRelations.studentId, studentIds))
|
||||||
|
return rows.map((r) => ({ parentId: r.parentId, studentId: r.studentId }))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -104,12 +104,14 @@ export type ParentAttendanceStatus =
|
|||||||
| "late"
|
| "late"
|
||||||
| "early_leave"
|
| "early_leave"
|
||||||
| "excused"
|
| "excused"
|
||||||
|
| "school_activity"
|
||||||
|
|
||||||
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
|
/** 家长视角所需的单条考勤记录(仅保留展示字段)。 */
|
||||||
export type ParentAttendanceListItem = {
|
export type ParentAttendanceListItem = {
|
||||||
id: string
|
id: string
|
||||||
date: string
|
date: string
|
||||||
status: ParentAttendanceStatus
|
status: ParentAttendanceStatus
|
||||||
|
reason: string | null
|
||||||
remark: string | null
|
remark: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user