feat(modules-edu): update ai, adaptive-practice, announcements, attendance, audit, auth

- ai: update chat-panel, child-summary, error-book-analysis, grading-assist,

  lesson-content-generator, markdown-renderer, question-variant-generator,

  study-path, usage-dashboard, use-ai-chat-stream, use-position-persistence

- adaptive-practice: update practice-result-view, practice-session-view,

  practice-starter, data-access-analytics, lib/type-guards

- announcements: update announcement-card, detail, form, data-access

- attendance: update attendance-record-list, attendance-rules-form,

  attendance-sheet, data-access

- audit: update actions, audit-log-export-button, audit-retention-settings,

  data-access

- auth: update login-form, register-form, data-access
This commit is contained in:
SpecialX
2026-07-07 16:18:29 +08:00
parent 224740ad98
commit b090a815ae
31 changed files with 259 additions and 214 deletions

View File

@@ -23,7 +23,6 @@ interface PracticeResultViewProps {
export function PracticeResultView({ session }: PracticeResultViewProps): React.ReactNode { export function PracticeResultView({ session }: PracticeResultViewProps): React.ReactNode {
const t = useTranslations("practice") const t = useTranslations("practice")
const total = session.totalQuestions
const answered = session.answeredQuestions const answered = session.answeredQuestions
const correct = session.correctCount const correct = session.correctCount
const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0 const accuracy = answered > 0 ? Math.round((correct / answered) * 100) : 0

View File

@@ -4,7 +4,7 @@ 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, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react" import { CheckCircle2, ChevronLeft, ChevronRight, Flag, Trophy } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
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"
@@ -128,7 +128,7 @@ export function PracticeSessionView({
return next return next
}) })
analytics.trackAnswerSubmit(session.id, current.id, data.isCorrect) analytics.trackAnswerSubmit(session.id, current.id, data.isCorrect)
toast.success(res.message ?? t("toasts.submitted")) notify.success(res.message ?? t("toasts.submitted"))
// 自动跳到下一题 // 自动跳到下一题
if (currentIndex < total - 1) { if (currentIndex < total - 1) {
setCurrentIndex(currentIndex + 1) setCurrentIndex(currentIndex + 1)
@@ -136,7 +136,7 @@ export function PracticeSessionView({
} else { } else {
// 标记提交失败,显示重试按钮 // 标记提交失败,显示重试按钮
setFailedAnswerIds((prev) => new Set(prev).add(current.id)) setFailedAnswerIds((prev) => new Set(prev).add(current.id))
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed")) notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.submitFailed"))
} }
}) })
} }
@@ -148,10 +148,10 @@ export function PracticeSessionView({
const res = await service.completeSession(undefined, formData) const res = await service.completeSession(undefined, formData)
if (res.success) { if (res.success) {
analytics.trackSessionComplete(session.id, session.accuracy) analytics.trackSessionComplete(session.id, session.accuracy)
toast.success(res.message ?? t("toasts.completed")) notify.success(res.message ?? t("toasts.completed"))
router.refresh() router.refresh()
} else { } else {
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed")) notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.completeFailed"))
} }
}) })
} }
@@ -163,7 +163,7 @@ export function PracticeSessionView({
const res = await service.abandonSession(undefined, formData) const res = await service.abandonSession(undefined, formData)
if (res.success) { if (res.success) {
analytics.trackSessionAbandon(session.id) analytics.trackSessionAbandon(session.id)
toast.success(res.message ?? t("toasts.abandoned")) notify.success(res.message ?? t("toasts.abandoned"))
// 优先使用注入的回调,否则回退到默认路由 // 优先使用注入的回调,否则回退到默认路由
if (onAbandoned) { if (onAbandoned) {
onAbandoned() onAbandoned()
@@ -171,7 +171,7 @@ export function PracticeSessionView({
router.push("/student/practice") router.push("/student/practice")
} }
} else { } else {
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed")) notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed"))
} }
}) })
} }

View File

@@ -3,7 +3,7 @@
import { useState, useTransition } from "react" import { useState, useTransition } from "react"
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 { notify } from "@/shared/lib/notify"
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"
@@ -140,10 +140,10 @@ export function PracticeStarter({
const res = await service.createSession(undefined, formData) const res = await service.createSession(undefined, formData)
if (res.success && res.data) { if (res.success && res.data) {
analytics.trackSessionStart(presetMode.type, questionCount) analytics.trackSessionStart(presetMode.type, questionCount)
toast.success(res.message ?? t("toasts.created")) notify.success(res.message ?? t("toasts.created"))
onSessionCreated?.(res.data.sessionId) onSessionCreated?.(res.data.sessionId)
} else { } else {
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed")) notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
} }
}) })
return return
@@ -154,7 +154,7 @@ export function PracticeStarter({
if (practiceType === "knowledge_point") { if (practiceType === "knowledge_point") {
if (selectedKpIds.length === 0) { if (selectedKpIds.length === 0) {
toast.error(t("toasts.selectKnowledgePoint")) notify.error(t("toasts.selectKnowledgePoint"))
return return
} }
sourceMeta = { sourceMeta = {
@@ -165,7 +165,7 @@ export function PracticeStarter({
// 薄弱章节模式:传入选中的知识点作为薄弱知识点 // 薄弱章节模式:传入选中的知识点作为薄弱知识点
// 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点 // 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点
if (selectedKpIds.length === 0) { if (selectedKpIds.length === 0) {
toast.error(t("toasts.selectWeakKnowledgePoint")) notify.error(t("toasts.selectWeakKnowledgePoint"))
return return
} }
sourceMeta = { sourceMeta = {
@@ -192,10 +192,10 @@ export function PracticeStarter({
const res = await service.createSession(undefined, formData) const res = await service.createSession(undefined, formData)
if (res.success && res.data) { if (res.success && res.data) {
analytics.trackSessionStart(practiceType, questionCount) analytics.trackSessionStart(practiceType, questionCount)
toast.success(res.message ?? t("toasts.created")) notify.success(res.message ?? t("toasts.created"))
onSessionCreated?.(res.data.sessionId) onSessionCreated?.(res.data.sessionId)
} else { } else {
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed")) notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
} }
}) })
} }

View File

@@ -11,6 +11,7 @@ import {
getClassNameById, getClassNameById,
getClassesByGradeId, getClassesByGradeId,
} from "@/modules/classes/data-access" } from "@/modules/classes/data-access"
import { getActiveStudentIdsByClassIdsBatch } from "@/modules/classes/data-access-teacher"
import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access" import { getUserIdsByGradeId, getUserNamesByIds } from "@/modules/users/data-access"
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -302,9 +303,11 @@ export interface TeacherClassPracticeOverview {
* *
* 用于教师端分析页面顶部展示每个班级的练习情况。 * 用于教师端分析页面顶部展示每个班级的练习情况。
* *
* N+1 优化: * N+1 优化G2-003
* - 批量获取所有班级名称1 条 SQL 替代 N 条) * - 批量获取所有班级名称1 条 SQL 替代 N 条)
* - 每个班级仅 1 条 SQL 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL * - 批量获取所有班级的活跃学生 ID1 条 SQL 替代 N 条
* - 单条 GROUP BY studentId 聚合所有学生的练习统计1 条 SQL 替代 N 条),
* 再在应用层按班级归并汇总。
* *
* @param classIds 教师/年级主任可访问的班级 ID 列表 * @param classIds 教师/年级主任可访问的班级 ID 列表
*/ */
@@ -316,69 +319,79 @@ export const getTeacherClassPracticeOverviewsRaw = async (
// 批量获取所有班级名称1 条 SQL // 批量获取所有班级名称1 条 SQL
const classNameMap = await getClassNamesByIds(classIds) const classNameMap = await getClassNamesByIds(classIds)
// 并行获取每个班级的学生 ID 列表 // 批量获取所有班级的活跃学生 ID1 条 SQL 替代 N 条)
const studentIdsPerClass = await Promise.all( const studentIdsByClass = await getActiveStudentIdsByClassIdsBatch(classIds)
classIds.map(async (classId) => ({
classId,
studentIds: await getActiveStudentIdsByClassId(classId),
})),
)
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数) // 收集所有去重学生 ID
const statsPerClass = await Promise.all( const allStudentIds = new Set<string>()
studentIdsPerClass.map(async ({ classId, studentIds }) => { for (const ids of studentIdsByClass.values()) {
const totalStudents = studentIds.length for (const id of ids) allStudentIds.add(id)
if (totalStudents === 0) { }
return {
classId,
totalStudents: 0,
totalSessions: 0,
completedSessions: 0,
totalQuestionsAnswered: 0,
totalCorrect: 0,
activeStudents: 0,
}
}
const rows = await db // 单条 GROUP BY studentId 聚合所有学生的练习统计1 条 SQL 替代 N 条)
.select({ const studentStatsMap = new Map<string, {
totalSessions: count(), totalSessions: number
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`), completedSessions: number
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`, totalQuestionsAnswered: number
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`, totalCorrect: number
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`, }>()
})
.from(practiceSessions)
.where(inArray(practiceSessions.studentId, studentIds))
const row = rows[0] if (allStudentIds.size > 0) {
return { const statsRows = await db
classId, .select({
totalStudents, studentId: practiceSessions.studentId,
totalSessions: Number(row?.totalSessions ?? 0), totalSessions: count(),
completedSessions: Number(row?.completedSessions ?? 0), completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0), totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
totalCorrect: Number(row?.totalCorrect ?? 0), totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
activeStudents: Number(row?.activeStudents ?? 0), })
} .from(practiceSessions)
}), .where(inArray(practiceSessions.studentId, Array.from(allStudentIds)))
) .groupBy(practiceSessions.studentId)
for (const r of statsRows) {
studentStatsMap.set(r.studentId, {
totalSessions: Number(r.totalSessions),
completedSessions: Number(r.completedSessions),
totalQuestionsAnswered: Number(r.totalQuestionsAnswered),
totalCorrect: Number(r.totalCorrect),
})
}
}
// 在应用层按班级归并汇总(替代原先 N 条 SQL
return classIds.map((classId) => {
const studentIds = studentIdsByClass.get(classId) ?? []
const totalStudents = studentIds.length
let totalSessions = 0
let completedSessions = 0
let totalQuestionsAnswered = 0
let totalCorrect = 0
let activeStudents = 0
for (const studentId of studentIds) {
const s = studentStatsMap.get(studentId)
if (!s) continue
// 有练习记录的学生计为活跃
if (s.totalSessions > 0) activeStudents += 1
totalSessions += s.totalSessions
completedSessions += s.completedSessions
totalQuestionsAnswered += s.totalQuestionsAnswered
totalCorrect += s.totalCorrect
}
return statsPerClass.map((s) => {
const totalQuestionsAnswered = s.totalQuestionsAnswered
const totalCorrect = s.totalCorrect
const activeStudents = s.activeStudents
return { return {
classId: s.classId, classId,
className: classNameMap.get(s.classId) ?? "", className: classNameMap.get(classId) ?? "",
totalSessions: s.totalSessions, totalSessions,
completedSessions: s.completedSessions, completedSessions,
totalQuestionsAnswered, totalQuestionsAnswered,
totalCorrect, totalCorrect,
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0, averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
activeStudents, activeStudents,
totalStudents: s.totalStudents, totalStudents,
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0, participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
} }
}) })
} }

View File

@@ -19,35 +19,35 @@ import type {
PracticeType, PracticeType,
} from "../types" } from "../types"
const PRACTICE_TYPES = new Set<PracticeType>([ const PRACTICE_TYPES = new Set<string>([
"error_variant", "error_variant",
"knowledge_point", "knowledge_point",
"weak_chapter", "weak_chapter",
"ai_recommended", "ai_recommended",
]) ])
const PRACTICE_STATUSES = new Set<PracticeStatus>([ const PRACTICE_STATUSES = new Set<string>([
"in_progress", "in_progress",
"completed", "completed",
"abandoned", "abandoned",
]) ])
const PRACTICE_ANSWER_STATUSES = new Set<PracticeAnswerStatus>([ const PRACTICE_ANSWER_STATUSES = new Set<string>([
"pending", "pending",
"answered", "answered",
"skipped", "skipped",
]) ])
export function isPracticeType(value: unknown): value is PracticeType { export function isPracticeType(value: unknown): value is PracticeType {
return typeof value === "string" && PRACTICE_TYPES.has(value as PracticeType) return typeof value === "string" && PRACTICE_TYPES.has(value)
} }
export function isPracticeStatus(value: unknown): value is PracticeStatus { export function isPracticeStatus(value: unknown): value is PracticeStatus {
return typeof value === "string" && PRACTICE_STATUSES.has(value as PracticeStatus) return typeof value === "string" && PRACTICE_STATUSES.has(value)
} }
export function isPracticeAnswerStatus(value: unknown): value is PracticeAnswerStatus { export function isPracticeAnswerStatus(value: unknown): value is PracticeAnswerStatus {
return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value as PracticeAnswerStatus) return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value)
} }
/** /**

View File

@@ -3,7 +3,7 @@
import { useState, useRef, useEffect, useCallback } from "react" import { useState, useRef, useEffect, useCallback } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Bot, Trash2 } from "lucide-react" import { Bot, Trash2 } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
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"
@@ -107,7 +107,7 @@ export function AiChatPanel({
const handleClear = (): void => { const handleClear = (): void => {
if (window.confirm(t("chat.clearConfirm"))) { if (window.confirm(t("chat.clearConfirm"))) {
clear() clear()
toast.success(t("chat.clear")) notify.success(t("chat.clear"))
} }
} }

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Sparkles, TrendingUp, Lightbulb, CheckCircle, AlertCircle } from "lucide-react" import { Sparkles, TrendingUp, Lightbulb, CheckCircle, AlertCircle } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
@@ -49,7 +49,7 @@ export function AiChildSummary({
const handleGenerate = async (): Promise<void> => { const handleGenerate = async (): Promise<void> => {
if (!aiClient.generateChildSummary) { if (!aiClient.generateChildSummary) {
toast.error(t("parent.error")) notify.error(t("parent.error"))
return return
} }
setLoading(true) setLoading(true)
@@ -64,12 +64,12 @@ export function AiChildSummary({
}) })
if (result.success && result.data) { if (result.success && result.data) {
setSummary(result.data) setSummary(result.data)
toast.success(t("parent.summary")) notify.success(t("parent.summary"))
} else { } else {
toast.error(result.message ?? t("parent.error")) notify.error(result.message ?? t("parent.error"))
} }
} catch { } catch {
toast.error(t("parent.error")) notify.error(t("parent.error"))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Sparkles, Lightbulb, BookOpen, TrendingDown } from "lucide-react" import { Sparkles, Lightbulb, BookOpen, TrendingDown } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
@@ -70,12 +70,12 @@ export function AiErrorBookAnalysis({
}) })
if (result.success && result.data) { if (result.success && result.data) {
setSimilarQuestions(result.data) setSimilarQuestions(result.data)
toast.success(t("suggestion.loaded")) notify.success(t("suggestion.loaded"))
} else { } else {
toast.error(result.message ?? t("suggestion.error")) notify.error(result.message ?? t("suggestion.error"))
} }
} catch { } catch {
toast.error(t("suggestion.error")) notify.error(t("suggestion.error"))
} finally { } finally {
setSimilarLoading(false) setSimilarLoading(false)
} }
@@ -92,12 +92,12 @@ export function AiErrorBookAnalysis({
}) })
if (result.success && result.data) { if (result.success && result.data) {
setWeaknessResult(result.data) setWeaknessResult(result.data)
toast.success(t("errorBook.weaknessAnalysis")) notify.success(t("errorBook.weaknessAnalysis"))
} else { } else {
toast.error(result.message ?? t("error.analysisFailed")) notify.error(result.message ?? t("error.analysisFailed"))
} }
} catch { } catch {
toast.error(t("error.analysisFailed")) notify.error(t("error.analysisFailed"))
} finally { } finally {
setWeaknessLoading(false) setWeaknessLoading(false)
} }

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Sparkles, Check } from "lucide-react" import { Sparkles, Check } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
@@ -73,12 +73,12 @@ export function AiGradingAssist({
}) })
if (result.success && result.data) { if (result.success && result.data) {
setSuggestion(result.data) setSuggestion(result.data)
toast.success(t("grading.title")) notify.success(t("grading.title"))
} else { } else {
toast.error(result.message ?? t("grading.error")) notify.error(result.message ?? t("grading.error"))
} }
} catch { } catch {
toast.error(t("grading.error")) notify.error(t("grading.error"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -134,7 +134,7 @@ export function AiGradingAssist({
size="sm" size="sm"
onClick={() => { onClick={() => {
onApplyScore(suggestion.suggestedScore) onApplyScore(suggestion.suggestedScore)
toast.success(t("grading.suggestedScore")) notify.success(t("grading.suggestedScore"))
}} }}
> >
<Check className="mr-1 h-3.5 w-3.5" /> <Check className="mr-1 h-3.5 w-3.5" />
@@ -148,7 +148,7 @@ export function AiGradingAssist({
size="sm" size="sm"
onClick={() => { onClick={() => {
onApplyFeedback(suggestion.feedback) onApplyFeedback(suggestion.feedback)
toast.success(t("grading.feedback")) notify.success(t("grading.feedback"))
}} }}
> >
<Check className="mr-1 h-3.5 w-3.5" /> <Check className="mr-1 h-3.5 w-3.5" />

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Sparkles, BookOpen, Lightbulb, HelpCircle, FileText } from "lucide-react" import { Sparkles, BookOpen, Lightbulb, HelpCircle, FileText } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
@@ -56,7 +56,7 @@ export function AiLessonContentGenerator({
const handleGenerate = async (): Promise<void> => { const handleGenerate = async (): Promise<void> => {
if (!topic.trim()) { if (!topic.trim()) {
toast.error(t("lessonPrep.error")) notify.error(t("lessonPrep.error"))
return return
} }
setLoading(true) setLoading(true)
@@ -72,12 +72,12 @@ export function AiLessonContentGenerator({
}) })
if (response.success && response.data) { if (response.success && response.data) {
setResult(response.data) setResult(response.data)
toast.success(t("lessonPrep.generateContent")) notify.success(t("lessonPrep.generateContent"))
} else { } else {
toast.error(response.message ?? t("lessonPrep.error")) notify.error(response.message ?? t("lessonPrep.error"))
} }
} catch { } catch {
toast.error(t("lessonPrep.error")) notify.error(t("lessonPrep.error"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -165,7 +165,7 @@ export function AiLessonContentGenerator({
size="sm" size="sm"
onClick={() => { onClick={() => {
onInsertContent(result) onInsertContent(result)
toast.success(t("lessonPrep.insertContent")) notify.success(t("lessonPrep.insertContent"))
}} }}
> >
{t("lessonPrep.insertContent")} {t("lessonPrep.insertContent")}

View File

@@ -5,7 +5,7 @@ import ReactMarkdown from "react-markdown"
import remarkGfm from "remark-gfm" import remarkGfm from "remark-gfm"
import { Copy, Check } from "lucide-react" import { Copy, Check } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { cn } from "@/shared/lib/utils" import { cn } from "@/shared/lib/utils"
@@ -73,10 +73,10 @@ function AiMarkdownRendererImpl({
try { try {
await navigator.clipboard.writeText(content) await navigator.clipboard.writeText(content)
setCopied(true) setCopied(true)
toast.success(t("chat.copied")) notify.success(t("chat.copied"))
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000)
} catch { } catch {
toast.error(t("error.chatFailed")) notify.error(t("error.chatFailed"))
} }
}, [content, t]) }, [content, t])

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Sparkles, RefreshCw, Plus } from "lucide-react" import { Sparkles, RefreshCw, Plus } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
@@ -22,10 +22,10 @@ import type { QuestionVariantResult } from "@/modules/ai/types"
type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format" type VariantType = "same_knowledge_point" | "different_difficulty" | "different_format"
const VARIANT_TYPES: readonly VariantType[] = ["same_knowledge_point", "different_difficulty", "different_format"] const VARIANT_TYPES: readonly string[] = ["same_knowledge_point", "different_difficulty", "different_format"]
const isVariantType = (value: string): value is VariantType => { const isVariantType = (value: string): value is VariantType => {
return VARIANT_TYPES.includes(value as VariantType) return VARIANT_TYPES.includes(value)
} }
type AiQuestionVariantGeneratorProps = { type AiQuestionVariantGeneratorProps = {
@@ -66,7 +66,7 @@ export function AiQuestionVariantGenerator({
const handleGenerate = async (): Promise<void> => { const handleGenerate = async (): Promise<void> => {
if (!originalQuestion.text.trim()) { if (!originalQuestion.text.trim()) {
toast.error(t("error.invalidInput")) notify.error(t("error.invalidInput"))
return return
} }
setLoading(true) setLoading(true)
@@ -78,12 +78,12 @@ export function AiQuestionVariantGenerator({
}) })
if (result.success && result.data) { if (result.success && result.data) {
setVariant(result.data) setVariant(result.data)
toast.success(t("exam.generate")) notify.success(t("exam.generate"))
} else { } else {
toast.error(result.message ?? t("error.variantFailed")) notify.error(result.message ?? t("error.variantFailed"))
} }
} catch { } catch {
toast.error(t("error.variantFailed")) notify.error(t("error.variantFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -192,7 +192,7 @@ export function AiQuestionVariantGenerator({
size="sm" size="sm"
onClick={() => { onClick={() => {
onAddVariant(variant) onAddVariant(variant)
toast.success(t("exam.generate")) notify.success(t("exam.generate"))
}} }}
> >
<Plus className="mr-1 h-3.5 w-3.5" /> <Plus className="mr-1 h-3.5 w-3.5" />

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Sparkles, CheckCircle, Clock, AlertCircle, Target } from "lucide-react" import { Sparkles, CheckCircle, Clock, AlertCircle, Target } from "lucide-react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
@@ -51,7 +51,7 @@ export function AiStudyPath({
const handleGenerate = async (): Promise<void> => { const handleGenerate = async (): Promise<void> => {
if (!aiClient.recommendStudyPath) { if (!aiClient.recommendStudyPath) {
toast.error(t("studyPath.error")) notify.error(t("studyPath.error"))
return return
} }
setLoading(true) setLoading(true)
@@ -65,12 +65,12 @@ export function AiStudyPath({
}) })
if (result.success && result.data) { if (result.success && result.data) {
setPath(result.data) setPath(result.data)
toast.success(t("studyPath.title")) notify.success(t("studyPath.title"))
} else { } else {
toast.error(result.message ?? t("studyPath.error")) notify.error(result.message ?? t("studyPath.error"))
} }
} catch { } catch {
toast.error(t("studyPath.error")) notify.error(t("studyPath.error"))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -3,7 +3,7 @@
import { useState, useEffect, useCallback } 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 { notify } from "@/shared/lib/notify"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
import { Badge } from "@/shared/components/ui/badge" import { Badge } from "@/shared/components/ui/badge"
@@ -41,10 +41,10 @@ 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.statsFailed")) notify.error(result.message ?? t("error.statsFailed"))
} }
} catch { } catch {
toast.error(t("error.statsFailed")) notify.error(t("error.statsFailed"))
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -29,15 +29,25 @@ type UseAiChatStreamReturn = {
clear: () => void clear: () => void
} }
function isAiChatMessage(value: unknown): value is AiChatMessage {
if (value === null || typeof value !== "object") return false
return (
"role" in value &&
(value.role === "system" || value.role === "user" || value.role === "assistant") &&
"content" in value &&
typeof value.content === "string"
)
}
function loadHistory(): AiChatMessage[] { function loadHistory(): AiChatMessage[] {
if (typeof window === "undefined") return [] if (typeof window === "undefined") return []
try { try {
const raw = localStorage.getItem(HISTORY_STORAGE_KEY) const raw = localStorage.getItem(HISTORY_STORAGE_KEY)
if (!raw) return [] if (!raw) return []
const parsed = JSON.parse(raw) as AiChatMessage[] const parsed: unknown = JSON.parse(raw)
if (!Array.isArray(parsed)) return [] if (!Array.isArray(parsed)) return []
// 过滤掉空的 assistant 消息 // 从 unknown 转换localStorage 数据不可信,用类型守卫过滤不合法的项
return parsed.filter((m) => m && m.role && typeof m.content === "string") return parsed.filter(isAiChatMessage)
} catch { } catch {
return [] return []
} }

View File

@@ -32,8 +32,13 @@ export function loadPosition(): Position {
try { try {
const raw = localStorage.getItem(STORAGE_KEY) const raw = localStorage.getItem(STORAGE_KEY)
if (raw) { if (raw) {
const parsed = JSON.parse(raw) as Partial<Position> const parsed: unknown = JSON.parse(raw)
if (typeof parsed.x === "number" && typeof parsed.y === "number") { // 从 unknown 转换localStorage 数据不可信,做字段类型校验
if (
typeof parsed === "object" && parsed !== null &&
"x" in parsed && typeof parsed.x === "number" &&
"y" in parsed && typeof parsed.y === "number"
) {
return clampPosition({ x: parsed.x, y: parsed.y }) return clampPosition({ x: parsed.x, y: parsed.y })
} }
} }

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Pin } from "lucide-react" import { Pin } from "lucide-react"
@@ -56,17 +56,17 @@ export function AnnouncementCard({
try { try {
const res = await service.togglePin(announcement.id) const res = await service.togglePin(announcement.id)
if (res.success) { if (res.success) {
toast.success(t("messages.pinToggled")) notify.success(t("messages.pinToggled"))
router.refresh() router.refresh()
} else { } else {
// 回滚 // 回滚
setIsPinned(prevPinned) setIsPinned(prevPinned)
toast.error(res.message) notify.error(res.message)
} }
} catch { } catch {
// 回滚 // 回滚
setIsPinned(prevPinned) setIsPinned(prevPinned)
toast.error(t("messages.publishFailed")) notify.error(t("messages.publishFailed"))
} finally { } finally {
setIsToggling(false) setIsToggling(false)
} }

View File

@@ -3,7 +3,7 @@
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import Link from "next/link" import Link from "next/link"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { import {
Archive, Archive,
@@ -72,13 +72,13 @@ export function AnnouncementDetail({
try { try {
const res = await service.publish(announcement.id) const res = await service.publish(announcement.id)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("messages.publishFailed")) notify.error(res.message || t("messages.publishFailed"))
} }
} catch { } catch {
toast.error(t("messages.publishFailed")) notify.error(t("messages.publishFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -89,13 +89,13 @@ export function AnnouncementDetail({
try { try {
const res = await service.archive(announcement.id) const res = await service.archive(announcement.id)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("messages.archiveFailed")) notify.error(res.message || t("messages.archiveFailed"))
} }
} catch { } catch {
toast.error(t("messages.archiveFailed")) notify.error(t("messages.archiveFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }
@@ -106,14 +106,14 @@ export function AnnouncementDetail({
try { try {
const res = await service.delete(announcement.id) const res = await service.delete(announcement.id)
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
router.push("/admin/announcements") router.push("/admin/announcements")
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("messages.deleteFailed")) notify.error(res.message || t("messages.deleteFailed"))
} }
} catch { } catch {
toast.error(t("messages.deleteFailed")) notify.error(t("messages.deleteFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
setDeleteOpen(false) setDeleteOpen(false)
@@ -128,17 +128,17 @@ export function AnnouncementDetail({
try { try {
const res = await service.togglePin(announcement.id) const res = await service.togglePin(announcement.id)
if (res.success) { if (res.success) {
toast.success(t("messages.pinToggled")) notify.success(t("messages.pinToggled"))
router.refresh() router.refresh()
} else { } else {
// 回滚 // 回滚
setIsPinned(prevPinned) setIsPinned(prevPinned)
toast.error(res.message) notify.error(res.message)
} }
} catch { } catch {
// 回滚 // 回滚
setIsPinned(prevPinned) setIsPinned(prevPinned)
toast.error(t("messages.publishFailed")) notify.error(t("messages.publishFailed"))
} finally { } finally {
setIsTogglingPin(false) setIsTogglingPin(false)
} }

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
@@ -110,22 +110,22 @@ export function AnnouncementForm({
: null : null
if (!res) { if (!res) {
toast.error(t("messages.invalidState")) notify.error(t("messages.invalidState"))
return return
} }
if (res.success) { if (res.success) {
toast.success(res.message) notify.success(res.message)
handleSuccess() handleSuccess()
} else { } else {
// 展示字段级错误(来自 Zod superRefine 校验) // 展示字段级错误(来自 Zod superRefine 校验)
if (res.errors) { if (res.errors) {
setFieldErrors(res.errors) setFieldErrors(res.errors)
} }
toast.error(res.message || t("messages.updateFailed")) notify.error(res.message || t("messages.updateFailed"))
} }
} catch { } catch {
toast.error(t("messages.updateFailed")) notify.error(t("messages.updateFailed"))
} finally { } finally {
setIsWorking(false) setIsWorking(false)
} }

View File

@@ -7,6 +7,7 @@ import { db } from "@/shared/db"
import { announcements, announcementReads, users } from "@/shared/db/schema" import { announcements, announcementReads, users } from "@/shared/db/schema"
import type { DataScope } from "@/shared/types/permissions" import type { DataScope } from "@/shared/types/permissions"
import { cacheFn } from "@/shared/lib/cache" import { cacheFn } from "@/shared/lib/cache"
import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
import type { import type {
Announcement, Announcement,
AnnouncementInsertData, AnnouncementInsertData,
@@ -22,11 +23,6 @@ type AnnouncementRow = typeof announcements.$inferSelect & {
authorName: string | null authorName: string | null
} }
const toIso = (d: Date | null | undefined): string | null =>
d ? d.toISOString() : null
const toIsoRequired = (d: Date): string => d.toISOString()
const mapRow = (row: AnnouncementRow): Announcement => ({ const mapRow = (row: AnnouncementRow): Announcement => ({
id: row.id, id: row.id,
title: row.title, title: row.title,

View File

@@ -1,7 +1,7 @@
"use client" "use client"
import { useState } from "react" import { useState } from "react"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { Trash2, Inbox } from "lucide-react" import { Trash2, Inbox } from "lucide-react"
@@ -60,14 +60,14 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
try { try {
const result = await deleteAttendanceAction(deleteId) const result = await deleteAttendanceAction(deleteId)
if (result.success) { if (result.success) {
toast.success(result.message || t("sheet.deleted")) notify.success(result.message || t("sheet.deleted"))
setDeleteId(null) setDeleteId(null)
router.refresh() router.refresh()
} else { } else {
toast.error(resolveActionError(result, t, t("errors.unexpected"))) notify.error(resolveActionError(result, t, t("errors.unexpected")))
} }
} catch { } catch {
toast.error(t("errors.unexpected")) notify.error(t("errors.unexpected"))
} finally { } finally {
setIsDeleting(false) setIsDeleting(false)
} }

View File

@@ -2,7 +2,7 @@
import { useState } from "react" import { useState } from "react"
import { useFormStatus } from "react-dom" import { useFormStatus } from "react-dom"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
@@ -71,7 +71,7 @@ export function AttendanceRulesForm({
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (!classId) { if (!classId) {
toast.error(t("sheet.selectClass")) notify.error(t("sheet.selectClass"))
return return
} }
formData.set("classId", classId) formData.set("classId", classId)
@@ -84,13 +84,13 @@ export function AttendanceRulesForm({
try { try {
const result = await saveAttendanceRulesAction(null, formData) const result = await saveAttendanceRulesAction(null, formData)
if (result.success) { if (result.success) {
toast.success(result.message || t("rules.saved")) notify.success(result.message || t("rules.saved"))
router.refresh() router.refresh()
} else { } else {
toast.error(resolveActionError(result, t, t("errors.unexpected"))) notify.error(resolveActionError(result, t, t("errors.unexpected")))
} }
} catch { } catch {
toast.error(t("errors.unexpected")) notify.error(t("errors.unexpected"))
} }
} }

View File

@@ -2,7 +2,7 @@
import { useState, useRef, useEffect, useCallback, useMemo, useOptimistic } from "react" import { useState, useRef, useEffect, useCallback, useMemo, useOptimistic } from "react"
import { useFormStatus } from "react-dom" import { useFormStatus } from "react-dom"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react" import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react"
@@ -48,6 +48,7 @@ import {
type AttendanceStatus, type AttendanceStatus,
} from "../constants" } from "../constants"
import type { AttendancePeriod } from "../types" import type { AttendancePeriod } from "../types"
import { isAttendancePeriod } from "../lib/type-guards"
type Option = { id: string; name: string } type Option = { id: string; name: string }
type Student = { id: string; name: string; email: string } type Student = { id: string; name: string; email: string }
@@ -153,7 +154,7 @@ export function AttendanceSheet({
for (const s of students) all[s.id] = "present" for (const s of students) all[s.id] = "present"
setStatuses(all) setStatuses(all)
setReasons({}) setReasons({})
toast.success(t("actions.markAllPresent")) notify.success(t("actions.markAllPresent"))
}, [students, t]) }, [students, t])
const handleClassChange = (newClassId: string) => { const handleClassChange = (newClassId: string) => {
@@ -236,7 +237,7 @@ export function AttendanceSheet({
const handleSubmit = async (formData: FormData) => { const handleSubmit = async (formData: FormData) => {
if (!classId || !date) { if (!classId || !date) {
toast.error(t("errors.invalidForm")) notify.error(t("errors.invalidForm"))
return return
} }
@@ -258,7 +259,7 @@ export function AttendanceSheet({
}) })
if (records.length === 0) { if (records.length === 0) {
toast.error(t("sheet.noStudents")) notify.error(t("sheet.noStudents"))
return return
} }
@@ -269,14 +270,14 @@ export function AttendanceSheet({
try { try {
const result = await batchRecordAttendanceAction(null, formData) const result = await batchRecordAttendanceAction(null, formData)
if (result.success) { if (result.success) {
toast.success(result.message || t("sheet.saved")) notify.success(result.message || t("sheet.saved"))
router.push("/teacher/attendance") router.push("/teacher/attendance")
router.refresh() router.refresh()
} else { } else {
toast.error(resolveActionError(result, t, t("errors.unexpected"))) notify.error(resolveActionError(result, t, t("errors.unexpected")))
} }
} catch { } catch {
toast.error(t("errors.unexpected")) notify.error(t("errors.unexpected"))
} }
} }
@@ -334,7 +335,7 @@ export function AttendanceSheet({
{/* L-6节次选择器允许按节次记录考勤 */} {/* L-6节次选择器允许按节次记录考勤 */}
<div className="grid gap-2"> <div className="grid gap-2">
<Label htmlFor="period-select">{t("period.label")}</Label> <Label htmlFor="period-select">{t("period.label")}</Label>
<Select value={period} onValueChange={(v) => setPeriod(v as AttendancePeriod)}> <Select value={period} onValueChange={(v) => setPeriod(isAttendancePeriod(v) ? v : "full_day")}>
<SelectTrigger id="period-select"> <SelectTrigger id="period-select">
<SelectValue placeholder={t("period.label")} /> <SelectValue placeholder={t("period.label")} />
</SelectTrigger> </SelectTrigger>

View File

@@ -59,7 +59,9 @@ const serializeDate = (d: Date | string | null): string =>
function buildPeriodFilter(period?: AttendancePeriod): SQL | null { function buildPeriodFilter(period?: AttendancePeriod): SQL | null {
if (!period) return null if (!period) return null
if (period === "full_day") { if (period === "full_day") {
return or(isNull(attendanceRecords.period), eq(attendanceRecords.period, "full_day")) as SQL // Drizzle ORM 的 or() 返回 SQL | undefined用 ?? null 统一为 SQL | null
const result = or(isNull(attendanceRecords.period), eq(attendanceRecords.period, "full_day"))
return result ?? null
} }
return eq(attendanceRecords.period, period) return eq(attendanceRecords.period, period)
} }
@@ -310,7 +312,17 @@ export async function getAttendanceRulesRaw(classId?: string): Promise<Attendanc
if (classCondition) conditions.push(classCondition) if (classCondition) conditions.push(classCondition)
} }
const rows = await db const rows = await db
.select() .select({
id: attendanceRules.id,
classId: attendanceRules.classId,
lateThresholdMinutes: attendanceRules.lateThresholdMinutes,
earlyLeaveThresholdMinutes: attendanceRules.earlyLeaveThresholdMinutes,
enableAutoMark: attendanceRules.enableAutoMark,
attendanceRateThreshold: attendanceRules.attendanceRateThreshold,
consecutiveAbsenceThreshold: attendanceRules.consecutiveAbsenceThreshold,
createdAt: attendanceRules.createdAt,
updatedAt: attendanceRules.updatedAt,
})
.from(attendanceRules) .from(attendanceRules)
.where(conditions.length > 0 ? and(...conditions) : undefined) .where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(attendanceRules.createdAt)) .orderBy(desc(attendanceRules.createdAt))
@@ -336,7 +348,7 @@ export const getAttendanceRules = cacheFn(getAttendanceRulesRaw, {
export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<string> { export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<string> {
const [existing] = await db const [existing] = await db
.select() .select({ id: attendanceRules.id })
.from(attendanceRules) .from(attendanceRules)
.where(eq(attendanceRules.classId, data.classId)) .where(eq(attendanceRules.classId, data.classId))
.limit(1) .limit(1)

View File

@@ -164,7 +164,8 @@ export async function saveAuditRetentionConfigAction(
config: AuditRetentionConfig, config: AuditRetentionConfig,
): Promise<ActionState<AuditRetentionConfig>> { ): Promise<ActionState<AuditRetentionConfig>> {
try { try {
await requirePermission(Permissions.AUDIT_LOG_READ) // 审计 v1 P0 修复G4-004保留策略是写操作改用专用写权限点
await requirePermission(Permissions.AUDIT_RETENTION_MANAGE)
const session = await getSession() const session = await getSession()
await saveAuditRetentionConfig(config, session?.user?.id) await saveAuditRetentionConfig(config, session?.user?.id)
@@ -194,7 +195,9 @@ export async function purgeAuditLogsAction(
loginLogRetentionDays?: number, loginLogRetentionDays?: number,
): Promise<ActionState<PurgeResult>> { ): Promise<ActionState<PurgeResult>> {
try { try {
await requirePermission(Permissions.AUDIT_LOG_READ) // 审计 v1 P0 修复G4-003purge 是破坏性操作(物理删除审计日志),
// 读权限不应授予删除能力。改用专用 PURGE 权限点(仅 admin
await requirePermission(Permissions.AUDIT_LOG_PURGE)
const session = await getSession() const session = await getSession()
// audit-P2-5: 未显式传入时读取配置(含 loginLogRetentionDays // audit-P2-5: 未显式传入时读取配置(含 loginLogRetentionDays

View File

@@ -3,7 +3,7 @@
import { useState } from "react" import { useState } from "react"
import { Download, Loader2 } from "lucide-react" import { Download, Loader2 } from "lucide-react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Button } from "@/shared/components/ui/button" import { Button } from "@/shared/components/ui/button"
import { downloadBlob } from "@/shared/lib/download" import { downloadBlob } from "@/shared/lib/download"
@@ -45,7 +45,7 @@ export function AuditLogExportButton({
if (!res.ok) { if (!res.ok) {
const body = await res.json().catch(() => null) const body = await res.json().catch(() => null)
toast.error(body?.message || t("export.failed")) notify.error(body?.message || t("export.failed"))
return return
} }
@@ -56,9 +56,9 @@ export function AuditLogExportButton({
const blob = await res.blob() const blob = await res.blob()
downloadBlob(blob, filename) downloadBlob(blob, filename)
analytics.trackExport(exportType, 0) analytics.trackExport(exportType, 0)
toast.success(t("export.success")) notify.success(t("export.success"))
} catch { } catch {
toast.error(t("export.failed")) notify.error(t("export.failed"))
} finally { } finally {
setIsPending(false) setIsPending(false)
} }

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, type ReactNode } from "react" import { useState, useEffect, type ReactNode } from "react"
import { useTranslations } from "next-intl" import { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
import { Save, Trash2, Loader2 } from "lucide-react" import { Save, Trash2, Loader2 } from "lucide-react"
import { import {
@@ -41,7 +41,7 @@ export function AuditRetentionSettings(): ReactNode {
if (cancelled) return if (cancelled) return
setConfig(data) setConfig(data)
} catch { } catch {
if (!cancelled) toast.error(t("saveFailed")) if (!cancelled) notify.error(t("saveFailed"))
} finally { } finally {
if (!cancelled) setIsLoading(false) if (!cancelled) setIsLoading(false)
} }
@@ -58,9 +58,9 @@ export function AuditRetentionSettings(): ReactNode {
try { try {
await service.saveAuditRetentionConfig(config) await service.saveAuditRetentionConfig(config)
analytics.trackRetentionConfigChange(config) analytics.trackRetentionConfigChange(config)
toast.success(t("saveSuccess")) notify.success(t("saveSuccess"))
} catch { } catch {
toast.error(t("saveFailed")) notify.error(t("saveFailed"))
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
@@ -78,7 +78,7 @@ export function AuditRetentionSettings(): ReactNode {
config.loginLogRetentionDays, config.loginLogRetentionDays,
) )
analytics.trackPurge(result) analytics.trackPurge(result)
toast.success( notify.success(
t("purgeSuccess", { t("purgeSuccess", {
auditLogsDeleted: result.auditLogsDeleted, auditLogsDeleted: result.auditLogsDeleted,
loginLogsDeleted: result.loginLogsDeleted, loginLogsDeleted: result.loginLogsDeleted,
@@ -86,7 +86,7 @@ export function AuditRetentionSettings(): ReactNode {
}), }),
) )
} catch { } catch {
toast.error(t("purgeFailed")) notify.error(t("purgeFailed"))
} finally { } finally {
setIsPurging(false) setIsPurging(false)
} }

View File

@@ -4,6 +4,7 @@ import { and, asc, desc, eq, gte, lte, count, like, type SQL, sql } from "drizzl
import { db } from "@/shared/db" import { db } from "@/shared/db"
import { cacheFn } from "@/shared/lib/cache" import { cacheFn } from "@/shared/lib/cache"
import { serializeDateRequired as toIso } from "@/shared/lib/date-utils"
import { createModuleLogger } from "@/shared/lib/logger" import { createModuleLogger } from "@/shared/lib/logger"
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema" import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
import type { import type {
@@ -22,8 +23,6 @@ import type {
const log = createModuleLogger("audit") const log = createModuleLogger("audit")
const toIso = (d: Date): string => d.toISOString()
const DEFAULT_PAGE_SIZE = 20 const DEFAULT_PAGE_SIZE = 20
const MAX_PAGE_SIZE = 100 const MAX_PAGE_SIZE = 100

View File

@@ -47,7 +47,8 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
event.preventDefault() event.preventDefault()
setError("") setError("")
const form = event.currentTarget as HTMLFormElement if (!(event.currentTarget instanceof HTMLFormElement)) return
const form = event.currentTarget
const formData = new FormData(form) const formData = new FormData(form)
const email = String(formData.get("email") ?? "").trim() const email = String(formData.get("email") ?? "").trim()
const password = String(formData.get("password") ?? "") const password = String(formData.get("password") ?? "")

View File

@@ -4,7 +4,7 @@ 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 { useTranslations } from "next-intl"
import { toast } from "sonner" import { notify } from "@/shared/lib/notify"
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"
@@ -56,33 +56,34 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
event.preventDefault() event.preventDefault()
if (!agreedTerms) { if (!agreedTerms) {
toast.error(t("toast.needAgreeTerms")) notify.error(t("toast.needAgreeTerms"))
return return
} }
if (isMinor && !agreedGuardian) { if (isMinor && !agreedGuardian) {
toast.error(t("toast.needGuardianConsent")) notify.error(t("toast.needGuardianConsent"))
return return
} }
if (isMinor && !guardianRelation) { if (isMinor && !guardianRelation) {
toast.error(t("toast.needGuardianRelation")) notify.error(t("toast.needGuardianRelation"))
return return
} }
setIsLoading(true) setIsLoading(true)
try { try {
const form = event.currentTarget as HTMLFormElement if (!(event.currentTarget instanceof HTMLFormElement)) return
const form = event.currentTarget
const formData = new FormData(form) const formData = new FormData(form)
const res = await registerAction(formData) const res = await registerAction(formData)
if (res.success) { if (res.success) {
toast.success(res.message || t("toast.createSuccess")) notify.success(res.message || t("toast.createSuccess"))
router.push("/login") router.push("/login")
router.refresh() router.refresh()
} else { } else {
toast.error(res.message || t("toast.createFailed")) notify.error(res.message || t("toast.createFailed"))
} }
} catch { } catch {
toast.error(t("toast.createFailed")) notify.error(t("toast.createFailed"))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }

View File

@@ -51,22 +51,9 @@ export async function createUser(
const normalizedEmail = input.email.trim().toLowerCase() const normalizedEmail = input.email.trim().toLowerCase()
const hashedPassword = normalizeBcryptHash(await hash(input.password, BCRYPT_COST)) 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 // audit-P2-3: 若 input.role 存在(邀请码注册),使用邀请码中的 role否则使用 DEFAULT_REGISTER_ROLE
// F-09: 角色查找前置,失败时立即抛错,避免产生孤儿用户记录
const roleName = input.role ?? DEFAULT_REGISTER_ROLE const roleName = input.role ?? DEFAULT_REGISTER_ROLE
const roleRow = await db.query.roles.findFirst({ const roleRow = await db.query.roles.findFirst({
where: eq(roles.name, roleName), where: eq(roles.name, roleName),
@@ -75,9 +62,27 @@ export async function createUser(
if (!roleRow?.id) { if (!roleRow?.id) {
throw new Error("DEFAULT_ROLE_NOT_FOUND") throw new Error("DEFAULT_ROLE_NOT_FOUND")
} }
await db.insert(usersToRoles).values({
userId, // F-09: 两次 INSERT 必须在同一事务内,保证 users 与 users_to_roles 原子写入
roleId: roleRow.id, const userId = createId()
await db.transaction(async (tx) => {
await tx.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(),
})
await tx.insert(usersToRoles).values({
userId,
roleId: roleRow.id,
})
}) })
return { userId } return { userId }