feat(modules): add leave-requests, invitation-codes, and standards modules

- Add leave-requests module for staff and student leave request management

- Add invitation-codes module for class invitation code generation and redemption

- Add standards module for curriculum standards management
This commit is contained in:
SpecialX
2026-07-03 10:24:07 +08:00
parent ac1de9e433
commit 7567f317e1
19 changed files with 3192 additions and 0 deletions

View File

@@ -0,0 +1,222 @@
/**
* M1 课标Standards模块 - Server Actions
*
* 所有 Action 必须调用 requirePermission() 进行权限校验。
* 返回值统一采用 ActionState<T> 类型。
*/
"use server";
import { getTranslations } from "next-intl/server";
import { revalidatePath } from "next/cache";
import {
createStandardSchema,
updateStandardSchema,
getStandardsParamsSchema,
linkPlanToStandardSchema,
} from "./schema";
import {
getStandards,
getStandardsTree,
getStandardById,
createStandard,
updateStandard,
deactivateStandard,
searchStandards,
getStandardsByPlanId,
linkPlanToStandard,
unlinkPlanFromStandard,
} from "./data-access";
import type { Standard, StandardTreeNode, LessonPlanStandardLink } from "./types";
import type { ActionState } from "@/shared/types/action-state";
import { Permissions } from "@/shared/types/permissions";
import { getAuthContext, requirePermission } from "@/shared/lib/auth-guard";
import { handleActionError } from "@/shared/lib/action-utils";
import { safeParseWithI18n, translateFieldErrors } from "@/modules/lesson-preparation/lib/i18n-errors";
/**
* 查询课标列表
*/
export async function getStandardsAction(
params?: Record<string, unknown>,
): Promise<ActionState<{ items: Standard[]; tree?: StandardTreeNode[] }>> {
try {
await requirePermission(Permissions.STANDARD_READ);
const t = await getTranslations("standards");
const parseResult = getStandardsParamsSchema.safeParse(params ?? {});
if (!parseResult.success) {
return {
success: false,
message: t("error.invalidParams"),
errors: await translateFieldErrors(parseResult.error.flatten().fieldErrors),
};
}
const { asTree, ...queryParams } = parseResult.data;
if (asTree) {
const tree = await getStandardsTree(queryParams);
return { success: true, data: { items: [], tree } };
}
const items = await getStandards(queryParams);
return { success: true, data: { items } };
} catch (e) {
return handleActionError(e);
}
}
/**
* 获取单个课标
*/
export async function getStandardByIdAction(
id: string,
): Promise<ActionState<{ standard: Standard }>> {
try {
await requirePermission(Permissions.STANDARD_READ);
const standard = await getStandardById(id);
if (!standard) {
const t = await getTranslations("standards");
return { success: false, message: t("error.notFound") };
}
return { success: true, data: { standard } };
} catch (e) {
return handleActionError(e);
}
}
/**
* 创建课标
*/
export async function createStandardAction(
input: Record<string, unknown>,
): Promise<ActionState<{ standard: Standard }>> {
try {
await requirePermission(Permissions.STANDARD_MANAGE);
const t = await getTranslations("standards");
const parseResult = await safeParseWithI18n(createStandardSchema, input);
if (!parseResult.success) return parseResult;
const auth = await getAuthContext();
if (!auth.userId) {
return { success: false, message: t("error.unauthorized") };
}
const standard = await createStandard(parseResult.data, auth.userId);
revalidatePath("/admin/standards");
return { success: true, data: { standard } };
} catch (e) {
return handleActionError(e);
}
}
/**
* 更新课标
*/
export async function updateStandardAction(
input: Record<string, unknown>,
): Promise<ActionState<{ standard: Standard }>> {
try {
await requirePermission(Permissions.STANDARD_MANAGE);
const t = await getTranslations("standards");
const parseResult = await safeParseWithI18n(updateStandardSchema, input);
if (!parseResult.success) return parseResult;
const { id, ...patch } = parseResult.data;
const updated = await updateStandard(id, patch);
if (!updated) {
return { success: false, message: t("error.notFound") };
}
revalidatePath("/admin/standards");
return { success: true, data: { standard: updated } };
} catch (e) {
return handleActionError(e);
}
}
/**
* 停用课标(软删除)
*/
export async function deactivateStandardAction(id: string): Promise<ActionState<null>> {
try {
await requirePermission(Permissions.STANDARD_MANAGE);
await deactivateStandard(id);
revalidatePath("/admin/standards");
return { success: true, data: null };
} catch (e) {
return handleActionError(e);
}
}
/**
* 搜索课标
*/
export async function searchStandardsAction(
keyword: string,
limit = 50,
): Promise<ActionState<{ items: Standard[] }>> {
try {
await requirePermission(Permissions.STANDARD_READ);
const items = await searchStandards(keyword, limit);
return { success: true, data: { items } };
} catch (e) {
return handleActionError(e);
}
}
/**
* 查询课案关联的课标
*/
export async function getPlanStandardsAction(
planId: string,
): Promise<ActionState<{ links: LessonPlanStandardLink[] }>> {
try {
await requirePermission(Permissions.STANDARD_READ);
const links = await getStandardsByPlanId(planId);
return { success: true, data: { links } };
} catch (e) {
return handleActionError(e);
}
}
/**
* 关联课案与课标
*/
export async function linkPlanToStandardAction(
input: Record<string, unknown>,
): Promise<ActionState<{ link: LessonPlanStandardLink }>> {
try {
await requirePermission(Permissions.STANDARD_LINK);
const t = await getTranslations("standards");
const parseResult = await safeParseWithI18n(linkPlanToStandardSchema, input);
if (!parseResult.success) return parseResult;
const auth = await getAuthContext();
if (!auth.userId) {
return { success: false, message: t("error.unauthorized") };
}
const link = await linkPlanToStandard(
parseResult.data.planId,
parseResult.data.standardId,
parseResult.data.relationType,
auth.userId,
);
revalidatePath(`/teacher/lesson-plans/${parseResult.data.planId}/edit`);
return { success: true, data: { link } };
} catch (e) {
return handleActionError(e);
}
}
/**
* 取消课案与课标的关联
*/
export async function unlinkPlanFromStandardAction(
planId: string,
standardId: string,
): Promise<ActionState<null>> {
try {
await requirePermission(Permissions.STANDARD_LINK);
await unlinkPlanFromStandard(planId, standardId);
revalidatePath(`/teacher/lesson-plans/${planId}/edit`);
return { success: true, data: null };
} catch (e) {
return handleActionError(e);
}
}

View File

@@ -0,0 +1,265 @@
/**
* M1 课标Standards模块 - 数据访问层
*
* 严格遵守三层架构:本文件只被 modules/standards/actions.ts 和 app/ 路由调用,
* 不直接被其他业务模块引用。其他模块如需查询课标,应通过本文件导出的函数。
*/
import "server-only";
import { db } from "@/shared/db";
import { standards, lessonPlanStandards } from "@/shared/db/schema";
import { and, eq, asc, like } from "drizzle-orm";
import type {
Standard,
StandardTreeNode,
LessonPlanStandardLink,
GetStandardsParams,
} from "./types";
import type { StandardLevel } from "../lesson-preparation/lib/type-guards";
/**
* 查询课标列表(可按层级/学科/年级过滤,可选返回树形结构)
*/
export async function getStandards(
params: GetStandardsParams = {},
): Promise<Standard[]> {
const conditions = [];
if (params.level) conditions.push(eq(standards.level, params.level));
if (params.parentId) conditions.push(eq(standards.parentId, params.parentId));
if (params.subjectId) conditions.push(eq(standards.subjectId, params.subjectId));
if (params.gradeId) conditions.push(eq(standards.gradeId, params.gradeId));
if (params.stage) conditions.push(eq(standards.stage, params.stage));
if (!params.includeInactive) conditions.push(eq(standards.isActive, true));
const rows = await db
.select()
.from(standards)
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(asc(standards.sortOrder), asc(standards.code));
return rows.map(mapRowToStandard);
}
/**
* 查询课标树形结构
*/
export async function getStandardsTree(
params: Omit<GetStandardsParams, "asTree" | "parentId"> = {},
): Promise<StandardTreeNode[]> {
const allStandards = await getStandards(params);
return buildStandardsTree(allStandards);
}
/**
* 按 ID 获取单个课标
*/
export async function getStandardById(id: string): Promise<Standard | null> {
const rows = await db.select().from(standards).where(eq(standards.id, id)).limit(1);
return rows.length === 0 ? null : mapRowToStandard(rows[0]!);
}
/**
* 按 code 获取单个课标
*/
export async function getStandardByCode(code: string): Promise<Standard | null> {
const rows = await db.select().from(standards).where(eq(standards.code, code)).limit(1);
return rows.length === 0 ? null : mapRowToStandard(rows[0]!);
}
/**
* 创建课标
*/
export async function createStandard(
input: {
code: string;
title: string;
description?: string;
level: StandardLevel;
parentId?: string;
subjectId?: string;
gradeId?: string;
stage?: string;
sortOrder?: number;
},
createdBy: string,
): Promise<Standard> {
const [row] = await db.insert(standards).values({
code: input.code,
title: input.title,
description: input.description,
level: input.level,
parentId: input.parentId,
subjectId: input.subjectId,
gradeId: input.gradeId,
stage: input.stage,
sortOrder: input.sortOrder ?? 0,
isActive: true,
createdBy,
});
const insertedId = row.insertId;
const created = await getStandardById(String(insertedId));
if (!created) throw new Error("STANDARD_CREATE_FAILED");
return created;
}
/**
* 更新课标
*/
export async function updateStandard(
id: string,
patch: {
title?: string;
description?: string;
parentId?: string;
sortOrder?: number;
isActive?: boolean;
},
): Promise<Standard | null> {
await db
.update(standards)
.set({
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.description !== undefined ? { description: patch.description } : {}),
...(patch.parentId !== undefined ? { parentId: patch.parentId } : {}),
...(patch.sortOrder !== undefined ? { sortOrder: patch.sortOrder } : {}),
...(patch.isActive !== undefined ? { isActive: patch.isActive } : {}),
})
.where(eq(standards.id, id));
return getStandardById(id);
}
/**
* 软删除课标(标记 isActive=false保留关联数据完整性
*/
export async function deactivateStandard(id: string): Promise<void> {
await db.update(standards).set({ isActive: false }).where(eq(standards.id, id));
}
/**
* 按关键词搜索课标
*/
export async function searchStandards(
keyword: string,
limit = 50,
): Promise<Standard[]> {
const rows = await db
.select()
.from(standards)
.where(like(standards.title, `%${keyword}%`))
.limit(limit)
.orderBy(asc(standards.sortOrder));
return rows.map(mapRowToStandard);
}
// ---- 课案 ↔ 课标 关联 ----
/**
* 查询课案关联的所有课标
*/
export async function getStandardsByPlanId(
planId: string,
): Promise<LessonPlanStandardLink[]> {
const rows = await db
.select({
link: lessonPlanStandards,
standard: standards,
})
.from(lessonPlanStandards)
.innerJoin(standards, eq(lessonPlanStandards.standardId, standards.id))
.where(eq(lessonPlanStandards.planId, planId))
.orderBy(asc(lessonPlanStandards.createdAt));
return rows.map((r) => ({
id: r.link.id,
planId: r.link.planId,
standardId: r.link.standardId,
relationType: r.link.relationType as "primary" | "related",
createdBy: r.link.createdBy,
createdAt: r.link.createdAt,
standard: mapRowToStandard(r.standard),
}));
}
/**
* 关联课案与课标
*/
export async function linkPlanToStandard(
planId: string,
standardId: string,
relationType: "primary" | "related",
createdBy: string,
): Promise<LessonPlanStandardLink> {
const [row] = await db.insert(lessonPlanStandards).values({
planId,
standardId,
relationType,
createdBy,
});
const insertedId = row.insertId;
const links = await getStandardsByPlanId(planId);
const created = links.find((l) => l.id === String(insertedId));
if (!created) throw new Error("STANDARD_LINK_CREATE_FAILED");
return created;
}
/**
* 取消课案与课标的关联
*/
export async function unlinkPlanFromStandard(
planId: string,
standardId: string,
): Promise<void> {
await db
.delete(lessonPlanStandards)
.where(
and(
eq(lessonPlanStandards.planId, planId),
eq(lessonPlanStandards.standardId, standardId),
),
);
}
// ---- 辅助函数 ----
function mapRowToStandard(row: typeof standards.$inferSelect): Standard {
return {
id: row.id,
code: row.code,
title: row.title,
description: row.description ?? undefined,
level: row.level as StandardLevel,
parentId: row.parentId ?? undefined,
subjectId: row.subjectId ?? undefined,
gradeId: row.gradeId ?? undefined,
stage: row.stage ?? undefined,
sortOrder: row.sortOrder,
isActive: row.isActive,
createdBy: row.createdBy ?? undefined,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* 将扁平列表构建为树形结构
*/
function buildStandardsTree(items: Standard[]): StandardTreeNode[] {
const map = new Map<string, StandardTreeNode>();
const roots: StandardTreeNode[] = [];
// 第一遍:建立 id → node 映射
for (const item of items) {
map.set(item.id, { ...item, children: [] });
}
// 第二遍:构建父子关系
for (const item of items) {
const node = map.get(item.id)!;
if (item.parentId && map.has(item.parentId)) {
map.get(item.parentId)!.children.push(node);
} else {
roots.push(node);
}
}
return roots;
}

View File

@@ -0,0 +1,68 @@
/**
* M1 课标Standards模块 - Zod 验证 schema
*
* 所有错误消息使用 i18n 键,由 actions 层通过 translateFieldErrors() 翻译。
*/
import { z } from "zod";
export const createStandardSchema = z.object({
code: z.string().min(1, "error.codeRequired").max(100, "error.codeTooLong"),
title: z.string().min(1, "error.titleRequired").max(255, "error.titleTooLong"),
description: z.string().max(2000, "error.descriptionTooLong").optional(),
level: z.enum(["national", "curriculum", "custom"]),
parentId: z.string().optional(),
subjectId: z.string().optional(),
gradeId: z.string().optional(),
stage: z.enum(["primary", "junior_high", "senior_high"]).optional(),
sortOrder: z.number().int().min(0).default(0),
});
export const updateStandardSchema = z.object({
id: z.string().min(1, "error.idRequired"),
title: z.string().min(1, "error.titleRequired").max(255, "error.titleTooLong").optional(),
description: z.string().max(2000, "error.descriptionTooLong").optional(),
parentId: z.string().optional(),
sortOrder: z.number().int().min(0).optional(),
isActive: z.boolean().optional(),
});
export const getStandardsParamsSchema = z.object({
level: z.enum(["national", "curriculum", "custom"]).optional(),
parentId: z.string().optional(),
subjectId: z.string().optional(),
gradeId: z.string().optional(),
stage: z.enum(["primary", "junior_high", "senior_high"]).optional(),
includeInactive: z.boolean().optional(),
asTree: z.boolean().optional(),
});
export const importStandardsSchema = z.object({
standards: z
.array(
z.object({
code: z.string().min(1, "error.codeRequired"),
title: z.string().min(1, "error.titleRequired"),
description: z.string().optional(),
level: z.enum(["national", "curriculum", "custom"]),
parentId: z.string().optional(),
subjectId: z.string().optional(),
gradeId: z.string().optional(),
stage: z.enum(["primary", "junior_high", "senior_high"]).optional(),
sortOrder: z.number().int().min(0).optional(),
}),
)
.min(1, "error.atLeastOneStandard"),
conflictStrategy: z.enum(["skip", "update", "fail"]).default("skip"),
});
export const linkPlanToStandardSchema = z.object({
planId: z.string().min(1, "error.planIdRequired"),
standardId: z.string().min(1, "error.standardIdRequired"),
relationType: z.enum(["primary", "related"]).default("primary"),
});
export type CreateStandardInput = z.infer<typeof createStandardSchema>;
export type UpdateStandardInput = z.infer<typeof updateStandardSchema>;
export type GetStandardsParamsInput = z.infer<typeof getStandardsParamsSchema>;
export type ImportStandardsInputZod = z.infer<typeof importStandardsSchema>;
export type LinkPlanToStandardInput = z.infer<typeof linkPlanToStandardSchema>;

View File

@@ -0,0 +1,83 @@
/**
* M1 课标Standards模块 - 类型定义
*
* 支持国家标准 / 课标 / 自定义三层级课标库。
* 课案通过 lessonPlanStandards 关联表实现多对多关系。
*/
import type { StandardLevel } from "../lesson-preparation/lib/type-guards";
/** 课标节点 */
export interface Standard {
id: string;
code: string;
title: string;
description?: string;
level: StandardLevel;
parentId?: string;
subjectId?: string;
gradeId?: string;
/** 学段primary / junior_high / senior_high */
stage?: string;
sortOrder: number;
isActive: boolean;
createdBy?: string;
createdAt: Date;
updatedAt: Date;
}
/** 课标树节点(带子节点) */
export interface StandardTreeNode extends Standard {
children: StandardTreeNode[];
}
/** 课案 ↔ 课标 关联 */
export interface LessonPlanStandardLink {
id: string;
planId: string;
standardId: string;
/** 关联类型primary主要对标/ related相关对标 */
relationType: "primary" | "related";
createdBy: string;
createdAt: Date;
/** 关联的课标详情join 查询时填充) */
standard?: Standard;
}
/** 课案课标覆盖度统计 */
export interface LessonPlanStandardsCoverage {
planId: string;
totalStandards: number;
primaryCount: number;
relatedCount: number;
coveragePercent: number;
}
/** 课标查询参数 */
export interface GetStandardsParams {
level?: StandardLevel;
parentId?: string;
subjectId?: string;
gradeId?: string;
stage?: string;
includeInactive?: boolean;
/** 是否返回树形结构 */
asTree?: boolean;
}
/** 课标导入参数 */
export interface ImportStandardsInput {
standards: Array<{
code: string;
title: string;
description?: string;
level: StandardLevel;
parentId?: string;
subjectId?: string;
gradeId?: string;
stage?: string;
sortOrder?: number;
}>;
/** 导入策略skip跳过已存在/ update更新已存在/ fail重复则失败 */
conflictStrategy?: "skip" | "update" | "fail";
}