feat: 新增备课模块并修复全模块 P0/P1/P2 缺陷
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
Some checks failed
Security / deep-security-scan (push) Failing after 20m5s
DR Drill / dr-drill (push) Failing after 1m31s
CI / scheduled-backup (push) Failing after 1m31s
CI / backup-verify (push) Has been skipped
CI / weekly-dr-drill (push) Failing after 0s
CI / build-deploy (push) Has been cancelled
CI / security-scan (push) Has been cancelled
主要变更: - 新增 lesson-preparation 模块: 备课编辑器、节点编辑、AI 建议、知识点选择、版本历史、作业发布 - 新增 shared 通用组件: charts/question-bank-filters/schedule-list/ui (chip-nav/filter-bar/page-header/stat-card/stat-item) - 新增 student/admin 端 loading.tsx 与 error.tsx, 优化加载与错误态体验 - 新增 teacher/lesson-plans 页面 (列表/新建/编辑) - 新增 drizzle 迁移 0002_tiny_lionheart 及 snapshot - 新增 textbooks/schema.ts 与 exams/utils/normalize-structure.ts - 修复 Tiptap v3 SSR hydration 崩溃 (rich-text-block immediatelyRender: false) - 重构多模块 data-access/actions/组件, 修复权限校验与类型规范 - 同步架构文档 004/005 反映新增模块、导出、依赖关系 - 归档 bugs/* 测试报告与 e2e 测试脚本 (admin/parent/student/teacher web_test)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import type { ActionState } from "@/shared/types/action-state";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
createTextbook,
|
||||
@@ -15,15 +16,20 @@ import {
|
||||
deleteTextbook,
|
||||
reorderChapters
|
||||
} from "./data-access";
|
||||
import type { CreateTextbookInput, UpdateTextbookInput } from "./types";
|
||||
import {
|
||||
CreateTextbookSchema,
|
||||
UpdateTextbookSchema,
|
||||
CreateChapterSchema,
|
||||
UpdateChapterContentSchema,
|
||||
CreateKnowledgePointSchema,
|
||||
UpdateKnowledgePointSchema,
|
||||
} from "./schema";
|
||||
|
||||
const getStringValue = (formData: FormData, key: string): string => {
|
||||
const value = formData.get(key)
|
||||
return typeof value === "string" ? value : ""
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
export async function reorderChaptersAction(
|
||||
chapterId: string,
|
||||
newIndex: number,
|
||||
@@ -43,36 +49,28 @@ export async function reorderChaptersAction(
|
||||
}
|
||||
}
|
||||
|
||||
export type ActionState = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
};
|
||||
|
||||
// ... existing createTextbookAction ...
|
||||
|
||||
export async function createTextbookAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
// ... implementation same as before
|
||||
const rawData: CreateTextbookInput = {
|
||||
const parsed = CreateTextbookSchema.safeParse({
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
publisher: getStringValue(formData, "publisher"),
|
||||
};
|
||||
});
|
||||
|
||||
if (!rawData.title || !rawData.subject || !rawData.grade) {
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please fill in all required fields.",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||||
await createTextbook(rawData);
|
||||
await createTextbook(parsed.data);
|
||||
revalidatePath("/teacher/textbooks");
|
||||
return {
|
||||
success: true,
|
||||
@@ -94,24 +92,25 @@ export async function updateTextbookAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const rawData: UpdateTextbookInput = {
|
||||
const parsed = UpdateTextbookSchema.safeParse({
|
||||
id: textbookId,
|
||||
title: getStringValue(formData, "title"),
|
||||
subject: getStringValue(formData, "subject"),
|
||||
grade: getStringValue(formData, "grade"),
|
||||
publisher: getStringValue(formData, "publisher"),
|
||||
};
|
||||
});
|
||||
|
||||
if (!rawData.title || !rawData.subject || !rawData.grade) {
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Please fill in all required fields.",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
await updateTextbook(rawData);
|
||||
await updateTextbook(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return {
|
||||
success: true,
|
||||
@@ -153,21 +152,27 @@ export async function deleteTextbookAction(
|
||||
export async function createChapterAction(
|
||||
textbookId: string,
|
||||
parentId: string | undefined,
|
||||
prevState: ActionState | null,
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const title = getStringValue(formData, "title");
|
||||
|
||||
if (!title) return { success: false, message: "Title is required" };
|
||||
const parsed = CreateChapterSchema.safeParse({
|
||||
textbookId,
|
||||
title: getStringValue(formData, "title"),
|
||||
parentId,
|
||||
order: 0,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Title is required",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||||
await createChapter({
|
||||
textbookId,
|
||||
title,
|
||||
parentId,
|
||||
order: 0 // Default order
|
||||
});
|
||||
await createChapter(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Chapter created successfully" };
|
||||
} catch (e) {
|
||||
@@ -183,9 +188,22 @@ export async function updateChapterContentAction(
|
||||
content: string,
|
||||
textbookId: string
|
||||
): Promise<ActionState> {
|
||||
const parsed = UpdateChapterContentSchema.safeParse({
|
||||
chapterId,
|
||||
content,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Invalid chapter content data",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
await updateChapterContent({ chapterId, content });
|
||||
await updateChapterContent(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Content updated successfully" };
|
||||
} catch (e) {
|
||||
@@ -219,15 +237,24 @@ export async function createKnowledgePointAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const name = getStringValue(formData, "name");
|
||||
const description = getStringValue(formData, "description");
|
||||
const anchorText = getStringValue(formData, "anchorText");
|
||||
const parsed = CreateKnowledgePointSchema.safeParse({
|
||||
name: getStringValue(formData, "name"),
|
||||
description: getStringValue(formData, "description") || undefined,
|
||||
anchorText: getStringValue(formData, "anchorText") || undefined,
|
||||
chapterId,
|
||||
});
|
||||
|
||||
if (!name) return { success: false, message: "Name is required" };
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Name is required",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_CREATE);
|
||||
await createKnowledgePoint({ name, description, anchorText, chapterId });
|
||||
await createKnowledgePoint(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point created successfully" };
|
||||
} catch (e) {
|
||||
@@ -261,15 +288,24 @@ export async function updateKnowledgePointAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData
|
||||
): Promise<ActionState> {
|
||||
const name = getStringValue(formData, "name");
|
||||
const description = getStringValue(formData, "description");
|
||||
const anchorText = getStringValue(formData, "anchorText");
|
||||
const parsed = UpdateKnowledgePointSchema.safeParse({
|
||||
id: kpId,
|
||||
name: getStringValue(formData, "name"),
|
||||
description: getStringValue(formData, "description") || undefined,
|
||||
anchorText: getStringValue(formData, "anchorText") || undefined,
|
||||
});
|
||||
|
||||
if (!name) return { success: false, message: "Name is required" };
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Name is required",
|
||||
errors: parsed.error.flatten().fieldErrors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await requirePermission(Permissions.TEXTBOOK_UPDATE);
|
||||
await updateKnowledgePoint({ id: kpId, name, description, anchorText });
|
||||
await updateKnowledgePoint(parsed.data);
|
||||
revalidatePath(`/teacher/textbooks/${textbookId}`);
|
||||
return { success: true, message: "Knowledge point updated successfully" };
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -12,6 +9,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function TextbookFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withDefault(""))
|
||||
@@ -21,16 +19,20 @@ export function TextbookFilters() {
|
||||
const hasFilters = Boolean(search || subject !== "all" || grade !== "all")
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="relative w-full md:w-80">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground/50" />
|
||||
<Input
|
||||
placeholder="Search by title, publisher..."
|
||||
className="pl-9 bg-background border-muted-foreground/20"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<FilterBar
|
||||
layout="between"
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setSearch(null)
|
||||
setSubject(null)
|
||||
setGrade(null)
|
||||
}}
|
||||
>
|
||||
<FilterSearchInput
|
||||
value={search}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search by title, publisher..."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={subject} onValueChange={(val) => setSubject(val === "all" ? null : val)}>
|
||||
@@ -63,23 +65,7 @@ export function TextbookFilters() {
|
||||
<SelectItem value="Grade 12">Grade 12</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setSubject(null)
|
||||
setGrade(null)
|
||||
}}
|
||||
className="h-10 px-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,21 +8,25 @@ import { db } from "@/shared/db"
|
||||
import { chapters, knowledgePoints, textbooks } from "@/shared/db/schema"
|
||||
import type {
|
||||
Chapter,
|
||||
CreateChapterInput,
|
||||
CreateTextbookInput,
|
||||
KnowledgePoint,
|
||||
Textbook,
|
||||
UpdateChapterContentInput,
|
||||
UpdateTextbookInput,
|
||||
} from "./types"
|
||||
import type {
|
||||
CreateChapterInput,
|
||||
CreateKnowledgePointInput,
|
||||
CreateTextbookInput,
|
||||
UpdateChapterContentInput,
|
||||
UpdateKnowledgePointInput,
|
||||
UpdateTextbookInput,
|
||||
} from "./schema"
|
||||
|
||||
const normalizeOptional = (v: string | null | undefined) => {
|
||||
const normalizeOptional = (v: string | null | undefined): string | null => {
|
||||
const trimmed = v?.trim()
|
||||
if (!trimmed) return null
|
||||
return trimmed
|
||||
}
|
||||
|
||||
const sortChapters = (a: Chapter, b: Chapter) => {
|
||||
const sortChapters = (a: Chapter, b: Chapter): number => {
|
||||
const ao = a.order ?? 0
|
||||
const bo = b.order ?? 0
|
||||
if (ao !== bo) return ao - bo
|
||||
@@ -308,10 +312,11 @@ export async function deleteChapter(id: string): Promise<void> {
|
||||
}
|
||||
|
||||
const idsToDelete: string[] = []
|
||||
const stack = [id]
|
||||
const stack: string[] = [id]
|
||||
const seen = new Set<string>()
|
||||
while (stack.length) {
|
||||
const cur = stack.pop()!
|
||||
const cur = stack.pop()
|
||||
if (!cur) break
|
||||
if (seen.has(cur)) continue
|
||||
seen.add(cur)
|
||||
idsToDelete.push(cur)
|
||||
@@ -376,7 +381,7 @@ export const getKnowledgePointsByTextbookId = cache(async (textbookId: string):
|
||||
}))
|
||||
})
|
||||
|
||||
export async function createKnowledgePoint(data: { name: string; description?: string; anchorText?: string; chapterId?: string; parentId?: string }): Promise<void> {
|
||||
export async function createKnowledgePoint(data: CreateKnowledgePointInput): Promise<void> {
|
||||
await db.insert(knowledgePoints).values({
|
||||
id: createId(),
|
||||
name: data.name,
|
||||
@@ -389,7 +394,7 @@ export async function createKnowledgePoint(data: { name: string; description?: s
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateKnowledgePoint(data: { id: string; name: string; description?: string; anchorText?: string }): Promise<void> {
|
||||
export async function updateKnowledgePoint(data: UpdateKnowledgePointInput): Promise<void> {
|
||||
await db
|
||||
.update(knowledgePoints)
|
||||
.set({
|
||||
@@ -453,3 +458,57 @@ export const getTextbooksDashboardStats = cache(async (): Promise<TextbooksDashb
|
||||
chapterCount: Number(chapterCountRow[0]?.value ?? 0),
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-module query interfaces — read-only access for other modules
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type KnowledgePointOption = {
|
||||
id: string
|
||||
name: string
|
||||
chapterId: string | null
|
||||
chapterTitle: string | null
|
||||
textbookId: string | null
|
||||
textbookTitle: string | null
|
||||
subject: string | null
|
||||
grade: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有知识点选项(含章节和教材信息)。
|
||||
* 供 questions 模块跨模块调用使用,避免直接查询 textbooks/chapters/knowledgePoints 表。
|
||||
*/
|
||||
export const getKnowledgePointOptions = cache(async (): Promise<KnowledgePointOption[]> => {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: knowledgePoints.id,
|
||||
name: knowledgePoints.name,
|
||||
chapterId: chapters.id,
|
||||
chapterTitle: chapters.title,
|
||||
textbookId: textbooks.id,
|
||||
textbookTitle: textbooks.title,
|
||||
subject: textbooks.subject,
|
||||
grade: textbooks.grade,
|
||||
})
|
||||
.from(knowledgePoints)
|
||||
.leftJoin(chapters, eq(chapters.id, knowledgePoints.chapterId))
|
||||
.leftJoin(textbooks, eq(textbooks.id, chapters.textbookId))
|
||||
.orderBy(
|
||||
asc(textbooks.title),
|
||||
asc(chapters.order),
|
||||
asc(chapters.title),
|
||||
asc(knowledgePoints.order),
|
||||
asc(knowledgePoints.name)
|
||||
)
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
chapterId: row.chapterId ?? null,
|
||||
chapterTitle: row.chapterTitle ?? null,
|
||||
textbookId: row.textbookId ?? null,
|
||||
textbookTitle: row.textbookTitle ?? null,
|
||||
subject: row.subject ?? null,
|
||||
grade: row.grade ?? null,
|
||||
}))
|
||||
})
|
||||
|
||||
64
src/modules/textbooks/schema.ts
Normal file
64
src/modules/textbooks/schema.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const CreateTextbookSchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(100),
|
||||
grade: z.string().min(1).max(100),
|
||||
publisher: z.string().max(255).optional().default(""),
|
||||
})
|
||||
|
||||
export type CreateTextbookInput = z.infer<typeof CreateTextbookSchema>
|
||||
|
||||
export const UpdateTextbookSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
title: z.string().min(1).max(255),
|
||||
subject: z.string().min(1).max(100),
|
||||
grade: z.string().min(1).max(100),
|
||||
publisher: z.string().max(255).optional().default(""),
|
||||
})
|
||||
|
||||
export type UpdateTextbookInput = z.infer<typeof UpdateTextbookSchema>
|
||||
|
||||
export const CreateChapterSchema = z.object({
|
||||
textbookId: z.string().min(1),
|
||||
title: z.string().min(1).max(255),
|
||||
parentId: z.string().optional(),
|
||||
order: z.coerce.number().int().min(0).optional(),
|
||||
})
|
||||
|
||||
export type CreateChapterInput = z.infer<typeof CreateChapterSchema>
|
||||
|
||||
export const UpdateChapterContentSchema = z.object({
|
||||
chapterId: z.string().min(1),
|
||||
content: z.string(),
|
||||
})
|
||||
|
||||
export type UpdateChapterContentInput = z.infer<typeof UpdateChapterContentSchema>
|
||||
|
||||
export const CreateKnowledgePointSchema = z.object({
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
anchorText: z.string().optional(),
|
||||
chapterId: z.string().optional(),
|
||||
parentId: z.string().optional(),
|
||||
})
|
||||
|
||||
export type CreateKnowledgePointInput = z.infer<typeof CreateKnowledgePointSchema>
|
||||
|
||||
export const UpdateKnowledgePointSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
anchorText: z.string().optional(),
|
||||
})
|
||||
|
||||
export type UpdateKnowledgePointInput = z.infer<typeof UpdateKnowledgePointSchema>
|
||||
|
||||
export const ReorderChaptersSchema = z.object({
|
||||
chapterId: z.string().min(1),
|
||||
newIndex: z.coerce.number().int().min(0),
|
||||
parentId: z.string().nullable(),
|
||||
textbookId: z.string().min(1),
|
||||
})
|
||||
|
||||
export type ReorderChaptersInput = z.infer<typeof ReorderChaptersSchema>
|
||||
@@ -1,7 +1,11 @@
|
||||
// Define types based on Drizzle Schema
|
||||
// In a real app, we would infer these from the schema, but since we might not have the full schema setup running locally with DB,
|
||||
// In a real app, we would infer these from the schema, but since we might not have the full schema setup running locally with DB,
|
||||
// we will define interfaces that match the schema description in ARCHITECTURE.md and schema.ts
|
||||
|
||||
// Note: Input types (CreateTextbookInput, UpdateTextbookInput, CreateChapterInput,
|
||||
// UpdateChapterContentInput, CreateKnowledgePointInput, UpdateKnowledgePointInput)
|
||||
// are defined in ./schema.ts alongside their Zod validation schemas.
|
||||
|
||||
export type Textbook = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -29,21 +33,6 @@ export type Chapter = {
|
||||
children?: Chapter[];
|
||||
};
|
||||
|
||||
export type CreateTextbookInput = {
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
publisher: string;
|
||||
};
|
||||
|
||||
export type UpdateTextbookInput = {
|
||||
id: string;
|
||||
title: string;
|
||||
subject: string;
|
||||
grade: string;
|
||||
publisher: string;
|
||||
};
|
||||
|
||||
export type KnowledgePoint = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -54,30 +43,3 @@ export type KnowledgePoint = {
|
||||
level: number;
|
||||
order: number;
|
||||
};
|
||||
|
||||
export type CreateChapterInput = {
|
||||
textbookId: string;
|
||||
title: string;
|
||||
parentId?: string;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type UpdateChapterContentInput = {
|
||||
chapterId: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type CreateKnowledgePointInput = {
|
||||
name: string;
|
||||
description?: string;
|
||||
anchorText?: string;
|
||||
parentId?: string;
|
||||
chapterId?: string;
|
||||
};
|
||||
|
||||
export type UpdateKnowledgePointInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
anchorText?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user