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:
@@ -23,7 +23,6 @@ interface PracticeResultViewProps {
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useTransition, useMemo } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -128,7 +128,7 @@ export function PracticeSessionView({
|
||||
return next
|
||||
})
|
||||
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) {
|
||||
setCurrentIndex(currentIndex + 1)
|
||||
@@ -136,7 +136,7 @@ export function PracticeSessionView({
|
||||
} else {
|
||||
// 标记提交失败,显示重试按钮
|
||||
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)
|
||||
if (res.success) {
|
||||
analytics.trackSessionComplete(session.id, session.accuracy)
|
||||
toast.success(res.message ?? t("toasts.completed"))
|
||||
notify.success(res.message ?? t("toasts.completed"))
|
||||
router.refresh()
|
||||
} 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)
|
||||
if (res.success) {
|
||||
analytics.trackSessionAbandon(session.id)
|
||||
toast.success(res.message ?? t("toasts.abandoned"))
|
||||
notify.success(res.message ?? t("toasts.abandoned"))
|
||||
// 优先使用注入的回调,否则回退到默认路由
|
||||
if (onAbandoned) {
|
||||
onAbandoned()
|
||||
@@ -171,7 +171,7 @@ export function PracticeSessionView({
|
||||
router.push("/student/practice")
|
||||
}
|
||||
} else {
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed"))
|
||||
notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.abandonFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useTransition } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -140,10 +140,10 @@ export function PracticeStarter({
|
||||
const res = await service.createSession(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
analytics.trackSessionStart(presetMode.type, questionCount)
|
||||
toast.success(res.message ?? t("toasts.created"))
|
||||
notify.success(res.message ?? t("toasts.created"))
|
||||
onSessionCreated?.(res.data.sessionId)
|
||||
} else {
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
}
|
||||
})
|
||||
return
|
||||
@@ -154,7 +154,7 @@ export function PracticeStarter({
|
||||
|
||||
if (practiceType === "knowledge_point") {
|
||||
if (selectedKpIds.length === 0) {
|
||||
toast.error(t("toasts.selectKnowledgePoint"))
|
||||
notify.error(t("toasts.selectKnowledgePoint"))
|
||||
return
|
||||
}
|
||||
sourceMeta = {
|
||||
@@ -165,7 +165,7 @@ export function PracticeStarter({
|
||||
// 薄弱章节模式:传入选中的知识点作为薄弱知识点
|
||||
// 不传 chapterId 时,后端跨所有章节自动识别薄弱知识点
|
||||
if (selectedKpIds.length === 0) {
|
||||
toast.error(t("toasts.selectWeakKnowledgePoint"))
|
||||
notify.error(t("toasts.selectWeakKnowledgePoint"))
|
||||
return
|
||||
}
|
||||
sourceMeta = {
|
||||
@@ -192,10 +192,10 @@ export function PracticeStarter({
|
||||
const res = await service.createSession(undefined, formData)
|
||||
if (res.success && res.data) {
|
||||
analytics.trackSessionStart(practiceType, questionCount)
|
||||
toast.success(res.message ?? t("toasts.created"))
|
||||
notify.success(res.message ?? t("toasts.created"))
|
||||
onSessionCreated?.(res.data.sessionId)
|
||||
} else {
|
||||
toast.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
notify.error(resolveErrorMessage(res.errorCode, res.message, "toasts.createFailed"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getClassNameById,
|
||||
getClassesByGradeId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getActiveStudentIdsByClassIdsBatch } from "@/modules/classes/data-access-teacher"
|
||||
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 同时计算汇总统计与活跃学生数(合并原先 2 条 SQL)
|
||||
* - 批量获取所有班级的活跃学生 ID(1 条 SQL 替代 N 条)
|
||||
* - 单条 GROUP BY studentId 聚合所有学生的练习统计(1 条 SQL 替代 N 条),
|
||||
* 再在应用层按班级归并汇总。
|
||||
*
|
||||
* @param classIds 教师/年级主任可访问的班级 ID 列表
|
||||
*/
|
||||
@@ -316,69 +319,79 @@ export const getTeacherClassPracticeOverviewsRaw = async (
|
||||
// 批量获取所有班级名称(1 条 SQL)
|
||||
const classNameMap = await getClassNamesByIds(classIds)
|
||||
|
||||
// 并行获取每个班级的学生 ID 列表
|
||||
const studentIdsPerClass = await Promise.all(
|
||||
classIds.map(async (classId) => ({
|
||||
classId,
|
||||
studentIds: await getActiveStudentIdsByClassId(classId),
|
||||
})),
|
||||
)
|
||||
// 批量获取所有班级的活跃学生 ID(1 条 SQL 替代 N 条)
|
||||
const studentIdsByClass = await getActiveStudentIdsByClassIdsBatch(classIds)
|
||||
|
||||
// 并行获取每个班级的练习统计(单条 SQL 同时计算汇总 + 活跃学生数)
|
||||
const statsPerClass = await Promise.all(
|
||||
studentIdsPerClass.map(async ({ classId, studentIds }) => {
|
||||
const totalStudents = studentIds.length
|
||||
if (totalStudents === 0) {
|
||||
return {
|
||||
classId,
|
||||
totalStudents: 0,
|
||||
totalSessions: 0,
|
||||
completedSessions: 0,
|
||||
totalQuestionsAnswered: 0,
|
||||
totalCorrect: 0,
|
||||
activeStudents: 0,
|
||||
}
|
||||
}
|
||||
// 收集所有去重学生 ID
|
||||
const allStudentIds = new Set<string>()
|
||||
for (const ids of studentIdsByClass.values()) {
|
||||
for (const id of ids) allStudentIds.add(id)
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
totalSessions: count(),
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
activeStudents: sql<number>`COUNT(DISTINCT ${practiceSessions.studentId})`,
|
||||
})
|
||||
.from(practiceSessions)
|
||||
.where(inArray(practiceSessions.studentId, studentIds))
|
||||
// 单条 GROUP BY studentId 聚合所有学生的练习统计(1 条 SQL 替代 N 条)
|
||||
const studentStatsMap = new Map<string, {
|
||||
totalSessions: number
|
||||
completedSessions: number
|
||||
totalQuestionsAnswered: number
|
||||
totalCorrect: number
|
||||
}>()
|
||||
|
||||
const row = rows[0]
|
||||
return {
|
||||
classId,
|
||||
totalStudents,
|
||||
totalSessions: Number(row?.totalSessions ?? 0),
|
||||
completedSessions: Number(row?.completedSessions ?? 0),
|
||||
totalQuestionsAnswered: Number(row?.totalQuestionsAnswered ?? 0),
|
||||
totalCorrect: Number(row?.totalCorrect ?? 0),
|
||||
activeStudents: Number(row?.activeStudents ?? 0),
|
||||
}
|
||||
}),
|
||||
)
|
||||
if (allStudentIds.size > 0) {
|
||||
const statsRows = await db
|
||||
.select({
|
||||
studentId: practiceSessions.studentId,
|
||||
totalSessions: count(),
|
||||
completedSessions: count(sql`CASE WHEN ${practiceSessions.status} = 'completed' THEN 1 END`),
|
||||
totalQuestionsAnswered: sql<number>`COALESCE(SUM(${practiceSessions.answeredQuestions}), 0)`,
|
||||
totalCorrect: sql<number>`COALESCE(SUM(${practiceSessions.correctCount}), 0)`,
|
||||
})
|
||||
.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 {
|
||||
classId: s.classId,
|
||||
className: classNameMap.get(s.classId) ?? "",
|
||||
totalSessions: s.totalSessions,
|
||||
completedSessions: s.completedSessions,
|
||||
classId,
|
||||
className: classNameMap.get(classId) ?? "",
|
||||
totalSessions,
|
||||
completedSessions,
|
||||
totalQuestionsAnswered,
|
||||
totalCorrect,
|
||||
averageAccuracy: totalQuestionsAnswered > 0 ? totalCorrect / totalQuestionsAnswered : 0,
|
||||
activeStudents,
|
||||
totalStudents: s.totalStudents,
|
||||
participationRate: s.totalStudents > 0 ? activeStudents / s.totalStudents : 0,
|
||||
totalStudents,
|
||||
participationRate: totalStudents > 0 ? activeStudents / totalStudents : 0,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,35 +19,35 @@ import type {
|
||||
PracticeType,
|
||||
} from "../types"
|
||||
|
||||
const PRACTICE_TYPES = new Set<PracticeType>([
|
||||
const PRACTICE_TYPES = new Set<string>([
|
||||
"error_variant",
|
||||
"knowledge_point",
|
||||
"weak_chapter",
|
||||
"ai_recommended",
|
||||
])
|
||||
|
||||
const PRACTICE_STATUSES = new Set<PracticeStatus>([
|
||||
const PRACTICE_STATUSES = new Set<string>([
|
||||
"in_progress",
|
||||
"completed",
|
||||
"abandoned",
|
||||
])
|
||||
|
||||
const PRACTICE_ANSWER_STATUSES = new Set<PracticeAnswerStatus>([
|
||||
const PRACTICE_ANSWER_STATUSES = new Set<string>([
|
||||
"pending",
|
||||
"answered",
|
||||
"skipped",
|
||||
])
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value as PracticeAnswerStatus)
|
||||
return typeof value === "string" && PRACTICE_ANSWER_STATUSES.has(value)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Bot, Trash2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
@@ -107,7 +107,7 @@ export function AiChatPanel({
|
||||
const handleClear = (): void => {
|
||||
if (window.confirm(t("chat.clearConfirm"))) {
|
||||
clear()
|
||||
toast.success(t("chat.clear"))
|
||||
notify.success(t("chat.clear"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
@@ -49,7 +49,7 @@ export function AiChildSummary({
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!aiClient.generateChildSummary) {
|
||||
toast.error(t("parent.error"))
|
||||
notify.error(t("parent.error"))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
@@ -64,12 +64,12 @@ export function AiChildSummary({
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setSummary(result.data)
|
||||
toast.success(t("parent.summary"))
|
||||
notify.success(t("parent.summary"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("parent.error"))
|
||||
notify.error(result.message ?? t("parent.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("parent.error"))
|
||||
notify.error(t("parent.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
@@ -70,12 +70,12 @@ export function AiErrorBookAnalysis({
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setSimilarQuestions(result.data)
|
||||
toast.success(t("suggestion.loaded"))
|
||||
notify.success(t("suggestion.loaded"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("suggestion.error"))
|
||||
notify.error(result.message ?? t("suggestion.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("suggestion.error"))
|
||||
notify.error(t("suggestion.error"))
|
||||
} finally {
|
||||
setSimilarLoading(false)
|
||||
}
|
||||
@@ -92,12 +92,12 @@ export function AiErrorBookAnalysis({
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setWeaknessResult(result.data)
|
||||
toast.success(t("errorBook.weaknessAnalysis"))
|
||||
notify.success(t("errorBook.weaknessAnalysis"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.analysisFailed"))
|
||||
notify.error(result.message ?? t("error.analysisFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.analysisFailed"))
|
||||
notify.error(t("error.analysisFailed"))
|
||||
} finally {
|
||||
setWeaknessLoading(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Sparkles, Check } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
@@ -73,12 +73,12 @@ export function AiGradingAssist({
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setSuggestion(result.data)
|
||||
toast.success(t("grading.title"))
|
||||
notify.success(t("grading.title"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("grading.error"))
|
||||
notify.error(result.message ?? t("grading.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("grading.error"))
|
||||
notify.error(t("grading.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export function AiGradingAssist({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onApplyScore(suggestion.suggestedScore)
|
||||
toast.success(t("grading.suggestedScore"))
|
||||
notify.success(t("grading.suggestedScore"))
|
||||
}}
|
||||
>
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
@@ -148,7 +148,7 @@ export function AiGradingAssist({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onApplyFeedback(suggestion.feedback)
|
||||
toast.success(t("grading.feedback"))
|
||||
notify.success(t("grading.feedback"))
|
||||
}}
|
||||
>
|
||||
<Check className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
@@ -56,7 +56,7 @@ export function AiLessonContentGenerator({
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!topic.trim()) {
|
||||
toast.error(t("lessonPrep.error"))
|
||||
notify.error(t("lessonPrep.error"))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
@@ -72,12 +72,12 @@ export function AiLessonContentGenerator({
|
||||
})
|
||||
if (response.success && response.data) {
|
||||
setResult(response.data)
|
||||
toast.success(t("lessonPrep.generateContent"))
|
||||
notify.success(t("lessonPrep.generateContent"))
|
||||
} else {
|
||||
toast.error(response.message ?? t("lessonPrep.error"))
|
||||
notify.error(response.message ?? t("lessonPrep.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("lessonPrep.error"))
|
||||
notify.error(t("lessonPrep.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export function AiLessonContentGenerator({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onInsertContent(result)
|
||||
toast.success(t("lessonPrep.insertContent"))
|
||||
notify.success(t("lessonPrep.insertContent"))
|
||||
}}
|
||||
>
|
||||
{t("lessonPrep.insertContent")}
|
||||
|
||||
@@ -5,7 +5,7 @@ import ReactMarkdown from "react-markdown"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { Copy, Check } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
@@ -73,10 +73,10 @@ function AiMarkdownRendererImpl({
|
||||
try {
|
||||
await navigator.clipboard.writeText(content)
|
||||
setCopied(true)
|
||||
toast.success(t("chat.copied"))
|
||||
notify.success(t("chat.copied"))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
toast.error(t("error.chatFailed"))
|
||||
notify.error(t("error.chatFailed"))
|
||||
}
|
||||
}, [content, t])
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { 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"
|
||||
|
||||
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 => {
|
||||
return VARIANT_TYPES.includes(value as VariantType)
|
||||
return VARIANT_TYPES.includes(value)
|
||||
}
|
||||
|
||||
type AiQuestionVariantGeneratorProps = {
|
||||
@@ -66,7 +66,7 @@ export function AiQuestionVariantGenerator({
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!originalQuestion.text.trim()) {
|
||||
toast.error(t("error.invalidInput"))
|
||||
notify.error(t("error.invalidInput"))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
@@ -78,12 +78,12 @@ export function AiQuestionVariantGenerator({
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setVariant(result.data)
|
||||
toast.success(t("exam.generate"))
|
||||
notify.success(t("exam.generate"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.variantFailed"))
|
||||
notify.error(result.message ?? t("error.variantFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.variantFailed"))
|
||||
notify.error(t("error.variantFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -192,7 +192,7 @@ export function AiQuestionVariantGenerator({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onAddVariant(variant)
|
||||
toast.success(t("exam.generate"))
|
||||
notify.success(t("exam.generate"))
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-1 h-3.5 w-3.5" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
@@ -51,7 +51,7 @@ export function AiStudyPath({
|
||||
|
||||
const handleGenerate = async (): Promise<void> => {
|
||||
if (!aiClient.recommendStudyPath) {
|
||||
toast.error(t("studyPath.error"))
|
||||
notify.error(t("studyPath.error"))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
@@ -65,12 +65,12 @@ export function AiStudyPath({
|
||||
})
|
||||
if (result.success && result.data) {
|
||||
setPath(result.data)
|
||||
toast.success(t("studyPath.title"))
|
||||
notify.success(t("studyPath.title"))
|
||||
} else {
|
||||
toast.error(result.message ?? t("studyPath.error"))
|
||||
notify.error(result.message ?? t("studyPath.error"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("studyPath.error"))
|
||||
notify.error(t("studyPath.error"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
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 { Badge } from "@/shared/components/ui/badge"
|
||||
@@ -41,10 +41,10 @@ export function AiUsageDashboard(): React.ReactNode {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data)
|
||||
} else {
|
||||
toast.error(result.message ?? t("error.statsFailed"))
|
||||
notify.error(result.message ?? t("error.statsFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("error.statsFailed"))
|
||||
notify.error(t("error.statsFailed"))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
@@ -29,15 +29,25 @@ type UseAiChatStreamReturn = {
|
||||
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[] {
|
||||
if (typeof window === "undefined") return []
|
||||
try {
|
||||
const raw = localStorage.getItem(HISTORY_STORAGE_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as AiChatMessage[]
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
// 过滤掉空的 assistant 消息
|
||||
return parsed.filter((m) => m && m.role && typeof m.content === "string")
|
||||
// 从 unknown 转换:localStorage 数据不可信,用类型守卫过滤不合法的项
|
||||
return parsed.filter(isAiChatMessage)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -32,8 +32,13 @@ export function loadPosition(): Position {
|
||||
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") {
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
// 从 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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Pin } from "lucide-react"
|
||||
|
||||
@@ -56,17 +56,17 @@ export function AnnouncementCard({
|
||||
try {
|
||||
const res = await service.togglePin(announcement.id)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.pinToggled"))
|
||||
notify.success(t("messages.pinToggled"))
|
||||
router.refresh()
|
||||
} else {
|
||||
// 回滚
|
||||
setIsPinned(prevPinned)
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
// 回滚
|
||||
setIsPinned(prevPinned)
|
||||
toast.error(t("messages.publishFailed"))
|
||||
notify.error(t("messages.publishFailed"))
|
||||
} finally {
|
||||
setIsToggling(false)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
import {
|
||||
Archive,
|
||||
@@ -72,13 +72,13 @@ export function AnnouncementDetail({
|
||||
try {
|
||||
const res = await service.publish(announcement.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("messages.publishFailed"))
|
||||
notify.error(res.message || t("messages.publishFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.publishFailed"))
|
||||
notify.error(t("messages.publishFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -89,13 +89,13 @@ export function AnnouncementDetail({
|
||||
try {
|
||||
const res = await service.archive(announcement.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("messages.archiveFailed"))
|
||||
notify.error(res.message || t("messages.archiveFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.archiveFailed"))
|
||||
notify.error(t("messages.archiveFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
@@ -106,14 +106,14 @@ export function AnnouncementDetail({
|
||||
try {
|
||||
const res = await service.delete(announcement.id)
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
router.push("/admin/announcements")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("messages.deleteFailed"))
|
||||
notify.error(res.message || t("messages.deleteFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.deleteFailed"))
|
||||
notify.error(t("messages.deleteFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
setDeleteOpen(false)
|
||||
@@ -128,17 +128,17 @@ export function AnnouncementDetail({
|
||||
try {
|
||||
const res = await service.togglePin(announcement.id)
|
||||
if (res.success) {
|
||||
toast.success(t("messages.pinToggled"))
|
||||
notify.success(t("messages.pinToggled"))
|
||||
router.refresh()
|
||||
} else {
|
||||
// 回滚
|
||||
setIsPinned(prevPinned)
|
||||
toast.error(res.message)
|
||||
notify.error(res.message)
|
||||
}
|
||||
} catch {
|
||||
// 回滚
|
||||
setIsPinned(prevPinned)
|
||||
toast.error(t("messages.publishFailed"))
|
||||
notify.error(t("messages.publishFailed"))
|
||||
} finally {
|
||||
setIsTogglingPin(false)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
@@ -110,22 +110,22 @@ export function AnnouncementForm({
|
||||
: null
|
||||
|
||||
if (!res) {
|
||||
toast.error(t("messages.invalidState"))
|
||||
notify.error(t("messages.invalidState"))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message)
|
||||
notify.success(res.message)
|
||||
handleSuccess()
|
||||
} else {
|
||||
// 展示字段级错误(来自 Zod superRefine 校验)
|
||||
if (res.errors) {
|
||||
setFieldErrors(res.errors)
|
||||
}
|
||||
toast.error(res.message || t("messages.updateFailed"))
|
||||
notify.error(res.message || t("messages.updateFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("messages.updateFailed"))
|
||||
notify.error(t("messages.updateFailed"))
|
||||
} finally {
|
||||
setIsWorking(false)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { db } from "@/shared/db"
|
||||
import { announcements, announcementReads, users } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { serializeDate as toIso, serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
|
||||
import type {
|
||||
Announcement,
|
||||
AnnouncementInsertData,
|
||||
@@ -22,11 +23,6 @@ type AnnouncementRow = typeof announcements.$inferSelect & {
|
||||
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 => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { Trash2, Inbox } from "lucide-react"
|
||||
@@ -60,14 +60,14 @@ export function AttendanceRecordList({ records }: { records: AttendanceListItem[
|
||||
try {
|
||||
const result = await deleteAttendanceAction(deleteId)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("sheet.deleted"))
|
||||
notify.success(result.message || t("sheet.deleted"))
|
||||
setDeleteId(null)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
notify.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
notify.error(t("errors.unexpected"))
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
|
||||
@@ -71,7 +71,7 @@ export function AttendanceRulesForm({
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId) {
|
||||
toast.error(t("sheet.selectClass"))
|
||||
notify.error(t("sheet.selectClass"))
|
||||
return
|
||||
}
|
||||
formData.set("classId", classId)
|
||||
@@ -84,13 +84,13 @@ export function AttendanceRulesForm({
|
||||
try {
|
||||
const result = await saveAttendanceRulesAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("rules.saved"))
|
||||
notify.success(result.message || t("rules.saved"))
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
notify.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
notify.error(t("errors.unexpected"))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect, useCallback, useMemo, useOptimistic } from "react"
|
||||
import { useFormStatus } from "react-dom"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { CalendarDays, Search, CheckCircle2, XCircle, Clock, LogOut, FileText, School } from "lucide-react"
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
type AttendanceStatus,
|
||||
} from "../constants"
|
||||
import type { AttendancePeriod } from "../types"
|
||||
import { isAttendancePeriod } from "../lib/type-guards"
|
||||
|
||||
type Option = { id: string; name: 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"
|
||||
setStatuses(all)
|
||||
setReasons({})
|
||||
toast.success(t("actions.markAllPresent"))
|
||||
notify.success(t("actions.markAllPresent"))
|
||||
}, [students, t])
|
||||
|
||||
const handleClassChange = (newClassId: string) => {
|
||||
@@ -236,7 +237,7 @@ export function AttendanceSheet({
|
||||
|
||||
const handleSubmit = async (formData: FormData) => {
|
||||
if (!classId || !date) {
|
||||
toast.error(t("errors.invalidForm"))
|
||||
notify.error(t("errors.invalidForm"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -258,7 +259,7 @@ export function AttendanceSheet({
|
||||
})
|
||||
|
||||
if (records.length === 0) {
|
||||
toast.error(t("sheet.noStudents"))
|
||||
notify.error(t("sheet.noStudents"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -269,14 +270,14 @@ export function AttendanceSheet({
|
||||
try {
|
||||
const result = await batchRecordAttendanceAction(null, formData)
|
||||
if (result.success) {
|
||||
toast.success(result.message || t("sheet.saved"))
|
||||
notify.success(result.message || t("sheet.saved"))
|
||||
router.push("/teacher/attendance")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
notify.error(resolveActionError(result, t, t("errors.unexpected")))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("errors.unexpected"))
|
||||
notify.error(t("errors.unexpected"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +335,7 @@ export function AttendanceSheet({
|
||||
{/* L-6:节次选择器,允许按节次记录考勤 */}
|
||||
<div className="grid gap-2">
|
||||
<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">
|
||||
<SelectValue placeholder={t("period.label")} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -59,7 +59,9 @@ const serializeDate = (d: Date | string | null): string =>
|
||||
function buildPeriodFilter(period?: AttendancePeriod): SQL | null {
|
||||
if (!period) return null
|
||||
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)
|
||||
}
|
||||
@@ -310,7 +312,17 @@ export async function getAttendanceRulesRaw(classId?: string): Promise<Attendanc
|
||||
if (classCondition) conditions.push(classCondition)
|
||||
}
|
||||
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)
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(attendanceRules.createdAt))
|
||||
@@ -336,7 +348,7 @@ export const getAttendanceRules = cacheFn(getAttendanceRulesRaw, {
|
||||
|
||||
export async function upsertAttendanceRules(data: AttendanceRuleInput): Promise<string> {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.select({ id: attendanceRules.id })
|
||||
.from(attendanceRules)
|
||||
.where(eq(attendanceRules.classId, data.classId))
|
||||
.limit(1)
|
||||
|
||||
@@ -164,7 +164,8 @@ export async function saveAuditRetentionConfigAction(
|
||||
config: AuditRetentionConfig,
|
||||
): Promise<ActionState<AuditRetentionConfig>> {
|
||||
try {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
// 审计 v1 P0 修复(G4-004):保留策略是写操作,改用专用写权限点
|
||||
await requirePermission(Permissions.AUDIT_RETENTION_MANAGE)
|
||||
const session = await getSession()
|
||||
await saveAuditRetentionConfig(config, session?.user?.id)
|
||||
|
||||
@@ -194,7 +195,9 @@ export async function purgeAuditLogsAction(
|
||||
loginLogRetentionDays?: number,
|
||||
): Promise<ActionState<PurgeResult>> {
|
||||
try {
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
// 审计 v1 P0 修复(G4-003):purge 是破坏性操作(物理删除审计日志),
|
||||
// 读权限不应授予删除能力。改用专用 PURGE 权限点(仅 admin)。
|
||||
await requirePermission(Permissions.AUDIT_LOG_PURGE)
|
||||
const session = await getSession()
|
||||
|
||||
// audit-P2-5: 未显式传入时读取配置(含 loginLogRetentionDays)
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState } from "react"
|
||||
import { Download, Loader2 } from "lucide-react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { downloadBlob } from "@/shared/lib/download"
|
||||
@@ -45,7 +45,7 @@ export function AuditLogExportButton({
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => null)
|
||||
toast.error(body?.message || t("export.failed"))
|
||||
notify.error(body?.message || t("export.failed"))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -56,9 +56,9 @@ export function AuditLogExportButton({
|
||||
const blob = await res.blob()
|
||||
downloadBlob(blob, filename)
|
||||
analytics.trackExport(exportType, 0)
|
||||
toast.success(t("export.success"))
|
||||
notify.success(t("export.success"))
|
||||
} catch {
|
||||
toast.error(t("export.failed"))
|
||||
notify.error(t("export.failed"))
|
||||
} finally {
|
||||
setIsPending(false)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, type ReactNode } from "react"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { Save, Trash2, Loader2 } from "lucide-react"
|
||||
|
||||
import {
|
||||
@@ -41,7 +41,7 @@ export function AuditRetentionSettings(): ReactNode {
|
||||
if (cancelled) return
|
||||
setConfig(data)
|
||||
} catch {
|
||||
if (!cancelled) toast.error(t("saveFailed"))
|
||||
if (!cancelled) notify.error(t("saveFailed"))
|
||||
} finally {
|
||||
if (!cancelled) setIsLoading(false)
|
||||
}
|
||||
@@ -58,9 +58,9 @@ export function AuditRetentionSettings(): ReactNode {
|
||||
try {
|
||||
await service.saveAuditRetentionConfig(config)
|
||||
analytics.trackRetentionConfigChange(config)
|
||||
toast.success(t("saveSuccess"))
|
||||
notify.success(t("saveSuccess"))
|
||||
} catch {
|
||||
toast.error(t("saveFailed"))
|
||||
notify.error(t("saveFailed"))
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export function AuditRetentionSettings(): ReactNode {
|
||||
config.loginLogRetentionDays,
|
||||
)
|
||||
analytics.trackPurge(result)
|
||||
toast.success(
|
||||
notify.success(
|
||||
t("purgeSuccess", {
|
||||
auditLogsDeleted: result.auditLogsDeleted,
|
||||
loginLogsDeleted: result.loginLogsDeleted,
|
||||
@@ -86,7 +86,7 @@ export function AuditRetentionSettings(): ReactNode {
|
||||
}),
|
||||
)
|
||||
} catch {
|
||||
toast.error(t("purgeFailed"))
|
||||
notify.error(t("purgeFailed"))
|
||||
} finally {
|
||||
setIsPurging(false)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { and, asc, desc, eq, gte, lte, count, like, type SQL, sql } from "drizzl
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { cacheFn } from "@/shared/lib/cache"
|
||||
import { serializeDateRequired as toIso } from "@/shared/lib/date-utils"
|
||||
import { createModuleLogger } from "@/shared/lib/logger"
|
||||
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
||||
import type {
|
||||
@@ -22,8 +23,6 @@ import type {
|
||||
|
||||
const log = createModuleLogger("audit")
|
||||
|
||||
const toIso = (d: Date): string => d.toISOString()
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20
|
||||
const MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
@@ -47,7 +47,8 @@ export function LoginForm({ className, ...props }: LoginFormProps) {
|
||||
event.preventDefault()
|
||||
setError("")
|
||||
|
||||
const form = event.currentTarget as HTMLFormElement
|
||||
if (!(event.currentTarget instanceof HTMLFormElement)) return
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
const email = String(formData.get("email") ?? "").trim()
|
||||
const password = String(formData.get("password") ?? "")
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useTranslations } from "next-intl"
|
||||
import { toast } from "sonner"
|
||||
import { notify } from "@/shared/lib/notify"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Label } from "@/shared/components/ui/label"
|
||||
@@ -56,33 +56,34 @@ export function RegisterForm({ className, registerAction, ...props }: RegisterFo
|
||||
event.preventDefault()
|
||||
|
||||
if (!agreedTerms) {
|
||||
toast.error(t("toast.needAgreeTerms"))
|
||||
notify.error(t("toast.needAgreeTerms"))
|
||||
return
|
||||
}
|
||||
if (isMinor && !agreedGuardian) {
|
||||
toast.error(t("toast.needGuardianConsent"))
|
||||
notify.error(t("toast.needGuardianConsent"))
|
||||
return
|
||||
}
|
||||
if (isMinor && !guardianRelation) {
|
||||
toast.error(t("toast.needGuardianRelation"))
|
||||
notify.error(t("toast.needGuardianRelation"))
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const form = event.currentTarget as HTMLFormElement
|
||||
if (!(event.currentTarget instanceof HTMLFormElement)) return
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
const res = await registerAction(formData)
|
||||
|
||||
if (res.success) {
|
||||
toast.success(res.message || t("toast.createSuccess"))
|
||||
notify.success(res.message || t("toast.createSuccess"))
|
||||
router.push("/login")
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(res.message || t("toast.createFailed"))
|
||||
notify.error(res.message || t("toast.createFailed"))
|
||||
}
|
||||
} catch {
|
||||
toast.error(t("toast.createFailed"))
|
||||
notify.error(t("toast.createFailed"))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@@ -51,22 +51,9 @@ export async function createUser(
|
||||
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
|
||||
// F-09: 角色查找前置,失败时立即抛错,避免产生孤儿用户记录
|
||||
const roleName = input.role ?? DEFAULT_REGISTER_ROLE
|
||||
const roleRow = await db.query.roles.findFirst({
|
||||
where: eq(roles.name, roleName),
|
||||
@@ -75,9 +62,27 @@ export async function createUser(
|
||||
if (!roleRow?.id) {
|
||||
throw new Error("DEFAULT_ROLE_NOT_FOUND")
|
||||
}
|
||||
await db.insert(usersToRoles).values({
|
||||
userId,
|
||||
roleId: roleRow.id,
|
||||
|
||||
// F-09: 两次 INSERT 必须在同一事务内,保证 users 与 users_to_roles 原子写入
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user