feat(parent,auth,onboarding,files,notifications,adaptive-practice,ai): add module updates

parent:

- Add parent-student-attendance-detail component

auth:

- Add actions, data-access, schema, services, types

onboarding:

- Add parent-children-form and hooks directory

files:

- Add actions, schema, hooks directory

notifications:

- Add schema and schema test

adaptive-practice:

- Add answer-input, answer-result, practice-result-view, practice-starter-with-nav

- Add question-card, question-content, lib and services directories

ai:

- Add context/create-ai-client-service, hooks/use-drag-position, hooks/use-position-persistence
This commit is contained in:
SpecialX
2026-07-03 10:26:12 +08:00
parent f3c223d914
commit e9a5264fe7
84 changed files with 6060 additions and 2530 deletions

View File

@@ -30,7 +30,12 @@ import {
getUnreadNotificationCount,
archiveNotification,
} from "./data-access"
import type { NotificationPayload, ChannelSendResult, Notification } from "./types"
import {
getNotificationPreferences,
upsertNotificationPreferences,
} from "./preferences"
import { UpdateNotificationPreferencesSchema } from "./schema"
import type { NotificationPayload, ChannelSendResult, Notification, NotificationPreferences, UpdateNotificationPreferencesInput } from "./types"
/**
* Zod 校验通知负载sendNotificationAction 入参)
@@ -298,3 +303,81 @@ export async function archiveNotificationAction(
return { success: false, message: "Unexpected error" }
}
}
// ---------------------------------------------------------------------------
// 通知偏好 Server Actions
//
// V3-P1-3: 从 messaging/actions.ts 迁移至 notifications/actions.ts
// 使通知偏好的 data-access、schema、actions 位于同一模块。
// 权限复用 MESSAGE_READ任何能读消息的用户都能管理通知偏好
// ---------------------------------------------------------------------------
/**
* 获取当前用户的通知偏好。
*/
export async function getNotificationPreferencesAction(): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const prefs = await getNotificationPreferences(ctx.userId)
return { success: true, data: prefs }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}
/**
* 更新当前用户的通知偏好。
*
* 从 FormData 中解析布尔值checkbox 提交 "on" 或不提交)和时间字符串。
*/
export async function updateNotificationPreferencesAction(
prevState: ActionState<NotificationPreferences> | null,
formData: FormData
): Promise<ActionState<NotificationPreferences>> {
try {
const ctx = await requirePermission(Permissions.MESSAGE_READ)
const parseBool = (key: string): boolean => formData.get(key) === "on"
const parseTime = (key: string): string | null => {
const v = formData.get(key)
if (typeof v !== "string") return null
const trimmed = v.trim()
return trimmed.length > 0 ? trimmed : null
}
const parsed = UpdateNotificationPreferencesSchema.safeParse({
emailEnabled: parseBool("emailEnabled"),
smsEnabled: parseBool("smsEnabled"),
pushEnabled: parseBool("pushEnabled"),
homeworkNotifications: parseBool("homeworkNotifications"),
gradeNotifications: parseBool("gradeNotifications"),
announcementNotifications: parseBool("announcementNotifications"),
messageNotifications: parseBool("messageNotifications"),
attendanceNotifications: parseBool("attendanceNotifications"),
quietHoursEnabled: parseBool("quietHoursEnabled"),
quietHoursStart: parseTime("quietHoursStart"),
quietHoursEnd: parseTime("quietHoursEnd"),
})
if (!parsed.success) {
return { success: false, message: "Invalid form data", errors: parsed.error.flatten().fieldErrors }
}
const input: UpdateNotificationPreferencesInput = parsed.data
const updated = await upsertNotificationPreferences(ctx.userId, input)
if (!updated) {
return { success: false, message: "Failed to update notification preferences" }
}
revalidatePath("/settings")
return { success: true, message: "Notification preferences updated", data: updated }
} catch (e) {
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
if (e instanceof Error) return { success: false, message: e.message }
return { success: false, message: "Unexpected error" }
}
}

View File

@@ -38,6 +38,7 @@ function getSmsConfig(): {
accessKeySecret: string | undefined
signName: string | undefined
templateCode: string | undefined
smsSdkAppId: string | undefined
} {
const rawProvider = process.env.SMS_PROVIDER ?? "mock"
return {
@@ -46,6 +47,7 @@ function getSmsConfig(): {
accessKeySecret: process.env.SMS_ACCESS_KEY_SECRET,
signName: process.env.SMS_SIGN_NAME,
templateCode: process.env.SMS_TEMPLATE_CODE,
smsSdkAppId: process.env.SMS_SDK_APP_ID,
}
}
@@ -198,7 +200,7 @@ class TencentSmsSender implements NotificationChannelSender {
const params = buildTemplateParams(payload)
const response = await client.SendSms({
PhoneNumberSet: [`+86${recipient.phone}`],
SmsSdkAppId: this.config.templateCode ?? "",
SmsSdkAppId: this.config.smsSdkAppId ?? "",
SignName: this.config.signName ?? "",
TemplateId: this.config.templateCode ?? "",
TemplateParamSet: [params.title, params.content],

View File

@@ -4,7 +4,7 @@ import { useEffect, useRef, useState } from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -32,6 +32,7 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
diagnostic: Stethoscope,
}
/** 轮询降级间隔(毫秒) */

View File

@@ -5,7 +5,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap } from "lucide-react"
import { Bell, CheckCheck, MessageSquare, Megaphone, PenTool, GraduationCap, Stethoscope } from "lucide-react"
import { Badge } from "@/shared/components/ui/badge"
import { Button } from "@/shared/components/ui/button"
@@ -21,8 +21,11 @@ const TYPE_ICON: Record<NotificationType, typeof Bell> = {
announcement: Megaphone,
homework: PenTool,
grade: GraduationCap,
diagnostic: Stethoscope,
}
const TYPE_KEYS: NotificationType[] = ["message", "announcement", "homework", "grade", "diagnostic"]
const PRIORITY_COLOR: Record<NotificationPriority, string> = {
low: "bg-muted text-muted-foreground",
normal: "bg-blue-500/10 text-blue-700 dark:text-blue-400",
@@ -103,7 +106,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
>
{t("filter.all")}
</Button>
{(Object.keys(TYPE_ICON) as NotificationType[]).map((type) => (
{TYPE_KEYS.map((type) => (
<Button
key={type}
variant={filterType === type ? "default" : "outline"}

View File

@@ -37,7 +37,7 @@ import type {
const toIsoRequired = (d: Date): string => d.toISOString()
const isNotificationType = (v: unknown): v is NotificationType =>
v === "message" || v === "announcement" || v === "homework" || v === "grade"
v === "message" || v === "announcement" || v === "homework" || v === "grade" || v === "diagnostic"
const toNotificationType = (v: string): NotificationType =>
isNotificationType(v) ? v : "message"
@@ -207,6 +207,7 @@ export async function logNotificationSend(
const errorPart = result.error ? ` error="${result.error}"` : ""
// 始终输出 console 日志(便于开发调试)
// TODO V3-P2-8: 接入统一日志服务shared/lib/logger替换 console.info
console.info(
`[NotificationLog] ${result.success ? "OK" : "FAIL"} channel=${result.channel} messageId=${result.messageId ?? "-"}${errorPart}`
)
@@ -227,6 +228,7 @@ export async function logNotificationSend(
})
} catch (dbError) {
// DB 写入失败不阻塞通知流程,仅记录错误
// TODO V3-P2-8: 接入统一日志服务shared/lib/logger替换 console.error
console.error("[NotificationLog] Failed to persist log:", dbError)
}
}

View File

@@ -141,12 +141,6 @@ export async function sendNotification(
export async function sendBatchNotifications(
payloads: NotificationPayload[]
): Promise<ChannelSendResult[][]> {
// 并行处理每个 payload
const results = await Promise.all(payloads.map((p) => sendNotification(p)))
// 汇总日志
const flatResults = results.flat()
logNotificationSendBatch(flatResults)
return results
// 并行处理每个 payload(每条通知的日志已在 sendNotification 内部记录)
return Promise.all(payloads.map((p) => sendNotification(p)))
}

View File

@@ -48,7 +48,11 @@ export {
markNotificationAsReadAction,
markAllNotificationsAsReadAction,
archiveNotificationAction,
getNotificationPreferencesAction,
updateNotificationPreferencesAction,
} from "./actions"
export { UpdateNotificationPreferencesSchema } from "./schema"
export type { UpdateNotificationPreferencesFormInput } from "./schema"
export { NotificationList, NotificationDropdown } from "./components"
export type {
NotificationChannel,

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest"
import { UpdateNotificationPreferencesSchema } from "./schema"
/**
* 通知偏好 Schema 测试
*
* V3-P1-3: 从 messaging/schema.test.ts 迁移至 notifications/schema.test.ts
* 与 notifications/schema.ts 位于同一模块。
*/
describe("UpdateNotificationPreferencesSchema", () => {
const validInput = {
emailEnabled: true,
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: false,
quietHoursEnabled: false,
}
it("should parse valid input without quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
expect(result.success).toBe(true)
})
it("should parse valid input with quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "22:00",
quietHoursEnd: "07:00",
})
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.quietHoursStart).toBe("22:00")
expect(result.data.quietHoursEnd).toBe("07:00")
}
})
it("should accept null quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: null,
quietHoursEnd: null,
})
expect(result.success).toBe(true)
})
it("should accept undefined quiet hours times", () => {
const result = UpdateNotificationPreferencesSchema.safeParse(validInput)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.quietHoursStart).toBeUndefined()
expect(result.data.quietHoursEnd).toBeUndefined()
}
})
it("should reject invalid time format for quietHoursStart", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "25:00",
})
expect(result.success).toBe(false)
})
it("should reject invalid time format for quietHoursEnd", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursEnd: "12:60",
})
expect(result.success).toBe(false)
})
it("should reject non-time string for quietHoursStart", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "not-a-time",
})
expect(result.success).toBe(false)
})
it("should accept boundary time 00:00", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursStart: "00:00",
})
expect(result.success).toBe(true)
})
it("should accept boundary time 23:59", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
quietHoursEnd: "23:59",
})
expect(result.success).toBe(true)
})
it("should reject non-boolean emailEnabled", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
emailEnabled: "yes",
})
expect(result.success).toBe(false)
})
it("should reject non-boolean smsEnabled", () => {
const result = UpdateNotificationPreferencesSchema.safeParse({
...validInput,
smsEnabled: 1,
})
expect(result.success).toBe(false)
})
it("should reject missing required boolean field", () => {
const inputWithoutEmail = {
smsEnabled: false,
pushEnabled: true,
homeworkNotifications: true,
gradeNotifications: true,
announcementNotifications: true,
messageNotifications: true,
attendanceNotifications: false,
quietHoursEnabled: false,
}
const result = UpdateNotificationPreferencesSchema.safeParse(inputWithoutEmail)
expect(result.success).toBe(false)
})
})

View File

@@ -0,0 +1,25 @@
import { z } from "zod"
/**
* 校验通知偏好更新表单8 个布尔字段 + 免打扰时段,来自 checkbox/FormData
*
* V3-P1-3: 从 messaging/schema.ts 迁移至 notifications/schema.ts
* 使通知偏好校验逻辑与其消费方notifications 模块)位于同一模块。
*/
export const UpdateNotificationPreferencesSchema = z.object({
emailEnabled: z.boolean(),
smsEnabled: z.boolean(),
pushEnabled: z.boolean(),
homeworkNotifications: z.boolean(),
gradeNotifications: z.boolean(),
announcementNotifications: z.boolean(),
messageNotifications: z.boolean(),
attendanceNotifications: z.boolean(),
quietHoursEnabled: z.boolean(),
quietHoursStart: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
quietHoursEnd: z.string().trim().regex(/^([01]\d|2[0-3]):[0-5]\d$/, "Invalid time format").nullable().optional(),
})
export type UpdateNotificationPreferencesFormInput = z.infer<
typeof UpdateNotificationPreferencesSchema
>

View File

@@ -18,7 +18,7 @@
export type NotificationChannel = "in_app" | "email" | "sms" | "wechat"
/** 站内通知类型message_notifications.type 列) */
export type NotificationType = "message" | "announcement" | "homework" | "grade"
export type NotificationType = "message" | "announcement" | "homework" | "grade" | "diagnostic"
/** 通知优先级message_notifications.priority 列) */
export type NotificationPriority = "low" | "normal" | "high" | "urgent"