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 {
|
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
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
* - 批量获取所有班级的活跃学生 ID(1 条 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 列表
|
// 批量获取所有班级的活跃学生 ID(1 条 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,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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-003):purge 是破坏性操作(物理删除审计日志),
|
||||||
|
// 读权限不应授予删除能力。改用专用 PURGE 权限点(仅 admin)。
|
||||||
|
await requirePermission(Permissions.AUDIT_LOG_PURGE)
|
||||||
const session = await getSession()
|
const session = await getSession()
|
||||||
|
|
||||||
// audit-P2-5: 未显式传入时读取配置(含 loginLogRetentionDays)
|
// audit-P2-5: 未显式传入时读取配置(含 loginLogRetentionDays)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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") ?? "")
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user