feat(modules-comm): update messaging, notifications, onboarding, parent, proctoring, questions, rbac

- messaging: update message-compose, message-detail, message-draft-list,

  message-group-compose, message-list, message-report-block,

  message-template-picker, unread-message-badge, data-access

- notifications: update notification-list, data-access, use-notification-stream, preferences

- onboarding: update actions, data-access, use-onboarding-form

- parent: update child-schedule-card, parent-export-button

- proctoring: update data-access

- questions: update batch-operations, create-question-dialog, import-export-buttons,

  question-actions, data-access

- rbac: update actions, permission-catalog
This commit is contained in:
SpecialX
2026-07-07 16:21:00 +08:00
parent 524ecade19
commit 783b8f5484
26 changed files with 484 additions and 1285 deletions

View File

@@ -3,7 +3,7 @@
import { useEffect, useRef, 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 { ArrowLeft, Check, Loader2, Save, Send } from "lucide-react"
@@ -110,7 +110,7 @@ export function MessageCompose({
const handleSubmit = async (formData: FormData) => {
if (!receiverId) {
toast.error(t("form.selectRecipient"))
notify.error(t("form.selectRecipient"))
return
}
// P0-5: 标记已提交,阻止后续自动保存 effect 触发新草稿创建
@@ -129,7 +129,7 @@ export function MessageCompose({
if (draftIdRef.current) {
void deleteMessageDraftAction(draftIdRef.current)
}
toast.success(res.message)
notify.success(res.message)
router.push("/messages")
router.refresh()
} else {
@@ -137,12 +137,12 @@ export function MessageCompose({
if (res.errors) {
setFieldErrors(res.errors)
}
toast.error(res.message || t("messages.sendFailed"))
notify.error(res.message || t("messages.sendFailed"))
// 提交失败时重置 submittedRef 允许后续自动保存
submittedRef.current = false
}
} catch {
toast.error(t("messages.sendFailed"))
notify.error(t("messages.sendFailed"))
submittedRef.current = false
} finally {
setIsWorking(false)
@@ -163,14 +163,14 @@ export function MessageCompose({
if (res.success && res.data) {
setDraftId(res.data)
setSaveStatus("saved")
toast.success(t("messages.draftSaved"))
notify.success(t("messages.draftSaved"))
} else {
setSaveStatus("idle")
toast.error(res.message)
notify.error(res.message)
}
} catch {
setSaveStatus("idle")
toast.error(t("messages.sendFailed"))
notify.error(t("messages.sendFailed"))
}
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useState, useOptimistic, useTransition } 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 { ArrowLeft, Loader2, Mail, Reply, RotateCcw, Star, Trash2 } from "lucide-react"
@@ -98,14 +98,14 @@ export function MessageDetail({
try {
const res = await deleteMessageAction(message.id)
if (res.success) {
toast.success(res.message)
notify.success(res.message)
router.push("/messages")
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)
@@ -120,14 +120,14 @@ export function MessageDetail({
try {
const res = await toggleMessageStarAction(message.id)
if (res.success) {
toast.success(t("messages.starToggled"))
notify.success(t("messages.starToggled"))
// 同步数据源,让 useOptimistic 回滚到最新服务端值
router.refresh()
} else {
toast.error(res.message)
notify.error(res.message)
}
} catch {
toast.error(t("messages.sendFailed"))
notify.error(t("messages.sendFailed"))
}
})
}
@@ -140,18 +140,18 @@ export function MessageDetail({
if (res.success) {
// 同步 UI立即显示"已撤回"占位
setIsRecalled(true)
toast.success(t("messages.recalled"))
notify.success(t("messages.recalled"))
router.refresh()
} else {
// 失败:根据返回消息判断是否超时
if (res.message === "Recall window expired") {
toast.error(t("messages.recallExpired"))
notify.error(t("messages.recallExpired"))
} else {
toast.error(res.message || t("messages.recallFailed"))
notify.error(res.message || t("messages.recallFailed"))
}
}
} catch {
toast.error(t("messages.recallFailed"))
notify.error(t("messages.recallFailed"))
} finally {
setIsRecalling(false)
setRecallOpen(false)

View File

@@ -2,7 +2,7 @@
import { useState } from "react"
import Link from "next/link"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl"
import { FileText, Loader2, Trash2 } from "lucide-react"
@@ -38,13 +38,13 @@ export function MessageDraftList({ drafts }: { drafts: MessageDraft[] }) {
if (!res.success) {
// 回滚
setItems(snapshot)
toast.error(res.message)
notify.error(res.message)
} else {
toast.success(t("messages.draftDeleted"))
notify.success(t("messages.draftDeleted"))
}
} catch {
setItems(snapshot)
toast.error(t("messages.sendFailed"))
notify.error(t("messages.sendFailed"))
} finally {
setDeletingId(null)
setDeleteOpen(null)

View File

@@ -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 { ArrowLeft, Send, Loader2 } from "lucide-react"
@@ -62,7 +62,7 @@ export function MessageGroupCompose({
const handleSubmit = async (formData: FormData) => {
if (!classId) {
toast.error(t("form.selectClass"))
notify.error(t("form.selectClass"))
return
}
formData.set("classId", classId)
@@ -73,25 +73,25 @@ export function MessageGroupCompose({
const res = await sendGroupMessageAction(null, formData)
if (res.success) {
const count = res.data?.recipientCount ?? 0
toast.success(t("messages.groupSent", { count }))
notify.success(t("messages.groupSent", { count }))
router.push("/messages")
router.refresh()
} else {
// 识别业务层返回的特定错误(无收件人 / 越权)
const msg = res.message ?? ""
if (msg.includes("No recipients")) {
toast.error(t("messages.groupNoRecipients"))
notify.error(t("messages.groupNoRecipients"))
} else if (msg.includes("Not allowed")) {
toast.error(t("messages.groupNotAuthorized"))
notify.error(t("messages.groupNotAuthorized"))
} else {
toast.error(t("messages.groupSendFailed"))
notify.error(t("messages.groupSendFailed"))
}
if (res.errors) {
setFieldErrors(res.errors)
}
}
} catch {
toast.error(t("messages.groupSendFailed"))
notify.error(t("messages.groupSendFailed"))
} finally {
setIsWorking(false)
}

View File

@@ -5,7 +5,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation"
import { Mail, MailOpen, Plus, Send, Inbox, Search, Loader2, ChevronLeft, ChevronRight, Star, RotateCcw, Users } from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -139,7 +139,7 @@ export function MessageList({
try {
const res = await messageService.toggleStar(messageId)
if (res.success) {
toast.success(t("messages.starToggled"))
notify.success(t("messages.starToggled"))
// P1-2: 当前在 starred Tab 时重新加载星标列表
if (tab === "starred") {
void loadStarred()
@@ -147,10 +147,10 @@ export function MessageList({
// 同步数据源,让 useOptimistic 回滚到最新服务端值
await router.refresh()
} else {
toast.error(res.message)
notify.error(res.message)
}
} catch {
toast.error(t("messages.sendFailed"))
notify.error(t("messages.sendFailed"))
} finally {
setTogglingStarId(null)
}
@@ -170,16 +170,16 @@ export function MessageList({
if (res.success) {
// 同步 UI立即显示"已撤回"占位
setRecalledOverride((prev) => ({ ...prev, [message.id]: true }))
toast.success(t("messages.recalled"))
notify.success(t("messages.recalled"))
} else {
if (res.message === "Recall window expired") {
toast.error(t("messages.recallExpired"))
notify.error(t("messages.recallExpired"))
} else {
toast.error(res.message || t("messages.recallFailed"))
notify.error(res.message || t("messages.recallFailed"))
}
}
} catch {
toast.error(t("messages.recallFailed"))
notify.error(t("messages.recallFailed"))
} finally {
setRecallingId(null)
}

View File

@@ -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 { Flag, Ban, Loader2 } from "lucide-react"
@@ -65,22 +65,22 @@ export function MessageReportBlock({
try {
const res = await reportMessageAction(null, formData)
if (res.success) {
toast.success(t("messages.reported"))
notify.success(t("messages.reported"))
setReportOpen(false)
setDescription("")
setReason("spam")
} else {
// 识别已举报
if (res.message?.includes("Already reported")) {
toast.error(t("messages.alreadyReported"))
notify.error(t("messages.alreadyReported"))
} else if (res.message?.includes("own message")) {
toast.error(t("messages.reportSelf"))
notify.error(t("messages.reportSelf"))
} else {
toast.error(res.message || t("messages.reportFailed"))
notify.error(res.message || t("messages.reportFailed"))
}
}
} catch {
toast.error(t("messages.reportFailed"))
notify.error(t("messages.reportFailed"))
} finally {
setIsReporting(false)
}
@@ -93,20 +93,20 @@ export function MessageReportBlock({
formData.set("blockedId", senderId)
const res = await blockUserAction(null, formData)
if (res.success) {
toast.success(t("messages.blocked"))
notify.success(t("messages.blocked"))
setBlockOpen(false)
router.refresh()
} else {
if (res.message?.includes("already")) {
toast.error(t("messages.alreadyBlocked"))
notify.error(t("messages.alreadyBlocked"))
} else if (res.message?.includes("yourself")) {
toast.error(t("messages.blockSelf"))
notify.error(t("messages.blockSelf"))
} else {
toast.error(res.message || t("messages.blockFailed"))
notify.error(res.message || t("messages.blockFailed"))
}
}
} catch {
toast.error(t("messages.blockFailed"))
notify.error(t("messages.blockFailed"))
} finally {
setIsBlocking(false)
}

View File

@@ -1,7 +1,7 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { useTranslations } from "next-intl"
import { ChevronDown, FileText, Plus, Trash2 } from "lucide-react"
@@ -78,7 +78,7 @@ export function MessageTemplatePicker({
const handleInsert = (template: MessageTemplate): void => {
onInsert(template.content)
toast.success(t("templates.insert"))
notify.success(t("templates.insert"))
}
const openCreate = (): void => {
@@ -102,14 +102,14 @@ export function MessageTemplatePicker({
try {
const res = await saveMessageTemplateAction(null, formData)
if (res.success) {
toast.success(t("templates.saved"))
notify.success(t("templates.saved"))
setManageOpen(false)
await loadTemplates()
} else {
toast.error(res.message)
notify.error(res.message)
}
} catch {
toast.error(t("templates.saved"))
notify.error(t("templates.saved"))
} finally {
setIsSaving(false)
}
@@ -120,13 +120,13 @@ export function MessageTemplatePicker({
try {
const res = await deleteMessageTemplateAction(templateId)
if (res.success) {
toast.success(t("templates.deleted"))
notify.success(t("templates.deleted"))
await loadTemplates()
} else {
toast.error(res.message)
notify.error(res.message)
}
} catch {
toast.error(t("templates.deleted"))
notify.error(t("templates.deleted"))
} finally {
setDeletingId(null)
}

View File

@@ -89,7 +89,7 @@ export function UnreadMessageBadge() {
// 兜底:如果 SSE 在 5 秒内未收到任何数据,启动轮询作为保险
const fallbackTimer = setTimeout(() => {
if (active && usingSSE && count === 0) {
if (active && usingSSE) {
// SSE 可能未推送初始数据,拉取一次
void fetchCount()
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
@@ -49,13 +49,13 @@ export function NotificationList({ notifications }: { notifications: Notificatio
try {
const res = await markAllNotificationsAsReadAction()
if (res.success) {
toast.success(res.message)
notify.success(res.message)
router.refresh()
} else {
toast.error(res.message || t("messages.markReadFailed"))
notify.error(res.message || t("messages.markReadFailed"))
}
} catch {
toast.error(t("messages.markReadFailed"))
notify.error(t("messages.markReadFailed"))
} finally {
setIsWorking(false)
}
@@ -68,7 +68,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
router.refresh()
}
} catch {
toast.error(t("messages.markReadFailed"))
notify.error(t("messages.markReadFailed"))
}
}
@@ -79,7 +79,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
router.refresh()
}
} catch {
toast.error(t("messages.archiveFailed"))
notify.error(t("messages.archiveFailed"))
}
}

View File

@@ -23,6 +23,7 @@ import { and, count, desc, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { messageNotifications, notificationLogs, users } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import { serializeDateRequired as toIsoRequired } from "@/shared/lib/date-utils"
import { createModuleLogger } from "@/shared/lib/logger"
import type { ChannelRecipient } from "./channels/types"
import type {
@@ -36,7 +37,6 @@ import type {
} from "./types"
const log = createModuleLogger("notifications")
const toIsoRequired = (d: Date): string => d.toISOString()
const isNotificationType = (v: unknown): v is NotificationType =>
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"

View File

@@ -158,7 +158,9 @@ export function useNotificationStream(options?: UseNotificationStreamOptions): U
setIsUsingFallback(true)
return
}
const data = JSON.parse(event.data) as NotificationStreamData
const raw: unknown = JSON.parse(event.data)
// 从 unknown 转换SSE 数据结构由后端保证,字段使用前已做 typeof 校验
const data = raw as NotificationStreamData
if (data.type === "update") {
if (typeof data.unreadCount === "number") setUnreadCount(data.unreadCount)
if (data.notifications) setNotifications(data.notifications)

View File

@@ -19,13 +19,12 @@ import { and, eq } from "drizzle-orm"
import { db } from "@/shared/db"
import { notificationPreferences } from "@/shared/db/schema"
import { cacheFn } from "@/shared/lib/cache"
import { serializeDateRequired as toIso } from "@/shared/lib/date-utils"
import type {
NotificationPreferences,
UpdateNotificationPreferencesInput,
} from "./types"
const toIso = (d: Date): string => d.toISOString()
const mapRow = (
row: typeof notificationPreferences.$inferSelect
): NotificationPreferences => ({

View File

@@ -1,7 +1,5 @@
"use server"
import { eq } from "drizzle-orm"
import type { ActionState } from "@/shared/types/action-state"
import { requireAuth } from "@/shared/lib/auth-guard"
import { invalidateFor } from "@/shared/lib/cache"
@@ -12,8 +10,6 @@ import {
rateLimitKey,
RATE_LIMIT_RULES,
} from "@/shared/lib/rate-limit"
import { db } from "@/shared/db"
import { users } from "@/shared/db/schema"
import {
enrollStudentByInvitationCode,
enrollTeacherByInvitationCode,
@@ -22,11 +18,18 @@ import { DEFAULT_CLASS_SUBJECTS } from "@/modules/classes/types"
import { OnboardingSchema } from "./schema"
import {
getOnboardingStatus,
getUserOnboardedAt,
markUserOnboarded,
updateUserProfile,
bindParentToChild,
} from "./data-access"
import type { OnboardingCompleteData, OnboardingFailureItem } from "./types"
// G3-030 修复:以类型标注替代 as 断言,避免对元组类型做 widening 断言。
// DEFAULT_CLASS_SUBJECTS 是 `as const` 元组,.includes() 需要 readonly string[] 入参,
// 显式标注的常量使其在不使用 as 的情况下满足 .includes() 签名。
const CLASS_SUBJECTS_READONLY: readonly string[] = DEFAULT_CLASS_SUBJECTS
/**
* 查询当前用户 onboarding 状态。
* 供服务端组件 / 客户端组件读取,决定是否渲染引导流程。
@@ -69,13 +72,10 @@ export async function completeOnboardingAction(
const userId = ctx.userId
// P0-5 服务端幂等:已完成的用户直接返回成功,防止双击或刷新重复提交
const [existingUser] = await db
.select({ onboardedAt: users.onboardedAt })
.from(users)
.where(eq(users.id, userId))
.limit(1)
// 审计 v1 P0 修复G5-002改调 data-access 函数,消除 actions 直查 DB
const existingOnboardedAt = await getUserOnboardedAt(userId)
if (existingUser?.onboardedAt) {
if (existingOnboardedAt) {
const roleNames = ctx.roles
return {
success: true,
@@ -108,7 +108,7 @@ export async function completeOnboardingAction(
// 教师任课科目过滤:仅保留系统默认科目
const validTeacherSubjects = input.teacherSubjects.filter(
(s): s is (typeof DEFAULT_CLASS_SUBJECTS)[number] =>
(DEFAULT_CLASS_SUBJECTS as readonly string[]).includes(s)
CLASS_SUBJECTS_READONLY.includes(s)
)
// audit-P1-8家长绑定子女独立速率限制。
@@ -144,8 +144,9 @@ export async function completeOnboardingAction(
// 收集局部失败项P1-2班级码/子女绑定失败不回滚整个事务
const failures: OnboardingFailureItem[] = []
// 事务包裹全部写入
const result = await db.transaction(async (tx) => {
// 审计 v1 P0 修复G5-002移除 db.transaction 包裹,消除 actions 对 db 的直接依赖。
// 原 db.transaction 实际上只保护 onboardedAt 的更新(其他 data-access 函数均用 db 不在事务内),
// 移除后行为一致。所有写操作通过 data-access 函数完成。
// 1. 更新基础资料
await updateUserProfile(userId, {
name: input.name,
@@ -219,14 +220,10 @@ export async function completeOnboardingAction(
}
}
// 5. 最后标记 onboarded事务内,确保原子性
await tx
.update(users)
.set({ onboardedAt: new Date() })
.where(eq(users.id, userId))
// 5. 最后标记 onboarded审计 v1 P0 修复:改调 data-access 函数
await markUserOnboarded(userId)
return { defaultPath: resolveDefaultPath(normalizedRoles) }
})
const result = { defaultPath: resolveDefaultPath(normalizedRoles) }
// P0-4 审计日志:记录 onboarding 完成(含失败项明细,对标 PowerSchool/Veracross
await logAudit({

View File

@@ -1,3 +1,5 @@
import "server-only"
import { eq, and } from "drizzle-orm"
import { db } from "@/shared/db"
@@ -49,6 +51,47 @@ export const getOnboardingStatus = cacheFn(getOnboardingStatusRaw, {
keyParts: ["onboarding", "status"],
})
/**
* 读取用户 onboardedAt 时间戳。
*
* 用于 completeOnboardingAction 的服务端幂等检查P0-5
* 若已完成 onboarding 则直接返回成功,防止双击或刷新重复提交。
*
* 审计 v1 P0 修复G5-002从 actions.ts 下沉到 data-access
* 消除 actions 层直查 DB 的违规A-09
*/
export async function getUserOnboardedAtRaw(userId: string): Promise<Date | null> {
const [row] = await db
.select({ onboardedAt: users.onboardedAt })
.from(users)
.where(eq(users.id, userId))
.limit(1)
return row?.onboardedAt ?? null
}
export const getUserOnboardedAt = cacheFn(getUserOnboardedAtRaw, {
tags: ["onboarding"],
ttl: 300,
keyParts: ["onboarding", "onboarded-at"],
})
/**
* 标记用户 onboarding 完成(写入 users.onboardedAt
*
* 审计 v1 P0 修复G5-002从 actions.ts 事务内 tx.update 下沉到 data-access
* 消除 actions 层直查 schema 表的违规A-09
*
* 注意:此函数使用 db 而非事务参数。原 actions.ts 中的 db.transaction 包裹
* 实际上只保护了 onboardedAt 的更新(其他 data-access 函数均用 db 不在事务内),
* 移除事务包裹后行为一致。
*/
export async function markUserOnboarded(userId: string): Promise<void> {
await db
.update(users)
.set({ onboardedAt: new Date() })
.where(eq(users.id, userId))
}
/**
* 更新用户基础资料(姓名/电话/住址)。
* 不涉及角色与班级绑定,独立可复用。

View File

@@ -4,7 +4,7 @@ import * as React from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { useSession } from "next-auth/react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { type ClassSubject } from "@/modules/classes/types"
import { completeOnboardingAction } from "@/modules/onboarding/actions"
@@ -96,11 +96,11 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
const onNext = () => {
if (currentStepId === "basicInfo" && !canNext) {
toast.error(t("validation.needNamePhone"))
notify.error(t("validation.needNamePhone"))
return
}
if (currentStepId === "roleInfo" && isParent && !canNext) {
toast.error(t("validation.needOneChild"))
notify.error(t("validation.needOneChild"))
return
}
goToStep(step + 1)
@@ -133,7 +133,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
(c) => c.childEmail.trim() && c.childBirthDate.trim() && c.childPhoneSuffix.trim(),
)
if (validChildren.length === 0) {
toast.error(t("validation.needOneChild"))
notify.error(t("validation.needOneChild"))
return
}
}
@@ -154,14 +154,14 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
const result = await completeOnboardingAction(null, formData)
if (!result.success) {
toast.error(result.message ?? t("toast.submitFailed"))
notify.error(result.message ?? t("toast.submitFailed"))
return
}
if (result.message && result.message.includes("绑定失败")) {
toast.warning(result.message)
notify.warning(result.message)
} else {
toast.success(t("toast.completeSuccess"))
notify.success(t("toast.completeSuccess"))
}
await update?.()
@@ -170,7 +170,7 @@ export function useOnboardingForm(initialStatus: OnboardingStatus) {
router.refresh()
} catch (e) {
const msg = e instanceof Error ? e.message : t("toast.submitFailed")
toast.error(msg)
notify.error(msg)
} finally {
setIsSubmitting(false)
}

View File

@@ -37,7 +37,10 @@ export function ChildScheduleCard({
if (!grouped[key]) grouped[key] = []
grouped[key].push(item)
}
const weekdays = Object.keys(grouped).map(Number).sort((a, b) => a - b) as Array<1 | 2 | 3 | 4 | 5 | 6 | 7>
const weekdays = Object.keys(grouped)
.map(Number)
.filter((n): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 => n >= 1 && n <= 7)
.sort((a, b) => a - b)
return (
<Card>

View File

@@ -1,7 +1,7 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import { Download, Loader2 } from "lucide-react"
import { Button } from "@/shared/components/ui/button"
@@ -30,7 +30,7 @@ export function ParentExportButton({
const result = await safeActionCall(
() => exportGradesAction({ studentId }),
{
onError: () => toast.error(`Failed to export grades for ${studentName}.`),
onError: () => notify.error(`Failed to export grades for ${studentName}.`),
onFinally: () => setIsExporting(false),
}
)
@@ -53,12 +53,12 @@ export function ParentExportButton({
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
toast.success(`Grades exported for ${studentName}.`)
notify.success(`Grades exported for ${studentName}.`)
} catch {
toast.error("Failed to download the export file.")
notify.error("Failed to download the export file.")
}
} else if (result) {
toast.error(result.message || `Failed to export grades for ${studentName}.`)
notify.error(result.message || `Failed to export grades for ${studentName}.`)
}
}

View File

@@ -29,6 +29,9 @@ import type {
} from "./types"
import { ABNORMAL_EVENT_THRESHOLD } from "./types"
/** F-05: 列表查询默认上限,防止无界查询拉爆内存 */
const DEFAULT_LIMIT = 1000
const ALL_EVENT_TYPES: ProctoringEventType[] = [
"tab_switch",
"window_blur",
@@ -146,6 +149,7 @@ export const getProctoringEventsRaw = async (
.from(examProctoringEvents)
.where(and(...conditions))
.orderBy(desc(examProctoringEvents.occurredAt))
.limit(DEFAULT_LIMIT)
if (rows.length === 0) return []
@@ -183,6 +187,7 @@ export const getProctoringEventsBySubmissionRaw = async (submissionId: string):
const rows = await db.query.examProctoringEvents.findMany({
where: eq(examProctoringEvents.submissionId, submissionId),
orderBy: [desc(examProctoringEvents.occurredAt)],
limit: DEFAULT_LIMIT,
})
return rows.map((row) => ({
@@ -305,7 +310,8 @@ export const getStudentProctoringStatusesRaw = async (examId: string): Promise<S
inArray(examProctoringEvents.studentId, studentIds),
),
)
.orderBy(desc(examProctoringEvents.occurredAt)),
.orderBy(desc(examProctoringEvents.occurredAt))
.limit(DEFAULT_LIMIT),
])
// 4. 按学生聚合

View File

@@ -19,7 +19,7 @@ import {
import { deleteQuestionsBatchAction } from "../actions"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
interface BatchOperationsProps {
/** 选中的题目 ID 列表 */
@@ -53,16 +53,16 @@ export function BatchOperations({ selectedIds, onClearSelection }: BatchOperatio
const res = await deleteQuestionsBatchAction(undefined, fd)
if (res.success) {
const deleted = res.data?.deleted ?? 0
toast.success(t("batch.deleteSuccess", { count: deleted }))
notify.success(t("batch.deleteSuccess", { count: deleted }))
setShowDeleteDialog(false)
onClearSelection()
router.refresh()
} else {
toast.error(res.message || t("batch.deleteFailed"))
notify.error(res.message || t("batch.deleteFailed"))
}
} catch (e) {
console.error("Failed to batch delete questions", e)
toast.error(t("batch.deleteFailed"))
notify.error(t("batch.deleteFailed"))
} finally {
setIsDeleting(false)
}

View File

@@ -21,7 +21,7 @@ import { SelectField } from "@/shared/components/form-fields/select-field"
import { TextareaField } from "@/shared/components/form-fields/textarea-field"
import { BaseQuestionSchema } from "../schema"
import { createQuestionAction, updateQuestionAction } from "../actions"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
import type { Question } from "../types"
import {
parseQuestionContent,
@@ -164,7 +164,7 @@ export function CreateQuestionDialog({
setIsPending(true)
try {
if (isEdit && !initialData?.id) {
toast.error(t("error.missingId"))
notify.error(t("error.missingId"))
return
}
const payload = {
@@ -180,18 +180,18 @@ export function CreateQuestionDialog({
? await updateQuestionAction(undefined, fd)
: await createQuestionAction(undefined, fd)
if (res.success) {
toast.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess"))
notify.success(isEdit ? t("error.updatedSuccess") : t("error.createdSuccess"))
onOpenChange(false)
router.refresh()
if (!isEdit) {
form.reset()
}
} else {
toast.error(res.message || t("error.operationFailed"))
notify.error(res.message || t("error.operationFailed"))
}
} catch (e) {
console.error("Failed to submit question", e)
toast.error(t("error.unexpected"))
notify.error(t("error.unexpected"))
} finally {
setIsPending(false)
}

View File

@@ -17,7 +17,7 @@ import {
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { exportQuestionsAction, importQuestionsAction } from "../actions"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
/**
* 题目导入/导出组件。
@@ -60,13 +60,13 @@ export function ImportExportButtons(): React.ReactNode {
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(t("importExport.exportSuccess", { count: res.data.length }))
notify.success(t("importExport.exportSuccess", { count: res.data.length }))
} else {
toast.error(res.message || t("importExport.exportFailed"))
notify.error(res.message || t("importExport.exportFailed"))
}
} catch (e) {
console.error("Failed to export questions", e)
toast.error(t("importExport.exportFailed"))
notify.error(t("importExport.exportFailed"))
} finally {
setIsExporting(false)
}
@@ -83,11 +83,11 @@ export function ImportExportButtons(): React.ReactNode {
setPendingImportData(text)
setShowImportDialog(true)
} else {
toast.error(t("importExport.invalidFile"))
notify.error(t("importExport.invalidFile"))
}
}
reader.onerror = () => {
toast.error(t("importExport.readFailed"))
notify.error(t("importExport.readFailed"))
}
reader.readAsText(file)
// 重置 input 以便重复选择同一文件
@@ -103,16 +103,16 @@ export function ImportExportButtons(): React.ReactNode {
const res = await importQuestionsAction(undefined, fd)
if (res.success) {
const imported = res.data?.imported ?? 0
toast.success(t("importExport.importSuccess", { count: imported }))
notify.success(t("importExport.importSuccess", { count: imported }))
setShowImportDialog(false)
setPendingImportData(null)
router.refresh()
} else {
toast.error(res.message || t("importExport.importFailed"))
notify.error(res.message || t("importExport.importFailed"))
}
} catch (e) {
console.error("Failed to import questions", e)
toast.error(t("importExport.importFailed"))
notify.error(t("importExport.importFailed"))
} finally {
setIsImporting(false)
}

View File

@@ -38,7 +38,7 @@ import { CreateQuestionDialog } from "./create-question-dialog"
import { QuestionContentRenderer } from "./question-content-renderer"
import { usePermission } from "@/shared/hooks/use-permission"
import { Permissions } from "@/shared/types/permissions"
import { toast } from "sonner"
import { notify } from "@/shared/lib/notify"
interface QuestionActionsProps {
question: Question
@@ -59,10 +59,10 @@ export function QuestionActions({ question }: QuestionActionsProps): React.React
const copyId = (): void => {
try {
navigator.clipboard.writeText(question.id)
toast.success(t("actions.copyIdSuccess"))
notify.success(t("actions.copyIdSuccess"))
} catch (e) {
console.error("Failed to copy question ID to clipboard", e)
toast.error(t("actions.copyIdFailed"))
notify.error(t("actions.copyIdFailed"))
}
}
@@ -73,15 +73,15 @@ export function QuestionActions({ question }: QuestionActionsProps): React.React
fd.set("questionId", question.id)
const res = await deleteQuestionAction(undefined, fd)
if (res.success) {
toast.success(t("actions.deleteSuccess"))
notify.success(t("actions.deleteSuccess"))
setShowDeleteDialog(false)
router.refresh()
} else {
toast.error(res.message || t("actions.deleteFailed"))
notify.error(res.message || t("actions.deleteFailed"))
}
} catch (e) {
console.error("Failed to delete question", e)
toast.error(t("actions.deleteFailed"))
notify.error(t("actions.deleteFailed"))
} finally {
setIsDeleting(false)
}

View File

@@ -1,7 +1,7 @@
import 'server-only';
import { db } from "@/shared/db";
import { knowledgePoints, questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { questions, questionsToKnowledgePoints } from "@/shared/db/schema";
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm";
import { cacheFn } from "@/shared/lib/cache";
import { createId } from "@paralleldrive/cuid2";
@@ -11,12 +11,25 @@ import {
getKnowledgePointsByChapterId as getKnowledgePointsByChapterIdFromTextbooks,
getKnowledgePointsByTextbookId as getKnowledgePointsByTextbookIdFromTextbooks,
getTextbooks as getTextbooksFromTextbooks,
getKnowledgePointNamesByIds,
} from "@/modules/textbooks/data-access";
import type { CreateQuestionInput } from "./schema";
import type { ChapterOption, KnowledgePointOption, Question, QuestionType, TextbookOption } from "./types";
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0]
/**
* F-02: 将原始查询字符串转换为 MySQL FULLTEXT BOOLEAN MODE 查询格式。
* 按空白拆分,转义 BOOLEAN MODE 特殊字符,每个词加 + 前缀和 * 后缀(前缀匹配)。
* 例如 "math question" → "+math* +question*"
*/
function toBooleanModeQuery(q: string): string {
const words = q.trim().split(/\s+/).filter(Boolean)
if (words.length === 0) return ""
const escapeWord = (w: string): string => `+${w.replace(/[+\-<>()~*"']/g, " ").trim()}*`
return words.map(escapeWord).filter((w) => w !== "+*").join(" ")
}
export type UpdateQuestionInput = {
type: QuestionType
difficulty: number
@@ -36,6 +49,16 @@ export type GetQuestionsParams = {
difficulty?: number;
};
/**
* 分页查询题目列表,支持按 ID 集合、关键词全文检索、题目类型/难度、
* 知识点/章节/教材级联筛选。
*
* 级联筛选通过 textbooks 模块 data-access 解析知识点 ID 集合,避免直接查询
* textbooks/chapters/knowledgePoints 表。子题parentId 非 null默认不返回
* 除非通过 `ids` 显式指定。
*
* @returns 包含 data 与 metapage/pageSize/total/totalPages的对象
*/
export const getQuestionsRaw = async ({
q,
page = 1,
@@ -58,10 +81,13 @@ export const getQuestionsRaw = async ({
}
if (q && q.trim().length > 0) {
const needle = `%${q.trim().toLowerCase()}%`;
// F-02: 使用 FULLTEXT 索引content_text 生成列)+ MATCH AGAINST 替代 LIKE '%xxx%' 全表扫描
const booleanQuery = toBooleanModeQuery(q.trim())
if (booleanQuery) {
conditions.push(
sql`LOWER(CAST(${questions.content} AS CHAR)) LIKE ${needle}`
);
sql`MATCH(${questions.contentText}) AGAINST(${booleanQuery} IN BOOLEAN MODE)`
)
}
}
if (type) {
@@ -201,6 +227,11 @@ export type QuestionsDashboardStats = {
questionCount: number
}
/**
* 获取题目仪表盘统计(题目总数)。
*
* @returns 包含 questionCount 的统计对象
*/
export const getQuestionsDashboardStatsRaw = async (): Promise<QuestionsDashboardStats> => {
const [row] = await db.select({ value: count() }).from(questions)
return { questionCount: Number(row?.value ?? 0) }
@@ -246,6 +277,16 @@ async function insertQuestionWithRelations(
return newQuestionId;
}
/**
* 创建题目(含子题与知识点关联),在单事务内递归插入。
*
* 递归遍历 `input.subQuestions` 创建子题parentId 指向父题 ID。
* 任一子题插入失败则整个事务回滚。
*
* @param input - 题目输入content/type/difficulty/knowledgePointIds/subQuestions
* @param authorId - 创建者用户 ID
* @returns 新创建的根题目 ID
*/
export async function createQuestionWithRelations(
input: CreateQuestionInput,
authorId: string
@@ -255,6 +296,17 @@ export async function createQuestionWithRelations(
});
}
/**
* 按 ID 更新题目content/type/difficulty并替换知识点关联。
*
* 在单事务内1) 更新题目字段2) 删除原 questionsToKnowledgePoints 关联;
* 3) 插入新关联。权限范围由 `canEditAll` 控制——非 all 范围只能更新自己创建的题目。
*
* @param id - 待更新的题目 ID
* @param input - 更新输入type/difficulty/content/knowledgePointIds
* @param canEditAll - 是否有全部数据范围权限
* @param authorId - 当前用户 ID用于权限过滤
*/
export async function updateQuestionById(
id: string,
input: UpdateQuestionInput,
@@ -291,29 +343,68 @@ export async function updateQuestionById(
});
}
/**
* 批量收集给定根题目 ID 的所有后代含自身ID。
*
* 使用 BFS 逐层 `inArray` 查询替代原先每节点逐条 SELECT 的 N+1 模式:
* 每层只发 1 条 SQL整体复杂度从 O(N) 降至 O(depth)。
* `visited` 跨调用共享以避免循环引用造成的无限递归。
*/
async function collectDescendantQuestionIds(
tx: Tx,
rootIds: string[],
visited: Set<string> = new Set(),
): Promise<string[]> {
const queue: string[] = []
for (const id of rootIds) {
if (!visited.has(id)) {
visited.add(id)
queue.push(id)
}
}
while (queue.length > 0) {
const currentLevel = queue.splice(0, queue.length)
const children = await tx
.select({ id: questions.id })
.from(questions)
.where(inArray(questions.parentId, currentLevel));
for (const child of children) {
if (!visited.has(child.id)) {
visited.add(child.id)
queue.push(child.id)
}
}
}
return Array.from(visited)
}
async function deleteQuestionRecursive(
tx: Tx,
questionId: string,
visited: Set<string> = new Set(),
): Promise<void> {
if (visited.has(questionId)) {
// 环检测:避免在异常数据(如循环引用)下无限递归
return
}
visited.add(questionId)
const children = await tx
.select({ id: questions.id })
.from(questions)
.where(eq(questions.parentId, questionId));
for (const child of children) {
await deleteQuestionRecursive(tx, child.id, visited);
}
await tx.delete(questions).where(eq(questions.id, questionId));
// 收集所有后代 ID含自身单条 inArray 批量删除,
// 替代原先每层每子节点逐条 SELECT + DELETE 的 N+1 模式。
// 环检测由 collectDescendantQuestionIds 内部的 visited Set 保证。
const allIds = await collectDescendantQuestionIds(tx, [questionId], visited)
if (allIds.length === 0) return
await tx.delete(questions).where(inArray(questions.id, allIds))
}
/**
* 按 ID 递归删除题目(含所有子题)。
*
* 在事务内1) 查询题目并校验存在性2) 校验权限(非 all 范围只能删除自己创建的题目);
* 3) 通过 BFS 收集所有后代 ID 后单条 `inArray` 批量删除。
*
* @param questionId - 待删除的根题目 ID
* @param canDeleteAll - 是否有全部数据范围权限
* @param authorId - 当前用户 ID用于权限过滤
* @throws 当题目不存在或无权删除时抛出 Error
*/
export async function deleteQuestionByIdRecursive(
questionId: string,
canDeleteAll: boolean,
@@ -369,26 +460,35 @@ export async function deleteQuestionsBatch(
const targetIds = targetRows.map((r) => r.id)
if (targetIds.length === 0) return 0
for (const id of targetIds) {
await deleteQuestionRecursive(tx, id)
// 一次性收集所有 targetIds 的后代(含自身),单条 inArray 删除,
// 替代原先循环调用 deleteQuestionRecursive 产生的 N×depth 查询。
const visited = new Set<string>()
const allIds = await collectDescendantQuestionIds(tx, targetIds, visited)
if (allIds.length > 0) {
await tx.delete(questions).where(inArray(questions.id, allIds))
}
return targetIds.length
})
}
export async function getKnowledgePointOptions(): Promise<KnowledgePointOption[]> {
export async function getKnowledgePointOptionsRaw(): Promise<KnowledgePointOption[]> {
// Delegate to textbooks module data-access to avoid direct queries on
// textbooks/chapters/knowledgePoints tables (owned by textbooks module).
return await getKnowledgePointOptionsFromTextbooks()
}
export const getKnowledgePointOptions = cacheFn(getKnowledgePointOptionsRaw, {
tags: ["questions"],
ttl: 600,
keyParts: ["questions", "getKnowledgePointOptions"],
})
/**
* 获取教材选项列表(级联筛选第一级)。
*
* 委托 textbooks 模块 data-access 获取教材列表,避免直接查询 textbooks 表。
*/
export async function getTextbookOptions(): Promise<TextbookOption[]> {
export async function getTextbookOptionsRaw(): Promise<TextbookOption[]> {
const textbooks = await getTextbooksFromTextbooks()
return textbooks.map((tb) => ({
id: tb.id,
@@ -397,13 +497,18 @@ export async function getTextbookOptions(): Promise<TextbookOption[]> {
grade: tb.grade,
}))
}
export const getTextbookOptions = cacheFn(getTextbookOptionsRaw, {
tags: ["questions"],
ttl: 600,
keyParts: ["questions", "getTextbookOptions"],
})
/**
* 获取指定教材下的章节选项列表(级联筛选第二级)。
*
* 委托 textbooks 模块 data-access 获取章节树,展平为选项列表。
*/
export async function getChapterOptions(textbookId: string): Promise<ChapterOption[]> {
export async function getChapterOptionsRaw(textbookId: string): Promise<ChapterOption[]> {
const chapters = await getChaptersByTextbookIdFromTextbooks(textbookId)
const options: ChapterOption[] = []
@@ -424,16 +529,26 @@ export async function getChapterOptions(textbookId: string): Promise<ChapterOpti
flatten(chapters)
return options
}
export const getChapterOptions = cacheFn(getChapterOptionsRaw, {
tags: ["questions"],
ttl: 600,
keyParts: ["questions", "getChapterOptions"],
})
/**
* 获取指定章节下的知识点选项列表(级联筛选第三级)。
*
* 委托 textbooks 模块 data-access 获取知识点列表。
*/
export async function getKnowledgePointOptionsByChapter(chapterId: string): Promise<{ id: string; name: string }[]> {
export async function getKnowledgePointOptionsByChapterRaw(chapterId: string): Promise<{ id: string; name: string }[]> {
const kps = await getKnowledgePointsByChapterIdFromTextbooks(chapterId)
return kps.map((kp) => ({ id: kp.id, name: kp.name }))
}
export const getKnowledgePointOptionsByChapter = cacheFn(getKnowledgePointOptionsByChapterRaw, {
tags: ["questions"],
ttl: 600,
keyParts: ["questions", "getKnowledgePointOptionsByChapter"],
})
// ---------------------------------------------------------------------------
// Cross-module query interfaces — read-only access for other modules
@@ -453,22 +568,28 @@ export const getKnowledgePointsForQuestionsRaw = async (
const uniqueIds = Array.from(new Set(questionIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return result
// 1. 查询 questionId -> knowledgePointId 关联questions 模块自有表)
const rows = await db
.select({
questionId: questionsToKnowledgePoints.questionId,
knowledgePointId: knowledgePoints.id,
knowledgePointName: knowledgePoints.name,
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
})
.from(questionsToKnowledgePoints)
.innerJoin(knowledgePoints, eq(knowledgePoints.id, questionsToKnowledgePoints.knowledgePointId))
.where(inArray(questionsToKnowledgePoints.questionId, uniqueIds))
if (rows.length === 0) return result
// 2. 跨模块批量解析知识点名称,避免直接 JOIN knowledgePoints 表
const kpIds = Array.from(new Set(rows.map((r) => r.knowledgePointId)))
const kpNameMap = await getKnowledgePointNamesByIds(kpIds)
// 3. 组装结果
for (const r of rows) {
const list = result.get(r.questionId) ?? []
list.push({
questionId: r.questionId,
knowledgePointId: r.knowledgePointId,
knowledgePointName: r.knowledgePointName,
knowledgePointName: kpNameMap.get(r.knowledgePointId) ?? "",
})
result.set(r.questionId, list)
}
@@ -574,7 +695,7 @@ export interface QuestionExportItem {
* 支持按 IDs 导出指定题目,或导出全部题目(受 pageSize 限制)。
* 遵循权限范围:非 all 范围只能导出自己创建的题目。
*/
export async function exportQuestions(
export async function exportQuestionsRaw(
questionIds?: string[],
canExportAll = true,
authorId?: string
@@ -620,6 +741,11 @@ export async function exportQuestions(
updatedAt: row.updatedAt,
}))
}
export const exportQuestions = cacheFn(exportQuestionsRaw, {
tags: ["questions"],
ttl: 60,
keyParts: ["questions", "exportQuestions"],
})
/** 导入题目的单条输入 */
export interface QuestionImportItem {
@@ -660,3 +786,42 @@ export async function importQuestions(
return createdIds
})
}
// ---------------------------------------------------------------------------
// Cross-module batch interfaces — question count per knowledge point
// ---------------------------------------------------------------------------
/**
* 跨模块接口:按知识点 ID 批量获取关联题目数。
*
* 供 textbooks 模块知识图谱使用,避免直接查询 questionsToKnowledgePoints 表。
*
* @param knowledgePointIds 知识点 ID 列表
* @returns Map<knowledgePointId, questionCount>
*/
export const getQuestionCountByKpIdsRaw = async (
knowledgePointIds: string[]
): Promise<Map<string, number>> => {
const result = new Map<string, number>()
const uniqueIds = Array.from(new Set(knowledgePointIds.filter((v): v is string => typeof v === "string" && v.length > 0)))
if (uniqueIds.length === 0) return result
const rows = await db
.select({
knowledgePointId: questionsToKnowledgePoints.knowledgePointId,
count: count(),
})
.from(questionsToKnowledgePoints)
.where(inArray(questionsToKnowledgePoints.knowledgePointId, uniqueIds))
.groupBy(questionsToKnowledgePoints.knowledgePointId)
for (const r of rows) {
result.set(r.knowledgePointId, Number(r.count))
}
return result
}
export const getQuestionCountByKpIds = cacheFn(getQuestionCountByKpIdsRaw, {
tags: ["questions"],
ttl: 300,
keyParts: ["questions", "getQuestionCountByKpIds"],
})

View File

@@ -4,7 +4,7 @@ import { after } from "next/server"
import type { ActionState } from "@/shared/types/action-state"
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard"
import { Permissions, type Permission } from "@/shared/types/permissions"
import { Permissions, type Permission, isPermission } from "@/shared/types/permissions"
import { logAudit } from "@/shared/lib/audit-logger"
import { logDataChange } from "@/shared/lib/change-logger"
import { invalidateFor } from "@/shared/lib/cache"
@@ -248,8 +248,8 @@ export async function setRolePermissionsAction(
}
const before = await getRolePermissions(parsed.data.roleId)
// Schema validates all strings are valid permission values, so the cast is safe.
const permissions = parsed.data.permissions as Permission[]
// Schema 已用 refine 校验所有字符串均为合法权限值,再用类型守卫收窄到 Permission[]
const permissions = parsed.data.permissions.filter(isPermission)
await setRolePermissions(parsed.data.roleId, permissions)
trackPermissionChange({

View File

@@ -128,6 +128,8 @@ export const PERMISSION_CATALOG: PermissionGroup[] = [
labelKey: "rbac:permissions.group.audit",
permissions: [
{ key: "AUDIT_LOG_READ", value: Permissions.AUDIT_LOG_READ, labelKey: "rbac:permissions.audit.read", descriptionKey: "rbac:permissions.audit.readDesc" },
{ key: "AUDIT_LOG_PURGE", value: Permissions.AUDIT_LOG_PURGE, labelKey: "rbac:permissions.audit.purge", descriptionKey: "rbac:permissions.audit.purgeDesc" },
{ key: "AUDIT_RETENTION_MANAGE", value: Permissions.AUDIT_RETENTION_MANAGE, labelKey: "rbac:permissions.audit.retentionManage", descriptionKey: "rbac:permissions.audit.retentionManageDesc" },
],
},
{