fix: patch P0 security vulnerabilities and critical UX issues across 6 modules

Security: Add admin/layout.tsx auth guard; Add requirePermission() to 12 admin pages

Dashboard: Fix StudentStatsGrid rendering; Fix teacher greeting; Add loading/error boundaries; Fix col-span; Add metadata

Announcements: Fix audience filtering; Add user detail page; Trigger notifications on publish; Pass classes data; Add loading.tsx

Messages: Implement soft delete; Add unread badge with polling; Add notification dropdown polling; Add keyword search; Add quiet hours DND

Management: Add loading/error for 9 admin routes; Fix admin-classes-view to use Select for school/grade

Profile/Settings: Add loading/error; Fix parent role routing; Create ParentSettingsView; Integrate AiProviderSettingsCard; Add Tab URL persistence; Add logout confirm; Add avatar; Fix Progress arbitrary class

Schema: Add senderDeletedAt/receiverDeletedAt to messages; Add quietHours to notificationPreferences; Add uniqueIndex import

Docs: Update architecture docs 004/005
This commit is contained in:
SpecialX
2026-06-22 13:57:31 +08:00
parent 5ff7ab9e72
commit a4d096a6fc
81 changed files with 2145 additions and 124 deletions

View File

@@ -7,8 +7,11 @@ import { cn } from "@/shared/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
/** Optional className applied to the inner indicator element. */
indicatorClassName?: string
}
>(({ className, value, indicatorClassName, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
@@ -18,7 +21,7 @@ const Progress = React.forwardRef<
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
className={cn("h-full w-full flex-1 bg-primary transition-all", indicatorClassName)}
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

View File

@@ -6,6 +6,7 @@ import {
int,
primaryKey,
index,
uniqueIndex,
json,
mysqlEnum,
boolean,
@@ -377,6 +378,52 @@ export const classSubjectTeachers = mysqlTable("class_subject_teachers", {
}).onDelete("cascade"),
}));
/**
* 班级邀请码表v3 新增,对标 Google Classroom / 钉钉教育 / 智学网)。
*
* 设计要点:
* - 独立表而非挂在 classes 表上,支持有效期/次数/审计/多码并存
* - 6 位字母数字(剔除歧义字符 0/O/1/I/L空间 22^6 ≈ 1.13 亿
* - status 枚举active/disabled/expired/exhausted
* - max_uses NULL=无限expires_at NULL=永久
* - 软删除revoke 时设置 status=disabled + revoked_at不物理删除审计需要
*
* 与 classes.invitationCode 的关系:
* - 新表上线后enrollStudentByInvitationCode / enrollTeacherByInvitationCode 优先查新表
* - classes.invitationCode 保留作为 fallback下个版本移除
*/
export const classInvitationCodeStatusEnum = mysqlEnum("class_invitation_code_status", [
"active",
"disabled",
"expired",
"exhausted",
]);
export const classInvitationCodes = mysqlTable("class_invitation_codes", {
id: id("id").primaryKey(),
classId: varchar("class_id", { length: 128 }).notNull().references(() => classes.id, { onDelete: "cascade" }),
code: varchar("code", { length: 8 }).notNull().unique(),
status: classInvitationCodeStatusEnum.default("active").notNull(),
maxUses: int("max_uses"),
usedCount: int("used_count").default(0).notNull(),
expiresAt: timestamp("expires_at"),
createdBy: varchar("created_by", { length: 128 }).notNull().references(() => users.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(),
revokedAt: timestamp("revoked_at"),
revokedBy: varchar("revoked_by", { length: 128 }),
note: varchar("note", { length: 255 }),
}, (table) => ({
codeIdx: uniqueIndex("class_invitation_codes_code_idx").on(table.code),
classIdx: index("class_invitation_codes_class_idx").on(table.classId),
statusExpiresIdx: index("class_invitation_codes_status_expires_idx").on(table.status, table.expiresAt),
classFk: foreignKey({
columns: [table.classId],
foreignColumns: [classes.id],
name: "cic_c_fk",
}).onDelete("cascade"),
}));
export const classEnrollments = mysqlTable("class_enrollments", {
classId: varchar("class_id", { length: 128 }).notNull(),
studentId: varchar("student_id", { length: 128 }).notNull(),
@@ -514,7 +561,7 @@ export const submissionAnswers = mysqlTable("submission_answers", {
export const homeworkAssignments = mysqlTable("homework_assignments", {
id: id("id").primaryKey(),
sourceExamId: varchar("source_exam_id", { length: 128 }).notNull(),
sourceExamId: varchar("source_exam_id", { length: 128 }),
title: varchar("title", { length: 255 }).notNull(),
description: text("description"),
structure: json("structure"),
@@ -904,6 +951,9 @@ export const messages = mysqlTable("messages", {
isRead: boolean("is_read").default(false).notNull(),
readAt: timestamp("read_at", { mode: "date" }),
parentMessageId: varchar("parent_message_id", { length: 128 }), // 回复链
// 软删除:发送方/接收方各自独立删除,互不影响
senderDeletedAt: timestamp("sender_deleted_at", { mode: "date" }),
receiverDeletedAt: timestamp("receiver_deleted_at", { mode: "date" }),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
}, (table) => ({
senderIdx: index("messages_sender_idx").on(table.senderId),
@@ -944,6 +994,10 @@ export const notificationPreferences = mysqlTable("notification_preferences", {
announcementNotifications: boolean("announcement_notifications").default(true).notNull(),
messageNotifications: boolean("message_notifications").default(true).notNull(),
attendanceNotifications: boolean("attendance_notifications").default(true).notNull(),
// 免打扰时段(格式 "HH:mm",如 "22:00"
quietHoursEnabled: boolean("quiet_hours_enabled").default(false).notNull(),
quietHoursStart: varchar("quiet_hours_start", { length: 5 }),
quietHoursEnd: varchar("quiet_hours_end", { length: 5 }),
createdAt: timestamp("created_at", { mode: "date" }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { mode: "date" }).defaultNow().onUpdateNow().notNull(),
}, (table) => ({