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:
@@ -19,6 +19,14 @@ import {
|
||||
} from "./data-access"
|
||||
import type { GetAnnouncementsParams, Announcement } from "./types"
|
||||
|
||||
function handleActionError(e: unknown): ActionState<never> {
|
||||
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" }
|
||||
}
|
||||
|
||||
export async function createAnnouncementAction(
|
||||
prevState: ActionState<string> | null,
|
||||
formData: FormData
|
||||
@@ -71,11 +79,7 @@ export async function createAnnouncementAction(
|
||||
|
||||
return { success: true, message: "Announcement created", data: id }
|
||||
} 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" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,11 +139,7 @@ export async function updateAnnouncementAction(
|
||||
|
||||
return { success: true, message: "Announcement updated", data: id }
|
||||
} 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" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,11 +157,7 @@ export async function deleteAnnouncementAction(id: string): Promise<ActionState<
|
||||
|
||||
return { success: true, message: "Announcement deleted" }
|
||||
} 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" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,11 +179,7 @@ export async function publishAnnouncementAction(id: string): Promise<ActionState
|
||||
|
||||
return { success: true, message: "Announcement published" }
|
||||
} 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" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,11 +198,7 @@ export async function archiveAnnouncementAction(id: string): Promise<ActionState
|
||||
|
||||
return { success: true, message: "Announcement archived" }
|
||||
} 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" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,10 +210,6 @@ export async function getAnnouncementsAction(
|
||||
const data = await getAnnouncements(params)
|
||||
return { success: true, data }
|
||||
} 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" }
|
||||
return handleActionError(e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { useMemo } from "react"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
@@ -35,36 +34,33 @@ export function AnnouncementCard({
|
||||
announcement: Announcement
|
||||
href?: string
|
||||
}) {
|
||||
const card = useMemo(
|
||||
() => (
|
||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{announcement.title}</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[announcement.status]} className="shrink-0">
|
||||
{STATUS_LABEL[announcement.status]}
|
||||
const card = (
|
||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
<CardTitle className="line-clamp-2 text-base">{announcement.title}</CardTitle>
|
||||
<Badge variant={STATUS_VARIANT[announcement.status]} className="shrink-0">
|
||||
{STATUS_LABEL[announcement.status]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="line-clamp-3 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{announcement.content}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{TYPE_LABEL[announcement.type]}
|
||||
</Badge>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<p className="line-clamp-3 text-sm text-muted-foreground whitespace-pre-wrap">
|
||||
{announcement.content}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{TYPE_LABEL[announcement.type]}
|
||||
</Badge>
|
||||
<span>
|
||||
{announcement.publishedAt
|
||||
? `Published ${formatDate(announcement.publishedAt)}`
|
||||
: `Updated ${formatDate(announcement.updatedAt)}`}
|
||||
</span>
|
||||
{announcement.authorName ? (
|
||||
<span className="ml-auto">by {announcement.authorName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
),
|
||||
[announcement]
|
||||
<span>
|
||||
{announcement.publishedAt
|
||||
? `Published ${formatDate(announcement.publishedAt)}`
|
||||
: `Updated ${formatDate(announcement.updatedAt)}`}
|
||||
</span>
|
||||
{announcement.authorName ? (
|
||||
<span className="ml-auto">by {announcement.authorName}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
|
||||
if (href) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import {
|
||||
@@ -120,9 +121,9 @@ export function AnnouncementDetail({
|
||||
<div className="flex items-center gap-2">
|
||||
{backHref ? (
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<a href={backHref}>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<h2 className="text-2xl font-bold tracking-tight">Announcement</h2>
|
||||
@@ -143,10 +144,10 @@ export function AnnouncementDetail({
|
||||
) : null}
|
||||
{editHref ? (
|
||||
<Button asChild>
|
||||
<a href={editHref}>
|
||||
<Link href={editHref}>
|
||||
<Pencil className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Plus } from "lucide-react"
|
||||
|
||||
@@ -73,10 +74,10 @@ export function AnnouncementList({
|
||||
</Select>
|
||||
{canManage && createHref ? (
|
||||
<Button asChild>
|
||||
<a href={createHref}>
|
||||
<Link href={createHref}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
New Announcement
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -49,77 +49,67 @@ const mapRow = (
|
||||
|
||||
export const getAnnouncements = cache(
|
||||
async (params?: GetAnnouncementsParams): Promise<Announcement[]> => {
|
||||
try {
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
const page = Math.max(1, params?.page ?? 1)
|
||||
const pageSize = Math.max(1, params?.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const conditions = []
|
||||
if (params?.status) {
|
||||
conditions.push(eq(announcements.status, params.status))
|
||||
}
|
||||
if (params?.type) {
|
||||
conditions.push(eq(announcements.type, params.type))
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
type: announcements.type,
|
||||
status: announcements.status,
|
||||
targetGradeId: announcements.targetGradeId,
|
||||
targetClassId: announcements.targetClassId,
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(announcements.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
|
||||
return rows.map(mapRow)
|
||||
} catch (error) {
|
||||
console.error("getAnnouncements failed:", error)
|
||||
return []
|
||||
const conditions = []
|
||||
if (params?.status) {
|
||||
conditions.push(eq(announcements.status, params.status))
|
||||
}
|
||||
if (params?.type) {
|
||||
conditions.push(eq(announcements.type, params.type))
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
type: announcements.type,
|
||||
status: announcements.status,
|
||||
targetGradeId: announcements.targetGradeId,
|
||||
targetClassId: announcements.targetClassId,
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||
.where(conditions.length > 0 ? and(...conditions) : undefined)
|
||||
.orderBy(desc(announcements.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
|
||||
return rows.map(mapRow)
|
||||
}
|
||||
)
|
||||
|
||||
export const getAnnouncementById = cache(
|
||||
async (id: string): Promise<Announcement | null> => {
|
||||
try {
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
type: announcements.type,
|
||||
status: announcements.status,
|
||||
targetGradeId: announcements.targetGradeId,
|
||||
targetClassId: announcements.targetClassId,
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||
.where(eq(announcements.id, id))
|
||||
.limit(1)
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: announcements.id,
|
||||
title: announcements.title,
|
||||
content: announcements.content,
|
||||
type: announcements.type,
|
||||
status: announcements.status,
|
||||
targetGradeId: announcements.targetGradeId,
|
||||
targetClassId: announcements.targetClassId,
|
||||
authorId: announcements.authorId,
|
||||
authorName: users.name,
|
||||
publishedAt: announcements.publishedAt,
|
||||
createdAt: announcements.createdAt,
|
||||
updatedAt: announcements.updatedAt,
|
||||
})
|
||||
.from(announcements)
|
||||
.leftJoin(users, eq(users.id, announcements.authorId))
|
||||
.where(eq(announcements.id, id))
|
||||
.limit(1)
|
||||
|
||||
return row ? mapRow(row) : null
|
||||
} catch (error) {
|
||||
console.error("getAnnouncementById failed:", error)
|
||||
return null
|
||||
}
|
||||
return row ? mapRow(row) : null
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { StatItem } from "@/shared/components/ui/stat-item"
|
||||
import {
|
||||
Users,
|
||||
CheckCircle2,
|
||||
@@ -10,26 +11,6 @@ import {
|
||||
} from "lucide-react"
|
||||
import type { AttendanceStats } from "../types"
|
||||
|
||||
interface StatItemProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
hint?: string
|
||||
}
|
||||
|
||||
function StatItem({ label, value, icon, hint }: StatItemProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">{value}</span>
|
||||
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function AttendanceStatsCard({ stats }: { stats: AttendanceStats | null }) {
|
||||
if (!stats || stats.total === 0) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { ChipNav } from "@/shared/components/ui/chip-nav"
|
||||
|
||||
interface AttendanceStatsClassSelectorProps {
|
||||
classes: Array<{ id: string; name: string }>
|
||||
currentClassId: string
|
||||
startDate: string
|
||||
endDate: string
|
||||
}
|
||||
|
||||
export function AttendanceStatsClassSelector({
|
||||
classes,
|
||||
currentClassId,
|
||||
startDate,
|
||||
endDate,
|
||||
}: AttendanceStatsClassSelectorProps): JSX.Element {
|
||||
const dateParams = `${startDate ? `&startDate=${startDate}` : ""}${endDate ? `&endDate=${endDate}` : ""}`
|
||||
|
||||
return (
|
||||
<ChipNav
|
||||
options={classes}
|
||||
currentId={currentClassId}
|
||||
buildHref={(id) => `/teacher/attendance/stats?classId=${id}${dateParams}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -67,7 +67,9 @@ const mapListItem = (
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
})
|
||||
|
||||
const resolveRecorderNames = async (rows: { record: typeof attendanceRecords.$inferSelect }[]) => {
|
||||
const resolveRecorderNames = async (
|
||||
rows: { record: typeof attendanceRecords.$inferSelect }[]
|
||||
): Promise<Map<string, string>> => {
|
||||
const ids = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
|
||||
const map = new Map<string, string>()
|
||||
if (ids.length > 0) {
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { PermissionDeniedError, requirePermission } from "@/shared/lib/auth-guard"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { ActionState } from "@/shared/types/action-state"
|
||||
import { exportToExcel } from "@/shared/lib/excel"
|
||||
import { exportToExcel, type ExcelColumn } from "@/shared/lib/excel"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
getAuditLogsForExport,
|
||||
@@ -60,6 +61,32 @@ export async function getDataChangeLogsAction(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 Excel 导出辅助函数。
|
||||
* 将 sheet 配置 + 行数据生成 Excel buffer,并按 filenamePrefix 拼接日期文件名。
|
||||
* 抽取自三个 export*Action,消除重复的 exportToExcel + filename 构造逻辑。
|
||||
*/
|
||||
async function buildExcelExport<TRow extends Record<string, unknown>>(params: {
|
||||
sheetName: string
|
||||
columns: ExcelColumn[]
|
||||
rows: TRow[]
|
||||
filenamePrefix: string
|
||||
}): Promise<{ buffer: Buffer; filename: string }> {
|
||||
const buffer = await exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: params.sheetName,
|
||||
columns: params.columns,
|
||||
rows: params.rows,
|
||||
},
|
||||
],
|
||||
})
|
||||
return {
|
||||
buffer,
|
||||
filename: `${params.filenamePrefix}_${formatDateForFile()}.xlsx`,
|
||||
}
|
||||
}
|
||||
|
||||
export async function exportAuditLogsAction(
|
||||
params?: AuditLogQueryParams
|
||||
): Promise<ActionState<{ buffer: Buffer; filename: string }>> {
|
||||
@@ -67,42 +94,36 @@ export async function exportAuditLogsAction(
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
const items = await getAuditLogsForExport(params)
|
||||
|
||||
const buffer = await exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: "Audit Logs",
|
||||
columns: [
|
||||
{ header: "User ID", key: "userId", width: 22 },
|
||||
{ header: "User Name", key: "userName", width: 18 },
|
||||
{ header: "Module", key: "module", width: 16 },
|
||||
{ header: "Action", key: "action", width: 22 },
|
||||
{ header: "Target ID", key: "targetId", width: 22 },
|
||||
{ header: "Target Type", key: "targetType", width: 16 },
|
||||
{ header: "Detail", key: "detail", width: 40 },
|
||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||
{ header: "Status", key: "status", width: 10 },
|
||||
{ header: "Created At", key: "createdAt", width: 22 },
|
||||
],
|
||||
rows: items.map((r) => ({
|
||||
userId: r.userId,
|
||||
userName: r.userName,
|
||||
module: r.module,
|
||||
action: r.action,
|
||||
targetId: r.targetId ?? "",
|
||||
targetType: r.targetType ?? "",
|
||||
detail: r.detail ?? "",
|
||||
ipAddress: r.ipAddress ?? "",
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
},
|
||||
const { buffer, filename } = await buildExcelExport({
|
||||
sheetName: "Audit Logs",
|
||||
columns: [
|
||||
{ header: "User ID", key: "userId", width: 22 },
|
||||
{ header: "User Name", key: "userName", width: 18 },
|
||||
{ header: "Module", key: "module", width: 16 },
|
||||
{ header: "Action", key: "action", width: 22 },
|
||||
{ header: "Target ID", key: "targetId", width: 22 },
|
||||
{ header: "Target Type", key: "targetType", width: 16 },
|
||||
{ header: "Detail", key: "detail", width: 40 },
|
||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||
{ header: "Status", key: "status", width: 10 },
|
||||
{ header: "Created At", key: "createdAt", width: 22 },
|
||||
],
|
||||
rows: items.map((r) => ({
|
||||
userId: r.userId,
|
||||
userName: r.userName,
|
||||
module: r.module,
|
||||
action: r.action,
|
||||
targetId: r.targetId ?? "",
|
||||
targetType: r.targetType ?? "",
|
||||
detail: r.detail ?? "",
|
||||
ipAddress: r.ipAddress ?? "",
|
||||
status: r.status,
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
filenamePrefix: "audit_logs",
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { buffer, filename: `audit_logs_${formatDateForFile()}.xlsx` },
|
||||
}
|
||||
return { success: true, data: { buffer, filename } }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
@@ -117,38 +138,32 @@ export async function exportLoginLogsAction(
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
const items = await getLoginLogsForExport(params)
|
||||
|
||||
const buffer = await exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: "Login Logs",
|
||||
columns: [
|
||||
{ header: "User ID", key: "userId", width: 22 },
|
||||
{ header: "User Email", key: "userEmail", width: 26 },
|
||||
{ header: "Action", key: "action", width: 12 },
|
||||
{ header: "Status", key: "status", width: 10 },
|
||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||
{ header: "User Agent", key: "userAgent", width: 40 },
|
||||
{ header: "Error Message", key: "errorMessage", width: 30 },
|
||||
{ header: "Created At", key: "createdAt", width: 22 },
|
||||
],
|
||||
rows: items.map((r) => ({
|
||||
userId: r.userId ?? "",
|
||||
userEmail: r.userEmail,
|
||||
action: r.action,
|
||||
status: r.status,
|
||||
ipAddress: r.ipAddress ?? "",
|
||||
userAgent: r.userAgent ?? "",
|
||||
errorMessage: r.errorMessage ?? "",
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
},
|
||||
const { buffer, filename } = await buildExcelExport({
|
||||
sheetName: "Login Logs",
|
||||
columns: [
|
||||
{ header: "User ID", key: "userId", width: 22 },
|
||||
{ header: "User Email", key: "userEmail", width: 26 },
|
||||
{ header: "Action", key: "action", width: 12 },
|
||||
{ header: "Status", key: "status", width: 10 },
|
||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||
{ header: "User Agent", key: "userAgent", width: 40 },
|
||||
{ header: "Error Message", key: "errorMessage", width: 30 },
|
||||
{ header: "Created At", key: "createdAt", width: 22 },
|
||||
],
|
||||
rows: items.map((r) => ({
|
||||
userId: r.userId ?? "",
|
||||
userEmail: r.userEmail,
|
||||
action: r.action,
|
||||
status: r.status,
|
||||
ipAddress: r.ipAddress ?? "",
|
||||
userAgent: r.userAgent ?? "",
|
||||
errorMessage: r.errorMessage ?? "",
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
filenamePrefix: "login_logs",
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { buffer, filename: `login_logs_${formatDateForFile()}.xlsx` },
|
||||
}
|
||||
return { success: true, data: { buffer, filename } }
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError) return { success: false, message: e.message }
|
||||
if (e instanceof Error) return { success: false, message: e.message }
|
||||
@@ -163,50 +178,37 @@ export async function exportDataChangeLogsAction(
|
||||
await requirePermission(Permissions.AUDIT_LOG_READ)
|
||||
const items = await getDataChangeLogsForExport(params)
|
||||
|
||||
const buffer = await exportToExcel({
|
||||
sheets: [
|
||||
{
|
||||
name: "Data Change Logs",
|
||||
columns: [
|
||||
{ header: "Table Name", key: "tableName", width: 22 },
|
||||
{ header: "Record ID", key: "recordId", width: 22 },
|
||||
{ header: "Action", key: "action", width: 10 },
|
||||
{ header: "Old Value", key: "oldValue", width: 50 },
|
||||
{ header: "New Value", key: "newValue", width: 50 },
|
||||
{ header: "Changed By", key: "changedBy", width: 22 },
|
||||
{ header: "Changed By Name", key: "changedByName", width: 18 },
|
||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||
{ header: "Created At", key: "createdAt", width: 22 },
|
||||
],
|
||||
rows: items.map((r) => ({
|
||||
tableName: r.tableName,
|
||||
recordId: r.recordId,
|
||||
action: r.action,
|
||||
oldValue: r.oldValue ?? "",
|
||||
newValue: r.newValue ?? "",
|
||||
changedBy: r.changedBy,
|
||||
changedByName: r.changedByName,
|
||||
ipAddress: r.ipAddress ?? "",
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
},
|
||||
const { buffer, filename } = await buildExcelExport({
|
||||
sheetName: "Data Change Logs",
|
||||
columns: [
|
||||
{ header: "Table Name", key: "tableName", width: 22 },
|
||||
{ header: "Record ID", key: "recordId", width: 22 },
|
||||
{ header: "Action", key: "action", width: 10 },
|
||||
{ header: "Old Value", key: "oldValue", width: 50 },
|
||||
{ header: "New Value", key: "newValue", width: 50 },
|
||||
{ header: "Changed By", key: "changedBy", width: 22 },
|
||||
{ header: "Changed By Name", key: "changedByName", width: 18 },
|
||||
{ header: "IP Address", key: "ipAddress", width: 16 },
|
||||
{ header: "Created At", key: "createdAt", width: 22 },
|
||||
],
|
||||
rows: items.map((r) => ({
|
||||
tableName: r.tableName,
|
||||
recordId: r.recordId,
|
||||
action: r.action,
|
||||
oldValue: r.oldValue ?? "",
|
||||
newValue: r.newValue ?? "",
|
||||
changedBy: r.changedBy,
|
||||
changedByName: r.changedByName,
|
||||
ipAddress: r.ipAddress ?? "",
|
||||
createdAt: r.createdAt,
|
||||
})),
|
||||
filenamePrefix: "data_change_logs",
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { buffer, filename: `data_change_logs_${formatDateForFile()}.xlsx` },
|
||||
}
|
||||
return { success: true, data: { buffer, filename } }
|
||||
} 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" }
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateForFile(d = new Date()): string {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Download, Loader2 } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { downloadBlob } from "@/shared/lib/download"
|
||||
|
||||
interface AuditLogExportButtonProps {
|
||||
exportType: "audit" | "login" | "dataChange"
|
||||
@@ -52,14 +53,7 @@ export function AuditLogExportButton({
|
||||
const filename = filenameMatch?.[1] ?? `export_${Date.now()}.xlsx`
|
||||
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob(blob, filename)
|
||||
toast.success("Export ready")
|
||||
} catch {
|
||||
toast.error("Export failed")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -10,8 +9,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { FilterBar } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
interface AuditLogFiltersProps {
|
||||
moduleOptions: string[]
|
||||
@@ -23,17 +22,27 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
"startDate",
|
||||
parseAsString.withOptions({ shallow: false })
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
)
|
||||
const [endDate, setEndDate] = useQueryState(
|
||||
"endDate",
|
||||
parseAsString.withOptions({ shallow: false })
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
)
|
||||
|
||||
const hasFilters = Boolean(module || action || status || startDate || endDate)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
|
||||
<FilterBar
|
||||
layout="wrap"
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setModule(null)
|
||||
setAction(null)
|
||||
setStatus(null)
|
||||
setStartDate(null)
|
||||
setEndDate(null)
|
||||
}}
|
||||
>
|
||||
<Select value={module || "all"} onValueChange={(val) => setModule(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[160px] bg-background">
|
||||
<SelectValue placeholder="Module" />
|
||||
@@ -78,23 +87,6 @@ export function AuditLogFilters({ moduleOptions }: AuditLogFiltersProps) {
|
||||
value={endDate || ""}
|
||||
onChange={(e) => setEndDate(e.target.value || null)}
|
||||
/>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setModule(null)
|
||||
setAction(null)
|
||||
setStatus(null)
|
||||
setStartDate(null)
|
||||
setEndDate(null)
|
||||
}}
|
||||
className="h-10 px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -10,25 +9,34 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { FilterBar } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function LoginLogFilters() {
|
||||
const [action, setAction] = useQueryState("action", parseAsString.withOptions({ shallow: false }))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||
const [startDate, setStartDate] = useQueryState(
|
||||
"startDate",
|
||||
parseAsString.withOptions({ shallow: false })
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
)
|
||||
const [endDate, setEndDate] = useQueryState(
|
||||
"endDate",
|
||||
parseAsString.withOptions({ shallow: false })
|
||||
parseAsString.withOptions({ shallow: false }),
|
||||
)
|
||||
|
||||
const hasFilters = Boolean(action || status || startDate || endDate)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:flex-wrap">
|
||||
<FilterBar
|
||||
layout="wrap"
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setAction(null)
|
||||
setStatus(null)
|
||||
setStartDate(null)
|
||||
setEndDate(null)
|
||||
}}
|
||||
>
|
||||
<Select value={action || "all"} onValueChange={(val) => setAction(val === "all" ? null : val)}>
|
||||
<SelectTrigger className="w-[140px] bg-background">
|
||||
<SelectValue placeholder="Action" />
|
||||
@@ -64,22 +72,6 @@ export function LoginLogFilters() {
|
||||
value={endDate || ""}
|
||||
onChange={(e) => setEndDate(e.target.value || null)}
|
||||
/>
|
||||
|
||||
{hasFilters && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAction(null)
|
||||
setStatus(null)
|
||||
setStartDate(null)
|
||||
setEndDate(null)
|
||||
}}
|
||||
className="h-10 px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, asc, desc, eq, gte, lte, count, like } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, lte, count, like, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { auditLogs, loginLogs, dataChangeLogs } from "@/shared/db/schema"
|
||||
@@ -93,7 +93,7 @@ export async function getLoginLogs(
|
||||
const pageSize = clampPageSize(params?.pageSize)
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const conditions = []
|
||||
const conditions: SQL[] = []
|
||||
if (params?.userId) conditions.push(eq(loginLogs.userId, params.userId))
|
||||
if (params?.action) conditions.push(eq(loginLogs.action, params.action))
|
||||
if (params?.status) conditions.push(eq(loginLogs.status, params.status))
|
||||
|
||||
@@ -44,7 +44,17 @@ import {
|
||||
EnrollStudentByEmailSchema,
|
||||
} from "./schema"
|
||||
|
||||
const isClassSubject = (v: string): v is ClassSubject => DEFAULT_CLASS_SUBJECTS.includes(v as ClassSubject)
|
||||
const CLASS_SUBJECT_STRINGS: readonly string[] = DEFAULT_CLASS_SUBJECTS
|
||||
|
||||
const isClassSubject = (v: string): v is ClassSubject => CLASS_SUBJECT_STRINGS.includes(v)
|
||||
|
||||
const isWeekday = (n: number): n is 1 | 2 | 3 | 4 | 5 | 6 | 7 =>
|
||||
n >= 1 && n <= 7 && Number.isInteger(n)
|
||||
|
||||
const toWeekday = (n: number): 1 | 2 | 3 | 4 | 5 | 6 | 7 => {
|
||||
if (!isWeekday(n)) throw new Error("Invalid weekday")
|
||||
return n
|
||||
}
|
||||
|
||||
export async function createTeacherClassAction(
|
||||
prevState: ActionState<string> | null,
|
||||
@@ -517,8 +527,7 @@ export async function createClassScheduleItemAction(
|
||||
try {
|
||||
const id = await createClassScheduleItem({
|
||||
classId,
|
||||
// weekday 已被 Zod 校验为 1-7 的整数,断言为 Weekday 联合类型
|
||||
weekday: weekday as 1 | 2 | 3 | 4 | 5 | 6 | 7,
|
||||
weekday: toWeekday(weekday),
|
||||
startTime,
|
||||
endTime,
|
||||
course,
|
||||
@@ -561,8 +570,7 @@ export async function updateClassScheduleItemAction(
|
||||
try {
|
||||
await updateClassScheduleItem(validatedScheduleId, {
|
||||
classId: classId ?? undefined,
|
||||
// weekday 已被 Zod 校验为 1-7 的整数或 null/undefined,断言为 Weekday 联合类型
|
||||
weekday: (weekday ?? undefined) as 1 | 2 | 3 | 4 | 5 | 6 | 7 | undefined,
|
||||
weekday: typeof weekday === "number" ? toWeekday(weekday) : undefined,
|
||||
startTime: startTime ?? undefined,
|
||||
endTime: endTime ?? undefined,
|
||||
course: course ?? undefined,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
import { AlertCircle, BarChart3, CheckCircle2, PenTool } from "lucide-react"
|
||||
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
|
||||
interface ClassOverviewStatsProps {
|
||||
averageScore: number | null
|
||||
@@ -18,57 +18,30 @@ export function ClassOverviewStats({
|
||||
}: ClassOverviewStatsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
<StatCard
|
||||
title="Class Average"
|
||||
value={averageScore ? `${averageScore.toFixed(1)}%` : "-"}
|
||||
subValue="Overall performance"
|
||||
description="Overall performance"
|
||||
icon={BarChart3}
|
||||
/>
|
||||
<StatsCard
|
||||
<StatCard
|
||||
title="Submission Rate"
|
||||
value={`${submissionRate.toFixed(0)}%`}
|
||||
subValue="Average turn-in rate"
|
||||
description="Average turn-in rate"
|
||||
icon={CheckCircle2}
|
||||
/>
|
||||
<StatsCard
|
||||
<StatCard
|
||||
title="To Grade"
|
||||
value={papersToGrade.toString()}
|
||||
subValue="Pending reviews"
|
||||
description="Pending reviews"
|
||||
icon={PenTool}
|
||||
/>
|
||||
<StatsCard
|
||||
<StatCard
|
||||
title="Missed Deadlines"
|
||||
value={overdueCount.toString()}
|
||||
subValue="Active assignments past due"
|
||||
description="Active assignments past due"
|
||||
icon={AlertCircle}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsCard({
|
||||
title,
|
||||
value,
|
||||
subValue,
|
||||
icon: Icon,
|
||||
}: {
|
||||
title: string
|
||||
value: string
|
||||
subValue: string
|
||||
icon: React.ElementType
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between space-y-0 pb-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">{subValue}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,9 +140,9 @@ export function GradeClassesClient({
|
||||
const formatSubjectTeachers = (list: ClassSubjectTeacherAssignment[]) => {
|
||||
const pairs = list
|
||||
.filter((x) => x.teacher)
|
||||
.map((x) => `${x.subject}:${x.teacher?.name ?? ""}`)
|
||||
.map((x) => `${x.subject}: ${x.teacher?.name ?? ""}`)
|
||||
.filter((x) => x.length > 0)
|
||||
return pairs.length > 0 ? pairs.join(",") : "-"
|
||||
return pairs.length > 0 ? pairs.join(", ") : "-"
|
||||
}
|
||||
|
||||
const selectedCreateGrade = managedGrades.find(g => g.id === createGradeId)
|
||||
@@ -180,8 +180,8 @@ export function GradeClassesClient({
|
||||
<TableHead>Grade</TableHead>
|
||||
<TableHead>Homeroom</TableHead>
|
||||
<TableHead>Room</TableHead>
|
||||
<TableHead>班主任</TableHead>
|
||||
<TableHead>任课老师</TableHead>
|
||||
<TableHead>Homeroom Teacher</TableHead>
|
||||
<TableHead>Subject Teachers</TableHead>
|
||||
<TableHead className="text-right">Students</TableHead>
|
||||
<TableHead>Updated</TableHead>
|
||||
<TableHead className="w-[60px]" />
|
||||
@@ -280,7 +280,7 @@ export function GradeClassesClient({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">班主任</Label>
|
||||
<Label className="text-right">Homeroom Teacher</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={createTeacherId} onValueChange={setCreateTeacherId} disabled={teachers.length === 0}>
|
||||
<SelectTrigger>
|
||||
@@ -367,9 +367,9 @@ export function GradeClassesClient({
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label className="text-right">班主任</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
|
||||
<Label className="text-right">Homeroom Teacher</Label>
|
||||
<div className="col-span-3">
|
||||
<Select value={editTeacherId} onValueChange={setEditTeacherId} disabled={teachers.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={teachers.length === 0 ? "No teachers" : "Select a teacher"} />
|
||||
</SelectTrigger>
|
||||
@@ -386,7 +386,7 @@ export function GradeClassesClient({
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border p-4">
|
||||
<div className="text-sm font-medium">任课老师</div>
|
||||
<div className="text-sm font-medium">Subject Teachers</div>
|
||||
<div className="grid gap-3">
|
||||
{DEFAULT_CLASS_SUBJECTS.map((subject) => {
|
||||
const selected = editSubjectTeachers.find((x) => x.subject === subject)?.teacherId ?? null
|
||||
|
||||
@@ -8,7 +8,7 @@ import { toast } from "sonner"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/shared/components/ui/avatar"
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { cn, getInitials } from "@/shared/lib/utils"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -52,15 +52,6 @@ export function StudentsTable({ students }: { students: ClassStudent[] }) {
|
||||
}
|
||||
}
|
||||
|
||||
const getInitials = (name: string) => {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
|
||||
@@ -50,14 +50,14 @@ export const getSessionTeacherId = async (): Promise<string | null> => {
|
||||
|
||||
// Strict subjectId-based mapping: no aliasing
|
||||
|
||||
export const isDuplicateInvitationCodeError = (err: unknown) => {
|
||||
export const isDuplicateInvitationCodeError = (err: unknown): boolean => {
|
||||
if (!err) return false
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
const m = msg.toLowerCase()
|
||||
return m.includes("duplicate") && (m.includes("invitation") || m.includes("invitation_code"))
|
||||
}
|
||||
|
||||
const generateInvitationCode = () => {
|
||||
const generateInvitationCode = (): string => {
|
||||
const n = randomInt(0, 1_000_000)
|
||||
return String(n).padStart(6, "0")
|
||||
}
|
||||
@@ -90,14 +90,15 @@ export const getClassSubjects = async (): Promise<string[]> => {
|
||||
return Array.from(new Set(names))
|
||||
}
|
||||
|
||||
const normalizeSortText = (v: string | null | undefined) => (typeof v === "string" ? v.trim().toLowerCase() : "")
|
||||
const normalizeSortText = (v: string | null | undefined): string =>
|
||||
typeof v === "string" ? v.trim().toLowerCase() : ""
|
||||
|
||||
const parseFirstInt = (v: string) => {
|
||||
const parseFirstInt = (v: string): number | null => {
|
||||
const m = v.match(/\d+/)
|
||||
return m ? Number(m[0]) : null
|
||||
}
|
||||
|
||||
const compareGradeLabel = (a: string, b: string) => {
|
||||
const compareGradeLabel = (a: string, b: string): number => {
|
||||
const aNum = parseFirstInt(a)
|
||||
const bNum = parseFirstInt(b)
|
||||
if (typeof aNum === "number" && typeof bNum === "number" && aNum !== bNum) return aNum - bNum
|
||||
@@ -107,7 +108,7 @@ const compareGradeLabel = (a: string, b: string) => {
|
||||
export const compareClassLike = (
|
||||
a: { schoolName?: string | null; grade: string; name: string; homeroom?: string | null; room?: string | null },
|
||||
b: { schoolName?: string | null; grade: string; name: string; homeroom?: string | null; room?: string | null }
|
||||
) => {
|
||||
): number => {
|
||||
const schoolCmp = normalizeSortText(a.schoolName).localeCompare(normalizeSortText(b.schoolName))
|
||||
if (schoolCmp !== 0) return schoolCmp
|
||||
|
||||
@@ -228,6 +229,32 @@ export const getTeacherSubjectIdsByClass = async (classId: string, teacherId: st
|
||||
return getTeacherSubjectIdsForClass(teacherId, classId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取多个班级的所有教师 ID(班主任 + 任课教师)。
|
||||
* 供跨模块调用使用,避免直接查询 classes / classSubjectTeachers 表。
|
||||
*/
|
||||
export const getTeacherIdsByClassIds = async (classIds: string[]): Promise<string[]> => {
|
||||
if (classIds.length === 0) return []
|
||||
const [homeroomRows, subjectRows] = await Promise.all([
|
||||
db
|
||||
.select({ teacherId: classes.teacherId })
|
||||
.from(classes)
|
||||
.where(inArray(classes.id, classIds)),
|
||||
db
|
||||
.select({ teacherId: classSubjectTeachers.teacherId })
|
||||
.from(classSubjectTeachers)
|
||||
.where(inArray(classSubjectTeachers.classId, classIds)),
|
||||
])
|
||||
const set = new Set<string>()
|
||||
for (const r of homeroomRows) {
|
||||
if (r.teacherId) set.add(r.teacherId)
|
||||
}
|
||||
for (const r of subjectRows) {
|
||||
if (r.teacherId) set.add(r.teacherId)
|
||||
}
|
||||
return Array.from(set)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级的 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments 表。
|
||||
@@ -242,6 +269,23 @@ export const getStudentActiveClassId = async (studentId: string): Promise<string
|
||||
return row?.classId ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级的 ID 与名称(一次 JOIN 查询)。
|
||||
* 供跨模块调用使用,避免分别查询 classEnrollments 与 classes 表。
|
||||
*/
|
||||
export const getStudentActiveClass = async (
|
||||
studentId: string,
|
||||
): Promise<{ classId: string; className: string } | null> => {
|
||||
const [row] = await db
|
||||
.select({ classId: classes.id, className: classes.name })
|
||||
.from(classEnrollments)
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.where(and(eq(classEnrollments.studentId, studentId), eq(classEnrollments.status, "active")))
|
||||
.orderBy(asc(classEnrollments.createdAt))
|
||||
.limit(1)
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学生当前活跃班级对应的年级 ID。
|
||||
* 供跨模块调用使用,避免直接查询 classEnrollments/classes 表。
|
||||
@@ -368,7 +412,8 @@ export const getTeacherClasses = cache(async (params?: { teacherId?: string }):
|
||||
.where(inArray(classes.id, allIds))
|
||||
.groupBy(classes.id, classes.schoolName, classes.name, classes.grade, classes.homeroom, classes.room, classes.invitationCode)
|
||||
.orderBy(asc(classes.schoolName), asc(classes.grade), asc(classes.name), asc(classes.homeroom), asc(classes.room))
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.error("getTeacherClasses query failed:", error)
|
||||
return []
|
||||
}
|
||||
})()
|
||||
@@ -672,7 +717,9 @@ export async function enrollTeacherByInvitationCode(
|
||||
|
||||
const preferred = DEFAULT_CLASS_SUBJECTS.find((s) => subjectRows.some((r) => r.name === s))
|
||||
if (!preferred) throw new Error("Class already has assigned teachers")
|
||||
const sid = subjectRows.find((r) => r.name === preferred)!.id
|
||||
const subjectRow = subjectRows.find((r) => r.name === preferred)
|
||||
if (!subjectRow) throw new Error("Subject not found")
|
||||
const sid = subjectRow.id
|
||||
|
||||
await db
|
||||
.update(classSubjectTeachers)
|
||||
|
||||
@@ -227,6 +227,7 @@ export async function updateCoursePlanItemAction(
|
||||
}
|
||||
|
||||
await updateCoursePlanItem(id, parsed.data)
|
||||
revalidatePlanPaths()
|
||||
return { success: true, message: "Week plan updated", data: id }
|
||||
} catch (e) {
|
||||
return handleError(e)
|
||||
|
||||
@@ -50,13 +50,13 @@ export function CoursePlanList({
|
||||
plans,
|
||||
canManage,
|
||||
createHref,
|
||||
detailHrefBuilder,
|
||||
detailBaseHref,
|
||||
initialStatus,
|
||||
}: {
|
||||
plans: CoursePlanListItem[]
|
||||
canManage?: boolean
|
||||
createHref?: string
|
||||
detailHrefBuilder?: (id: string) => string
|
||||
detailBaseHref?: string
|
||||
initialStatus?: Filter
|
||||
}) {
|
||||
const router = useRouter()
|
||||
@@ -116,7 +116,7 @@ export function CoursePlanList({
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((plan) => {
|
||||
const href = detailHrefBuilder ? detailHrefBuilder(plan.id) : undefined
|
||||
const href = detailBaseHref ? `${detailBaseHref}/${plan.id}` : undefined
|
||||
const card = (
|
||||
<Card className="h-full transition-colors hover:bg-accent/50">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0">
|
||||
|
||||
@@ -299,12 +299,14 @@ export async function reorderCoursePlanItems(
|
||||
|
||||
if (!existing) return
|
||||
|
||||
for (const item of items) {
|
||||
await db
|
||||
.update(coursePlanItems)
|
||||
.set({ week: item.week })
|
||||
.where(eq(coursePlanItems.id, item.id))
|
||||
}
|
||||
await Promise.all(
|
||||
items.map((item) =>
|
||||
db
|
||||
.update(coursePlanItems)
|
||||
.set({ week: item.week })
|
||||
.where(eq(coursePlanItems.id, item.id))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export type { CoursePlan, CoursePlanItem, CoursePlanWithItems }
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Users, LayoutDashboard, BookOpen, FileText, ClipboardList, Library, Act
|
||||
|
||||
import type { AdminDashboardData } from "@/modules/dashboard/types"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import { PageHeader } from "@/shared/components/ui/page-header"
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
@@ -11,28 +13,28 @@ import { formatDate } from "@/shared/lib/utils"
|
||||
export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-8 p-8">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<div className="text-sm text-muted-foreground">System overview across users, learning content, and activity.</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
{data.activeSessionsCount} active sessions
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
{data.userCount} users
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="System overview across users, learning content, and activity."
|
||||
actions={
|
||||
<>
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
{data.activeSessionsCount} active sessions
|
||||
</Badge>
|
||||
<Badge variant="outline" className="gap-2">
|
||||
<Users className="h-4 w-4" />
|
||||
{data.userCount} users
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<KpiCard title="Users" value={data.userCount} icon={<Users className="h-4 w-4" />} />
|
||||
<KpiCard title="Classes" value={data.classCount} icon={<LayoutDashboard className="h-4 w-4" />} />
|
||||
<KpiCard title="Homework (published)" value={data.homeworkAssignmentPublishedCount} icon={<ClipboardList className="h-4 w-4" />} />
|
||||
<KpiCard title="To grade" value={data.homeworkSubmissionToGradeCount} icon={<FileText className="h-4 w-4" />} />
|
||||
<StatCard title="Users" value={data.userCount} icon={Users} valueClassName="tabular-nums" />
|
||||
<StatCard title="Classes" value={data.classCount} icon={LayoutDashboard} valueClassName="tabular-nums" />
|
||||
<StatCard title="Homework (published)" value={data.homeworkAssignmentPublishedCount} icon={ClipboardList} valueClassName="tabular-nums" />
|
||||
<StatCard title="To grade" value={data.homeworkSubmissionToGradeCount} icon={FileText} valueClassName="tabular-nums" />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
@@ -115,28 +117,6 @@ export function AdminDashboardView({ data }: { data: AdminDashboardData }) {
|
||||
)
|
||||
}
|
||||
|
||||
function KpiCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
}: {
|
||||
title: string
|
||||
value: number
|
||||
icon: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<div className="text-muted-foreground">{icon}</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold tabular-nums">{value}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function ContentRow({
|
||||
label,
|
||||
value,
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
|
||||
import Link from "next/link"
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/shared/components/ui/table"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { StudentDashboardGradeProps } from "@/modules/homework/types"
|
||||
|
||||
@@ -24,140 +22,84 @@ export function StudentGradesCard({ grades }: { grades: StudentDashboardGradePro
|
||||
maxScore: item.maxScore,
|
||||
}))
|
||||
|
||||
const chartConfig = {
|
||||
score: {
|
||||
label: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
}
|
||||
|
||||
const latestGrade = grades.trend[grades.trend.length - 1]
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4 text-muted-foreground" />
|
||||
Recent Grades
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasGradeTrend ? (
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No graded work yet"
|
||||
description="Finish and submit assignments to see your score trend."
|
||||
className="border-none h-72"
|
||||
<ChartCardShell
|
||||
title="Recent Grades"
|
||||
icon={BarChart3}
|
||||
iconClassName="text-muted-foreground"
|
||||
isEmpty={!hasGradeTrend}
|
||||
emptyTitle="No graded work yet"
|
||||
emptyDescription="Finish and submit assignments to see your score trend."
|
||||
emptyClassName="h-72"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "score",
|
||||
name: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[200px]"
|
||||
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
|
||||
yWidth={30}
|
||||
tooltipClassName="w-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border bg-card p-4">
|
||||
<ChartContainer config={chartConfig} className="h-[200px] w-full">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<XAxis
|
||||
dataKey="title"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
width={30}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="line"
|
||||
labelKey="fullTitle"
|
||||
className="w-[200px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="score"
|
||||
type="monotone"
|
||||
stroke="var(--color-score)"
|
||||
strokeWidth={2}
|
||||
dot={{
|
||||
fill: "var(--color-score)",
|
||||
r: 4,
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
strokeWidth: 0,
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
|
||||
{latestGrade && (
|
||||
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div>
|
||||
Latest:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{Math.round(latestGrade.percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
Points:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{latestGrade.score}/{latestGrade.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasRecentGrades ? null : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grades.recent.map((r) => (
|
||||
<TableRow key={r.assignmentId} className="h-12">
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/student/learning/assignments/${r.assignmentId}`} className="hover:underline">
|
||||
{r.assignmentTitle}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
{latestGrade ? (
|
||||
<div className="mt-3 flex items-center justify-between text-sm text-muted-foreground">
|
||||
<div>
|
||||
Latest:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{Math.round(latestGrade.percentage)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
Points:{" "}
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{latestGrade.score}/{latestGrade.maxScore}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!hasRecentGrades ? null : (
|
||||
<div className="rounded-md border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50">
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Assignment</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">Score</TableHead>
|
||||
<TableHead className="text-xs font-medium uppercase text-muted-foreground">When</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grades.recent.map((r) => (
|
||||
<TableRow key={r.assignmentId} className="h-12">
|
||||
<TableCell className="font-medium">
|
||||
<Link href={`/student/learning/assignments/${r.assignmentId}`} className="hover:underline">
|
||||
{r.assignmentTitle}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="tabular-nums">
|
||||
{r.score}/{r.maxScore} <span className="text-muted-foreground">({Math.round(r.percentage)}%)</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{formatDate(r.submittedAt)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
import Link from "next/link"
|
||||
import { BookOpen, PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
||||
import { PenTool, TriangleAlert, Trophy, TrendingUp } from "lucide-react"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { StatCard } from "@/shared/components/ui/stat-card"
|
||||
import type { StudentRanking } from "@/modules/homework/types"
|
||||
|
||||
type Stat = {
|
||||
title: string
|
||||
value: string
|
||||
description: string
|
||||
icon: typeof BookOpen
|
||||
href: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export function StudentStatsGrid({
|
||||
dueSoonCount,
|
||||
overdueCount,
|
||||
@@ -25,57 +14,44 @@ export function StudentStatsGrid({
|
||||
gradedCount: number
|
||||
ranking: StudentRanking | null
|
||||
}) {
|
||||
const stats: Stat[] = [
|
||||
{
|
||||
title: "Average Score",
|
||||
value: ranking ? `${Math.round(ranking.percentage)}%` : "-",
|
||||
description: ranking ? "Overall performance" : "No grades yet",
|
||||
icon: TrendingUp,
|
||||
href: "/student/learning/assignments",
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
title: "Class Rank",
|
||||
value: ranking ? `${ranking.rank}/${ranking.classSize}` : "-",
|
||||
description: ranking ? "Current position" : "No ranking yet",
|
||||
icon: Trophy,
|
||||
href: "/student/learning/assignments",
|
||||
color: "text-purple-500",
|
||||
},
|
||||
{
|
||||
title: "Due Soon",
|
||||
value: String(dueSoonCount),
|
||||
description: "Next 7 days",
|
||||
icon: PenTool,
|
||||
href: "/student/learning/assignments",
|
||||
color: dueSoonCount > 0 ? "text-orange-500" : undefined,
|
||||
},
|
||||
{
|
||||
title: "Overdue",
|
||||
value: String(overdueCount),
|
||||
description: "Needs attention",
|
||||
icon: TriangleAlert,
|
||||
href: "/student/learning/assignments",
|
||||
color: overdueCount > 0 ? "text-red-500" : undefined,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<Link key={stat.title} href={stat.href}>
|
||||
<Card className="hover:bg-muted/50 transition-colors cursor-pointer h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
|
||||
<stat.icon className={cn("h-4 w-4 text-muted-foreground", stat.color)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={cn("text-2xl font-bold tabular-nums", stat.color)}>{stat.value}</div>
|
||||
<div className="text-xs text-muted-foreground">{stat.description}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<StatCard
|
||||
title="Average Score"
|
||||
value={ranking ? `${Math.round(ranking.percentage)}%` : "-"}
|
||||
description={ranking ? "Overall performance" : "No grades yet"}
|
||||
icon={TrendingUp}
|
||||
href="/student/learning/assignments"
|
||||
color="text-blue-500"
|
||||
valueClassName={ranking ? "text-blue-500 tabular-nums" : "tabular-nums"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Class Rank"
|
||||
value={ranking ? `${ranking.rank}/${ranking.classSize}` : "-"}
|
||||
description={ranking ? "Current position" : "No ranking yet"}
|
||||
icon={Trophy}
|
||||
href="/student/learning/assignments"
|
||||
color="text-purple-500"
|
||||
valueClassName={ranking ? "text-purple-500 tabular-nums" : "tabular-nums"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Due Soon"
|
||||
value={String(dueSoonCount)}
|
||||
description="Next 7 days"
|
||||
icon={PenTool}
|
||||
href="/student/learning/assignments"
|
||||
color={dueSoonCount > 0 ? "text-orange-500" : undefined}
|
||||
valueClassName={dueSoonCount > 0 ? "text-orange-500 tabular-nums" : "tabular-nums"}
|
||||
/>
|
||||
<StatCard
|
||||
title="Overdue"
|
||||
value={String(overdueCount)}
|
||||
description="Needs attention"
|
||||
icon={TriangleAlert}
|
||||
href="/student/learning/assignments"
|
||||
color={overdueCount > 0 ? "text-red-500" : undefined}
|
||||
valueClassName={overdueCount > 0 ? "text-red-500 tabular-nums" : "tabular-nums"}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { CalendarDays, CalendarX, Clock, MapPin } from "lucide-react"
|
||||
import { CalendarDays, CalendarX } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/shared/components/ui/badge"
|
||||
import { ScheduleList } from "@/shared/components/schedule/schedule-list"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import type { StudentTodayScheduleItem } from "@/modules/dashboard/types"
|
||||
@@ -25,32 +25,11 @@ export function StudentTodayScheduleCard({ items }: { items: StudentTodaySchedul
|
||||
className="border-none h-72"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between border-b pb-4 last:border-0 last:pb-0">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<div className="font-medium leading-none truncate">{item.course}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-muted-foreground">
|
||||
<div className="flex items-center">
|
||||
<Clock className="mr-1 h-3 w-3" />
|
||||
<span>
|
||||
{item.startTime}–{item.endTime}
|
||||
</span>
|
||||
</div>
|
||||
{item.location ? (
|
||||
<div className="flex items-center">
|
||||
<MapPin className="mr-1 h-3 w-3" />
|
||||
<span className="truncate">{item.location}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{item.className}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<ScheduleList
|
||||
items={items}
|
||||
variant="separator"
|
||||
spacingClassName="space-y-4"
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,135 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/shared/components/ui/card"
|
||||
import { TrendingUp } from "lucide-react"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import type { TeacherGradeTrendItem } from "@/modules/homework/types"
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
import { ChartContainer, ChartTooltip, ChartTooltipContent } from "@/shared/components/ui/chart"
|
||||
|
||||
export function TeacherGradeTrends({ trends }: { trends: TeacherGradeTrendItem[] }) {
|
||||
const hasTrends = trends.length > 0
|
||||
|
||||
// Calculate percentages for the chart
|
||||
const chartData = trends.map((item) => {
|
||||
const percentage = item.maxScore > 0 ? (item.averageScore / item.maxScore) * 100 : 0
|
||||
return {
|
||||
title: item.title,
|
||||
score: Math.round(percentage),
|
||||
fullTitle: item.title, // For tooltip
|
||||
fullTitle: item.title,
|
||||
submissionCount: item.submissionCount,
|
||||
totalStudents: item.totalStudents,
|
||||
}
|
||||
})
|
||||
|
||||
const chartConfig = {
|
||||
score: {
|
||||
label: "Average Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base font-medium">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
Class Performance
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Average scores for the last {trends.length} assignments
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasTrends ? (
|
||||
<EmptyState
|
||||
icon={TrendingUp}
|
||||
title="No data available"
|
||||
description="Publish assignments to see class performance trends."
|
||||
className="border-none h-[200px] p-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ChartContainer config={chartConfig} className="h-[200px] w-full">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 12,
|
||||
bottom: 12,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid vertical={false} strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<XAxis
|
||||
dataKey="title"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value) => value.slice(0, 10) + (value.length > 10 ? "..." : "")}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}%`}
|
||||
width={30}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="line"
|
||||
labelKey="fullTitle"
|
||||
className="w-[200px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="score"
|
||||
type="monotone"
|
||||
stroke="var(--color-score)"
|
||||
strokeWidth={2}
|
||||
dot={{
|
||||
fill: "var(--color-score)",
|
||||
r: 4,
|
||||
strokeWidth: 2,
|
||||
stroke: "hsl(var(--background))"
|
||||
}}
|
||||
activeDot={{
|
||||
r: 6,
|
||||
strokeWidth: 2,
|
||||
stroke: "hsl(var(--background))"
|
||||
}}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
<ChartCardShell
|
||||
title="Class Performance"
|
||||
description={`Average scores for the last ${trends.length} assignments`}
|
||||
icon={TrendingUp}
|
||||
iconClassName="text-primary"
|
||||
titleClassName="text-base font-medium"
|
||||
isEmpty={!hasTrends}
|
||||
emptyTitle="No data available"
|
||||
emptyDescription="Publish assignments to see class performance trends."
|
||||
emptyClassName="h-[200px] p-0"
|
||||
className="col-span-1"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "score",
|
||||
name: "Average Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 4,
|
||||
activeDotRadius: 6,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[200px]"
|
||||
margin={{ left: 12, right: 12, top: 12, bottom: 12 }}
|
||||
yWidth={30}
|
||||
tooltipClassName="w-[200px]"
|
||||
/>
|
||||
|
||||
{/* Metric Summary */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{chartData.slice().reverse().slice(0, 3).map((item, i) => (
|
||||
<div key={i} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
|
||||
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
|
||||
{item.fullTitle}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold tabular-nums">
|
||||
{item.score}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.submissionCount}/{item.totalStudents} submitted
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{chartData
|
||||
.slice()
|
||||
.reverse()
|
||||
.slice(0, 3)
|
||||
.map((item, i) => (
|
||||
<div key={i} className="flex flex-col gap-1 rounded-lg border p-3 bg-card/50">
|
||||
<div className="text-xs text-muted-foreground truncate" title={item.fullTitle}>
|
||||
{item.fullTitle}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-bold tabular-nums">{item.score}%</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{item.submissionCount}/{item.totalStudents} submitted
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card";
|
||||
import { FileCheck, PenTool, TrendingUp, BarChart } from "lucide-react";
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { StatCard } from "@/shared/components/ui/stat-card";
|
||||
|
||||
interface TeacherStatsProps {
|
||||
toGradeCount: number;
|
||||
@@ -19,84 +16,45 @@ export function TeacherStats({
|
||||
submissionRate,
|
||||
isLoading = false,
|
||||
}: TeacherStatsProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
<Skeleton className="h-4 w-4 rounded-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-8 w-[60px] mb-2" />
|
||||
<Skeleton className="h-3 w-[140px]" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Needs Grading",
|
||||
value: String(toGradeCount),
|
||||
description: "Submissions pending review",
|
||||
icon: FileCheck,
|
||||
href: "/teacher/homework/submissions?status=submitted",
|
||||
highlight: toGradeCount > 0,
|
||||
color: "text-amber-500",
|
||||
},
|
||||
{
|
||||
title: "Active Assignments",
|
||||
value: String(activeAssignmentsCount),
|
||||
description: "Published and ongoing",
|
||||
icon: PenTool,
|
||||
href: "/teacher/homework/assignments?status=published",
|
||||
highlight: false,
|
||||
color: "text-blue-500",
|
||||
},
|
||||
{
|
||||
title: "Average Score",
|
||||
value: `${Math.round(averageScore)}%`,
|
||||
description: "Across recent assignments",
|
||||
icon: TrendingUp,
|
||||
href: "#grade-trends",
|
||||
highlight: false,
|
||||
color: "text-emerald-500",
|
||||
},
|
||||
{
|
||||
title: "Submission Rate",
|
||||
value: `${Math.round(submissionRate)}%`,
|
||||
description: "Overall completion rate",
|
||||
icon: BarChart,
|
||||
href: "#grade-trends",
|
||||
highlight: false,
|
||||
color: "text-purple-500",
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((stat, i) => (
|
||||
<Link key={i} href={stat.href} className="block transition-transform hover:-translate-y-1">
|
||||
<Card className={cn(stat.highlight && "border-amber-200 bg-amber-50/50 dark:border-amber-900 dark:bg-amber-950/20")}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{stat.title}
|
||||
</CardTitle>
|
||||
<stat.icon className={cn("h-4 w-4", stat.color)} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{stat.value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{stat.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
<StatCard
|
||||
title="Needs Grading"
|
||||
value={String(toGradeCount)}
|
||||
description="Submissions pending review"
|
||||
icon={FileCheck}
|
||||
href="/teacher/homework/submissions?status=submitted"
|
||||
highlight={toGradeCount > 0}
|
||||
color="text-amber-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Assignments"
|
||||
value={String(activeAssignmentsCount)}
|
||||
description="Published and ongoing"
|
||||
icon={PenTool}
|
||||
href="/teacher/homework/assignments?status=published"
|
||||
color="text-blue-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Average Score"
|
||||
value={`${Math.round(averageScore)}%`}
|
||||
description="Across recent assignments"
|
||||
icon={TrendingUp}
|
||||
href="#grade-trends"
|
||||
color="text-emerald-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Submission Rate"
|
||||
value={`${Math.round(submissionRate)}%`}
|
||||
description="Overall completion rate"
|
||||
icon={BarChart}
|
||||
href="#grade-trends"
|
||||
color="text-purple-500"
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,114 +1,69 @@
|
||||
"use client"
|
||||
|
||||
import { RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Legend } from "recharts"
|
||||
import { Target } from "lucide-react"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { ComparisonRadarChart } from "@/shared/components/charts/comparison-radar-chart"
|
||||
import type { MasteryRadarPoint } from "@/modules/diagnostic/types"
|
||||
|
||||
const chartConfig = {
|
||||
student: { label: "Student", color: "hsl(var(--primary))" },
|
||||
classAverage: { label: "Class Avg", color: "hsl(var(--chart-2))" },
|
||||
}
|
||||
|
||||
interface MasteryRadarChartProps {
|
||||
data: MasteryRadarPoint[]
|
||||
}
|
||||
|
||||
export function MasteryRadarChart({ data }: MasteryRadarChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Knowledge Point Mastery
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Radar chart of mastery level (0-100) across knowledge points.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={Target}
|
||||
title="No mastery data"
|
||||
description="No knowledge point mastery records found for this student."
|
||||
className="border-none h-60"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
// 知识点名称过长时截断显示
|
||||
const chartData = data.map((d) => ({
|
||||
...d,
|
||||
shortName: d.knowledgePoint.length > 8 ? `${d.knowledgePoint.slice(0, 8)}...` : d.knowledgePoint,
|
||||
}))
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.map((d) => ({
|
||||
...d,
|
||||
shortName:
|
||||
d.knowledgePoint.length > 8
|
||||
? `${d.knowledgePoint.slice(0, 8)}...`
|
||||
: d.knowledgePoint,
|
||||
}))
|
||||
|
||||
const hasClassAverage = data.some((d) => d.classAverage !== undefined)
|
||||
const hasClassAverage = !isEmpty && data.some((d) => d.classAverage !== undefined)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-4 w-4" />
|
||||
Knowledge Point Mastery
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Radar chart of mastery level (0-100) across knowledge points.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="mx-auto h-[360px] w-full max-w-[520px]">
|
||||
<RadarChart data={chartData} outerRadius="75%">
|
||||
<PolarGrid strokeDasharray="4 4" strokeOpacity={0.4} />
|
||||
<PolarAngleAxis
|
||||
dataKey="shortName"
|
||||
tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
domain={[0, 100]}
|
||||
tickCount={5}
|
||||
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
|
||||
axisLine={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
|
||||
{hasClassAverage ? <Legend /> : null}
|
||||
<Radar
|
||||
name="Student"
|
||||
dataKey="student"
|
||||
stroke="var(--color-student)"
|
||||
fill="var(--color-student)"
|
||||
fillOpacity={0.35}
|
||||
strokeWidth={2}
|
||||
/>
|
||||
{hasClassAverage ? (
|
||||
<Radar
|
||||
name="Class Avg"
|
||||
dataKey="classAverage"
|
||||
stroke="var(--color-classAverage)"
|
||||
fill="var(--color-classAverage)"
|
||||
fillOpacity={0.15}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
) : null}
|
||||
</RadarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChartCardShell
|
||||
title="Knowledge Point Mastery"
|
||||
description="Radar chart of mastery level (0-100) across knowledge points."
|
||||
icon={Target}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No mastery data"
|
||||
emptyDescription="No knowledge point mastery records found for this student."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="shortName"
|
||||
angleTickFontSize={11}
|
||||
domain={[0, 100]}
|
||||
tickCount={5}
|
||||
showLegend={hasClassAverage}
|
||||
heightClassName="mx-auto h-[360px] w-full max-w-[520px]"
|
||||
gridStrokeDasharray="4 4"
|
||||
series={[
|
||||
{
|
||||
dataKey: "student",
|
||||
name: "Student",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.35,
|
||||
strokeWidth: 2,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
dataKey: "classAverage",
|
||||
name: "Class Avg",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.15,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "4 4",
|
||||
show: hasClassAverage,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, desc, eq, inArray } from "drizzle-orm"
|
||||
import { and, desc, eq, inArray, type SQL } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
@@ -19,8 +19,6 @@ const toNumber = (v: unknown): number => {
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
const round2 = (n: number): number => Math.round(n * 100) / 100
|
||||
|
||||
const isStringArray = (v: unknown): v is string[] =>
|
||||
Array.isArray(v) && v.every((item) => typeof item === "string")
|
||||
|
||||
@@ -127,7 +125,7 @@ export async function generateClassDiagnosticReport(
|
||||
/** 查询诊断报告列表 */
|
||||
export const getDiagnosticReports = cache(
|
||||
async (filters: DiagnosticReportQueryParams): Promise<DiagnosticReportWithDetails[]> => {
|
||||
const conditions = []
|
||||
const conditions: SQL[] = []
|
||||
if (filters.studentId) conditions.push(eq(learningDiagnosticReports.studentId, filters.studentId))
|
||||
if (filters.reportType) conditions.push(eq(learningDiagnosticReports.reportType, filters.reportType))
|
||||
if (filters.status) conditions.push(eq(learningDiagnosticReports.status, filters.status))
|
||||
@@ -203,6 +201,3 @@ export async function publishDiagnosticReport(id: string): Promise<void> {
|
||||
export async function deleteDiagnosticReport(id: string): Promise<void> {
|
||||
await db.delete(learningDiagnosticReports).where(eq(learningDiagnosticReports.id, id))
|
||||
}
|
||||
|
||||
// 防止 round2 未使用警告(保留以备扩展)
|
||||
void round2
|
||||
|
||||
@@ -116,52 +116,62 @@ export async function updateMasteryFromSubmission(submissionId: string): Promise
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
for (const [kpId, stat] of kpStats.entries()) {
|
||||
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
|
||||
await db
|
||||
.insert(knowledgePointMastery)
|
||||
.values({
|
||||
studentId: submission.studentId,
|
||||
knowledgePointId: kpId,
|
||||
masteryLevel: String(masteryLevel),
|
||||
totalQuestions: stat.total,
|
||||
correctQuestions: stat.correct,
|
||||
lastAssessedAt: now,
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
await Promise.all(
|
||||
Array.from(kpStats.entries()).map(async ([kpId, stat]) => {
|
||||
const masteryLevel = stat.total > 0 ? round2((stat.correct / stat.total) * 100) : 0
|
||||
await db
|
||||
.insert(knowledgePointMastery)
|
||||
.values({
|
||||
studentId: submission.studentId,
|
||||
knowledgePointId: kpId,
|
||||
masteryLevel: String(masteryLevel),
|
||||
totalQuestions: stat.total,
|
||||
correctQuestions: stat.correct,
|
||||
lastAssessedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
.onDuplicateKeyUpdate({
|
||||
set: {
|
||||
masteryLevel: String(masteryLevel),
|
||||
totalQuestions: stat.total,
|
||||
correctQuestions: stat.correct,
|
||||
lastAssessedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** 获取班级掌握度摘要 */
|
||||
export const getClassMasterySummary = cache(async (classId: string): Promise<ClassMasterySummary | null> => {
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) return null
|
||||
const className = (await getClassNameById(classId)) ?? "Unknown"
|
||||
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
// 班级名称 与 学生列表 相互独立,并行拉取
|
||||
const [classNameResult, studentIds] = await Promise.all([
|
||||
getClassNameById(classId),
|
||||
getActiveStudentIdsByClassId(classId),
|
||||
])
|
||||
const className = classNameResult ?? "Unknown"
|
||||
|
||||
if (studentIds.length === 0) {
|
||||
return { classId, className, studentCount: 0, averageMastery: 0, knowledgePointStats: [], studentsNeedingAttention: [] }
|
||||
}
|
||||
|
||||
const userMap = await getUserNamesByIds(studentIds)
|
||||
// 学生姓名 与 掌握度记录 相互独立,并行拉取
|
||||
const [userMap, masteryRows] = await Promise.all([
|
||||
getUserNamesByIds(studentIds),
|
||||
db
|
||||
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
|
||||
.from(knowledgePointMastery)
|
||||
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||
.where(inArray(knowledgePointMastery.studentId, studentIds)),
|
||||
])
|
||||
|
||||
const students = studentIds
|
||||
.map((id) => ({ id, name: userMap.get(id)?.name ?? null }))
|
||||
.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""))
|
||||
|
||||
const masteryRows = await db
|
||||
.select({ mastery: knowledgePointMastery, kpName: knowledgePoints.name })
|
||||
.from(knowledgePointMastery)
|
||||
.leftJoin(knowledgePoints, eq(knowledgePoints.id, knowledgePointMastery.knowledgePointId))
|
||||
.where(inArray(knowledgePointMastery.studentId, studentIds))
|
||||
|
||||
const byKp = new Map<string, { name: string; levels: number[]; mastered: number; notMastered: number }>()
|
||||
const byStudent = new Map<string, { levels: number[]; weakCount: number }>()
|
||||
for (const s of students) byStudent.set(s.id, { levels: [], weakCount: 0 })
|
||||
|
||||
@@ -28,12 +28,12 @@ import {
|
||||
export function ElectiveCourseList({
|
||||
courses,
|
||||
createHref,
|
||||
editHrefBuilder,
|
||||
editBaseHref,
|
||||
canManage,
|
||||
}: {
|
||||
courses: ElectiveCourseWithDetails[]
|
||||
createHref?: string
|
||||
editHrefBuilder?: (id: string) => string
|
||||
editBaseHref?: string
|
||||
canManage?: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
@@ -165,13 +165,13 @@ export function ElectiveCourseList({
|
||||
|
||||
{manageResolved ? (
|
||||
<div className="mt-auto flex flex-wrap gap-2 pt-2">
|
||||
{editHrefBuilder ? (
|
||||
{editBaseHref ? (
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<a href={editHrefBuilder(course.id)}>
|
||||
<a href={`${editBaseHref}/${course.id}/edit`}>
|
||||
<Pencil className="mr-1 h-3 w-3" />
|
||||
Edit
|
||||
</a>
|
||||
|
||||
@@ -46,7 +46,12 @@ export async function runLottery(courseId: string): Promise<{
|
||||
return { enrolled: 0, waitlist: 0 }
|
||||
}
|
||||
|
||||
const shuffled = [...selections].sort(() => Math.random() - 0.5)
|
||||
// Fisher-Yates shuffle: 无偏均匀随机排列,避免 sort(() => Math.random() - 0.5) 的分布偏差
|
||||
const shuffled = [...selections]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
|
||||
}
|
||||
const capacity = course.capacity
|
||||
const now = new Date()
|
||||
|
||||
@@ -99,13 +104,26 @@ export async function selectCourse(
|
||||
studentId: string,
|
||||
priority?: number
|
||||
): Promise<{ status: CourseSelectionStatus; message: string }> {
|
||||
const [courseRows, existingRows] = await Promise.all([
|
||||
db
|
||||
return db.transaction(async (tx) => {
|
||||
// 锁定课程行,防止 FCFS 模式下并发超卖
|
||||
const [course] = await tx
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1),
|
||||
db
|
||||
.for("update")
|
||||
.limit(1)
|
||||
if (!course) throw new Error("Course not found")
|
||||
if (course.status !== "open") throw new Error("Course selection is not open")
|
||||
|
||||
const now = new Date()
|
||||
if (course.selectionStartAt && now < course.selectionStartAt) {
|
||||
throw new Error("Selection has not started yet")
|
||||
}
|
||||
if (course.selectionEndAt && now > course.selectionEndAt) {
|
||||
throw new Error("Selection has ended")
|
||||
}
|
||||
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
@@ -115,68 +133,55 @@ export async function selectCourse(
|
||||
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
])
|
||||
const course = courseRows[0]
|
||||
if (!course) throw new Error("Course not found")
|
||||
if (course.status !== "open") throw new Error("Course selection is not open")
|
||||
.limit(1)
|
||||
if (existing) throw new Error("Already selected this course")
|
||||
|
||||
const now = new Date()
|
||||
if (course.selectionStartAt && now < course.selectionStartAt) {
|
||||
throw new Error("Selection has not started yet")
|
||||
}
|
||||
if (course.selectionEndAt && now > course.selectionEndAt) {
|
||||
throw new Error("Selection has ended")
|
||||
}
|
||||
const id = createId()
|
||||
let status: CourseSelectionStatus = "selected"
|
||||
let enrolledAt: Date | null = null
|
||||
|
||||
const existing = existingRows[0]
|
||||
if (existing) throw new Error("Already selected this course")
|
||||
if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) {
|
||||
status = "enrolled"
|
||||
enrolledAt = now
|
||||
await tx
|
||||
.update(electiveCourses)
|
||||
.set({
|
||||
enrolledCount: course.enrolledCount + 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
} else if (course.selectionMode === "fcfs") {
|
||||
status = "waitlist"
|
||||
}
|
||||
|
||||
const id = createId()
|
||||
let status: CourseSelectionStatus = "selected"
|
||||
let enrolledAt: Date | null = null
|
||||
await tx.insert(courseSelections).values({
|
||||
id,
|
||||
courseId,
|
||||
studentId,
|
||||
status,
|
||||
priority: priority ?? 1,
|
||||
selectedAt: now,
|
||||
enrolledAt,
|
||||
})
|
||||
|
||||
if (course.selectionMode === "fcfs" && course.enrolledCount < course.capacity) {
|
||||
status = "enrolled"
|
||||
enrolledAt = now
|
||||
await db
|
||||
.update(electiveCourses)
|
||||
.set({
|
||||
enrolledCount: course.enrolledCount + 1,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
} else if (course.selectionMode === "fcfs") {
|
||||
status = "waitlist"
|
||||
}
|
||||
|
||||
await db.insert(courseSelections).values({
|
||||
id,
|
||||
courseId,
|
||||
studentId,
|
||||
status,
|
||||
priority: priority ?? 1,
|
||||
selectedAt: now,
|
||||
enrolledAt,
|
||||
return {
|
||||
status,
|
||||
message:
|
||||
status === "enrolled"
|
||||
? "Enrolled successfully"
|
||||
: status === "waitlist"
|
||||
? "Added to waitlist"
|
||||
: "Selection submitted",
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
status,
|
||||
message:
|
||||
status === "enrolled"
|
||||
? "Enrolled successfully"
|
||||
: status === "waitlist"
|
||||
? "Added to waitlist"
|
||||
: "Selection submitted",
|
||||
}
|
||||
}
|
||||
|
||||
export async function dropCourse(
|
||||
courseId: string,
|
||||
studentId: string
|
||||
): Promise<void> {
|
||||
const [existingRows, courseRows] = await Promise.all([
|
||||
db
|
||||
await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
@@ -186,32 +191,31 @@ export async function dropCourse(
|
||||
inArray(courseSelections.status, ["selected", "enrolled", "waitlist"])
|
||||
)
|
||||
)
|
||||
.limit(1),
|
||||
db
|
||||
.limit(1)
|
||||
if (!existing) throw new Error("No active selection found")
|
||||
|
||||
// 锁定课程行,确保 enrolledCount 更新与候补递补的原子性
|
||||
const [course] = await tx
|
||||
.select()
|
||||
.from(electiveCourses)
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
.limit(1),
|
||||
])
|
||||
const existing = existingRows[0]
|
||||
if (!existing) throw new Error("No active selection found")
|
||||
.for("update")
|
||||
.limit(1)
|
||||
|
||||
const course = courseRows[0]
|
||||
const now = new Date()
|
||||
await db
|
||||
.update(courseSelections)
|
||||
.set({ status: "dropped", droppedAt: now, updatedAt: now })
|
||||
.where(eq(courseSelections.id, existing.id))
|
||||
const now = new Date()
|
||||
await tx
|
||||
.update(courseSelections)
|
||||
.set({ status: "dropped", droppedAt: now, updatedAt: now })
|
||||
.where(eq(courseSelections.id, existing.id))
|
||||
|
||||
if (existing.status === "enrolled") {
|
||||
if (course && course.selectionMode === "fcfs") {
|
||||
if (existing.status === "enrolled" && course && course.selectionMode === "fcfs") {
|
||||
const newEnrolledCount = Math.max(0, course.enrolledCount - 1)
|
||||
await db
|
||||
await tx
|
||||
.update(electiveCourses)
|
||||
.set({ enrolledCount: newEnrolledCount, updatedAt: now })
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
|
||||
const [nextWait] = await db
|
||||
const [nextWait] = await tx
|
||||
.select()
|
||||
.from(courseSelections)
|
||||
.where(
|
||||
@@ -223,7 +227,7 @@ export async function dropCourse(
|
||||
.orderBy(asc(courseSelections.priority), asc(courseSelections.selectedAt))
|
||||
.limit(1)
|
||||
if (nextWait) {
|
||||
await db
|
||||
await tx
|
||||
.update(courseSelections)
|
||||
.set({
|
||||
status: "enrolled",
|
||||
@@ -231,11 +235,11 @@ export async function dropCourse(
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(courseSelections.id, nextWait.id))
|
||||
await db
|
||||
await tx
|
||||
.update(electiveCourses)
|
||||
.set({ enrolledCount: newEnrolledCount + 1, updatedAt: now })
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10,16 +10,19 @@ import {
|
||||
} from "@/shared/db/schema"
|
||||
|
||||
import { getStudentActiveGradeId } from "@/modules/classes/data-access"
|
||||
import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import {
|
||||
buildCourseSelect,
|
||||
mapCourseRow,
|
||||
resolveCourseDisplayNames,
|
||||
type CourseCoreRow,
|
||||
} from "./data-access"
|
||||
import type {
|
||||
CourseSelectionWithDetails,
|
||||
ElectiveCourseWithDetails,
|
||||
} from "./types"
|
||||
|
||||
type CourseCoreRow = typeof electiveCourses.$inferSelect
|
||||
|
||||
type SelectionCoreRow = {
|
||||
id: string
|
||||
courseId: string
|
||||
@@ -43,36 +46,6 @@ const toIso = (d: Date | null | undefined): string | null =>
|
||||
|
||||
const toIsoRequired = (d: Date): string => d.toISOString()
|
||||
|
||||
const mapCourseRow = (
|
||||
r: CourseCoreRow,
|
||||
teacherNames: Map<string, string | null>,
|
||||
subjectNames: Map<string, string>,
|
||||
gradeNames: Map<string, string>
|
||||
): ElectiveCourseWithDetails => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
subjectId: r.subjectId,
|
||||
teacherId: r.teacherId,
|
||||
gradeId: r.gradeId,
|
||||
description: r.description,
|
||||
capacity: r.capacity,
|
||||
enrolledCount: r.enrolledCount,
|
||||
classroom: r.classroom,
|
||||
schedule: r.schedule,
|
||||
startDate: r.startDate ? new Date(r.startDate).toISOString().slice(0, 10) : null,
|
||||
endDate: r.endDate ? new Date(r.endDate).toISOString().slice(0, 10) : null,
|
||||
selectionStartAt: toIso(r.selectionStartAt),
|
||||
selectionEndAt: toIso(r.selectionEndAt),
|
||||
status: r.status,
|
||||
selectionMode: r.selectionMode,
|
||||
credit: String(r.credit),
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
teacherName: r.teacherId ? (teacherNames.get(r.teacherId) ?? null) : null,
|
||||
subjectName: r.subjectId ? (subjectNames.get(r.subjectId) ?? null) : null,
|
||||
gradeName: r.gradeId ? (gradeNames.get(r.gradeId) ?? null) : null,
|
||||
})
|
||||
|
||||
const mapSelectionRow = (
|
||||
r: SelectionCoreRow,
|
||||
studentNames: Map<string, string | null>
|
||||
@@ -95,31 +68,6 @@ const mapSelectionRow = (
|
||||
courseStatus: r.courseStatus,
|
||||
})
|
||||
|
||||
const buildCourseCoreSelect = () =>
|
||||
db
|
||||
.select({
|
||||
id: electiveCourses.id,
|
||||
name: electiveCourses.name,
|
||||
subjectId: electiveCourses.subjectId,
|
||||
teacherId: electiveCourses.teacherId,
|
||||
gradeId: electiveCourses.gradeId,
|
||||
description: electiveCourses.description,
|
||||
capacity: electiveCourses.capacity,
|
||||
enrolledCount: electiveCourses.enrolledCount,
|
||||
classroom: electiveCourses.classroom,
|
||||
schedule: electiveCourses.schedule,
|
||||
startDate: electiveCourses.startDate,
|
||||
endDate: electiveCourses.endDate,
|
||||
selectionStartAt: electiveCourses.selectionStartAt,
|
||||
selectionEndAt: electiveCourses.selectionEndAt,
|
||||
status: electiveCourses.status,
|
||||
selectionMode: electiveCourses.selectionMode,
|
||||
credit: electiveCourses.credit,
|
||||
createdAt: electiveCourses.createdAt,
|
||||
updatedAt: electiveCourses.updatedAt,
|
||||
})
|
||||
.from(electiveCourses)
|
||||
|
||||
const buildSelectionCoreSelect = () =>
|
||||
db
|
||||
.select({
|
||||
@@ -142,30 +90,6 @@ const buildSelectionCoreSelect = () =>
|
||||
.from(courseSelections)
|
||||
.leftJoin(electiveCourses, eq(electiveCourses.id, courseSelections.courseId))
|
||||
|
||||
const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{
|
||||
teacherNames: Map<string, string | null>
|
||||
subjectNames: Map<string, string>
|
||||
gradeNames: Map<string, string>
|
||||
}> => {
|
||||
const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const [userMap, subjects, grades] = await Promise.all([
|
||||
getUserNamesByIds(teacherIds),
|
||||
getSubjectOptions(),
|
||||
getGradeOptions(),
|
||||
])
|
||||
|
||||
const teacherNames = new Map<string, string | null>()
|
||||
for (const [id, user] of userMap.entries()) {
|
||||
teacherNames.set(id, user.name)
|
||||
}
|
||||
const subjectNames = new Map<string, string>()
|
||||
for (const s of subjects) subjectNames.set(s.id, s.name)
|
||||
const gradeNames = new Map<string, string>()
|
||||
for (const g of grades) gradeNames.set(g.id, g.name)
|
||||
|
||||
return { teacherNames, subjectNames, gradeNames }
|
||||
}
|
||||
|
||||
const resolveStudentDisplayNames = async (rows: SelectionCoreRow[]): Promise<Map<string, string | null>> => {
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.studentId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const userMap = await getUserNamesByIds(studentIds)
|
||||
@@ -216,7 +140,7 @@ export const getAvailableCoursesForStudent = cache(
|
||||
sql`(${electiveCourses.gradeId} = ${resolvedGradeId} OR ${electiveCourses.gradeId} IS NULL)`
|
||||
)
|
||||
}
|
||||
const rows = await buildCourseCoreSelect()
|
||||
const rows: CourseCoreRow[] = await buildCourseSelect()
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(electiveCourses.createdAt))
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
|
||||
@@ -2,16 +2,13 @@ import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, asc, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
import { and, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
electiveCourses,
|
||||
grades,
|
||||
subjects,
|
||||
users,
|
||||
} from "@/shared/db/schema"
|
||||
import { electiveCourses } from "@/shared/db/schema"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import { getGradeOptions, getSubjectOptions } from "@/modules/school/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
|
||||
import type {
|
||||
ElectiveCourseWithDetails,
|
||||
@@ -43,12 +40,13 @@ const buildScopeFilter = (scope: DataScope, userId?: string): SQL | null => {
|
||||
return sql`1=0`
|
||||
}
|
||||
|
||||
const mapCourseRow = (
|
||||
r: typeof electiveCourses.$inferSelect & {
|
||||
teacherName: string | null
|
||||
subjectName: string | null
|
||||
gradeName: string | null
|
||||
}
|
||||
export type CourseCoreRow = typeof electiveCourses.$inferSelect
|
||||
|
||||
export const mapCourseRow = (
|
||||
r: CourseCoreRow,
|
||||
teacherNames: Map<string, string | null>,
|
||||
subjectNames: Map<string, string>,
|
||||
gradeNames: Map<string, string>
|
||||
): ElectiveCourseWithDetails => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
@@ -69,12 +67,12 @@ const mapCourseRow = (
|
||||
credit: String(r.credit),
|
||||
createdAt: toIsoRequired(r.createdAt),
|
||||
updatedAt: toIsoRequired(r.updatedAt),
|
||||
teacherName: r.teacherName,
|
||||
subjectName: r.subjectName,
|
||||
gradeName: r.gradeName,
|
||||
teacherName: r.teacherId ? (teacherNames.get(r.teacherId) ?? null) : null,
|
||||
subjectName: r.subjectId ? (subjectNames.get(r.subjectId) ?? null) : null,
|
||||
gradeName: r.gradeId ? (gradeNames.get(r.gradeId) ?? null) : null,
|
||||
})
|
||||
|
||||
const buildCourseSelect = () =>
|
||||
export const buildCourseSelect = () =>
|
||||
db
|
||||
.select({
|
||||
id: electiveCourses.id,
|
||||
@@ -96,14 +94,32 @@ const buildCourseSelect = () =>
|
||||
credit: electiveCourses.credit,
|
||||
createdAt: electiveCourses.createdAt,
|
||||
updatedAt: electiveCourses.updatedAt,
|
||||
teacherName: users.name,
|
||||
subjectName: subjects.name,
|
||||
gradeName: grades.name,
|
||||
})
|
||||
.from(electiveCourses)
|
||||
.leftJoin(users, eq(users.id, electiveCourses.teacherId))
|
||||
.leftJoin(subjects, eq(subjects.id, electiveCourses.subjectId))
|
||||
.leftJoin(grades, eq(grades.id, electiveCourses.gradeId))
|
||||
|
||||
export const resolveCourseDisplayNames = async (rows: CourseCoreRow[]): Promise<{
|
||||
teacherNames: Map<string, string | null>
|
||||
subjectNames: Map<string, string>
|
||||
gradeNames: Map<string, string>
|
||||
}> => {
|
||||
const teacherIds = Array.from(new Set(rows.map((r) => r.teacherId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const [userMap, subjects, grades] = await Promise.all([
|
||||
getUserNamesByIds(teacherIds),
|
||||
getSubjectOptions(),
|
||||
getGradeOptions(),
|
||||
])
|
||||
|
||||
const teacherNames = new Map<string, string | null>()
|
||||
for (const [id, user] of userMap.entries()) {
|
||||
teacherNames.set(id, user.name)
|
||||
}
|
||||
const subjectNames = new Map<string, string>()
|
||||
for (const s of subjects) subjectNames.set(s.id, s.name)
|
||||
const gradeNames = new Map<string, string>()
|
||||
for (const g of grades) gradeNames.set(g.id, g.name)
|
||||
|
||||
return { teacherNames, subjectNames, gradeNames }
|
||||
}
|
||||
|
||||
export const getElectiveCourses = cache(
|
||||
async (
|
||||
@@ -131,7 +147,9 @@ export const getElectiveCourses = cache(
|
||||
: query
|
||||
).orderBy(desc(electiveCourses.createdAt))
|
||||
|
||||
return rows.map(mapCourseRow)
|
||||
if (rows.length === 0) return []
|
||||
const displayMaps = await resolveCourseDisplayNames(rows)
|
||||
return rows.map((r) => mapCourseRow(r, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames))
|
||||
} catch (error) {
|
||||
console.error("getElectiveCourses failed:", error)
|
||||
return []
|
||||
@@ -146,7 +164,8 @@ export const getElectiveCourseById = cache(
|
||||
.where(eq(electiveCourses.id, id))
|
||||
.limit(1)
|
||||
if (!row) return null
|
||||
return mapCourseRow(row)
|
||||
const displayMaps = await resolveCourseDisplayNames([row])
|
||||
return mapCourseRow(row, displayMaps.teacherNames, displayMaps.subjectNames, displayMaps.gradeNames)
|
||||
} catch (error) {
|
||||
console.error("getElectiveCourseById failed:", error)
|
||||
return null
|
||||
@@ -228,17 +247,4 @@ export async function closeSelection(courseId: string): Promise<void> {
|
||||
.where(eq(electiveCourses.id, courseId))
|
||||
}
|
||||
|
||||
export async function getSubjectOptions(): Promise<{ id: string; name: string }[]> {
|
||||
try {
|
||||
const rows = await db
|
||||
.select({ id: subjects.id, name: subjects.name })
|
||||
.from(subjects)
|
||||
.orderBy(asc(subjects.order), asc(subjects.name))
|
||||
return rows.map((r) => ({ id: r.id, name: r.name }))
|
||||
} catch (error) {
|
||||
console.error("getSubjectOptions failed:", error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export type { ElectiveCourseWithDetails }
|
||||
|
||||
@@ -137,12 +137,24 @@ const loadAiDraftQuestionsAndStructure = async (input: {
|
||||
if (!validated.success || validated.data.length === 0) {
|
||||
return { ok: false, message: "Invalid AI preview payload" }
|
||||
}
|
||||
const generated = validated.data.map((q) => ({
|
||||
const generated: AiGeneratedQuestion[] = validated.data.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content,
|
||||
score: q.score,
|
||||
content: {
|
||||
text: q.content.text,
|
||||
...(q.content.options
|
||||
? {
|
||||
options: q.content.options.map((opt) => ({
|
||||
id: opt.id,
|
||||
text: opt.text,
|
||||
isCorrect: opt.isCorrect ?? false,
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
...(q.content.subQuestions ? { subQuestions: q.content.subQuestions } : {}),
|
||||
},
|
||||
}))
|
||||
let structure: AiGeneratedStructureNode[] = []
|
||||
if (input.rawStructure) {
|
||||
|
||||
@@ -65,7 +65,7 @@ const AiExamResponseSchema = z.object({
|
||||
sections: z.array(AiSectionSchema).optional(),
|
||||
})
|
||||
|
||||
const sanitizeJsonCandidate = (value: string) => value
|
||||
const sanitizeJsonCandidate = (value: string): string => value
|
||||
.replace(/\[\s*\.\.\.\s*\]/g, "[]")
|
||||
.replace(/\{\s*\.\.\.\s*\}/g, "{}")
|
||||
.trim()
|
||||
@@ -174,7 +174,7 @@ const parseAiResponse = async (raw: string, providerId?: string) => {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeScores = (scores: number[], totalScore: number) => {
|
||||
const normalizeScores = (scores: number[], totalScore: number): number[] => {
|
||||
if (scores.length === 0) return []
|
||||
const sum = scores.reduce((acc, s) => acc + s, 0)
|
||||
if (sum <= 0) {
|
||||
@@ -306,6 +306,8 @@ const AI_QUESTION_DETAIL_SYSTEM_PROMPT = [
|
||||
"Never output placeholders like ..., [...], or {...}.",
|
||||
].join("\n")
|
||||
|
||||
type AiChatMessage = { role: "system" | "user"; content: string }
|
||||
|
||||
const buildAiMessages = (input: {
|
||||
title?: string
|
||||
subject?: string
|
||||
@@ -315,7 +317,7 @@ const buildAiMessages = (input: {
|
||||
durationMin?: number
|
||||
questionCount?: number
|
||||
sourceText: string
|
||||
}) => {
|
||||
}): AiChatMessage[] => {
|
||||
const userLines = [
|
||||
input.title ? `Title: ${input.title}` : "",
|
||||
input.subject ? `Subject: ${input.subject}` : "",
|
||||
@@ -450,7 +452,7 @@ const validateExamSourceText = async (input: { sourceText: string; aiProviderId?
|
||||
}
|
||||
}
|
||||
|
||||
const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>) => {
|
||||
const splitStructureItems = (draft: z.infer<typeof AiStructureResponseSchema>): SplitQuestionItem[] => {
|
||||
const hasSections = Array.isArray(draft.sections) && draft.sections.length > 0
|
||||
if (!hasSections) {
|
||||
return (draft.questions ?? []).map((q) => ({
|
||||
@@ -481,7 +483,7 @@ const mapWithConcurrency = async <T, R>(
|
||||
items: T[],
|
||||
concurrency: number,
|
||||
worker: (item: T, index: number) => Promise<R>
|
||||
) => {
|
||||
): Promise<R[]> => {
|
||||
const results = new Array<R>(items.length)
|
||||
let cursor = 0
|
||||
const runWorker = async () => {
|
||||
@@ -502,7 +504,7 @@ const parseQuestionDetail = async (input: {
|
||||
grade?: string
|
||||
difficulty: number
|
||||
aiProviderId?: string
|
||||
}) => {
|
||||
}): Promise<z.infer<typeof AiQuestionSchema>> => {
|
||||
const normalizeQuestionCandidate = (value: unknown): unknown => {
|
||||
if (!value || typeof value !== "object") return value
|
||||
const record = value as Record<string, unknown>
|
||||
@@ -568,7 +570,13 @@ const parseQuestionDetail = async (input: {
|
||||
} satisfies z.infer<typeof AiQuestionSchema>
|
||||
}
|
||||
|
||||
const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>) => {
|
||||
type QuestionContentResult = {
|
||||
text: string
|
||||
options?: Array<{ id: string; text: string; isCorrect: boolean }>
|
||||
subQuestions?: Array<{ id: string; text: string; answer?: string; score?: number }>
|
||||
}
|
||||
|
||||
const buildQuestionContent = (q: z.infer<typeof AiQuestionSchema>): QuestionContentResult => {
|
||||
const base = { text: q.content.text }
|
||||
const subQuestions = Array.isArray(q.content.subQuestions)
|
||||
? q.content.subQuestions.map((item, index) => ({
|
||||
@@ -709,7 +717,10 @@ const buildPreviewPayload = (
|
||||
}
|
||||
}
|
||||
|
||||
const previewToDraft = (preview: AiPreviewData) => {
|
||||
const previewToDraft = (preview: AiPreviewData): {
|
||||
generated: AiGeneratedQuestion[]
|
||||
structure: AiGeneratedStructureNode[]
|
||||
} => {
|
||||
const generated: AiGeneratedQuestion[] = []
|
||||
const structure: AiGeneratedStructureNode[] = []
|
||||
if (Array.isArray(preview.sections) && preview.sections.length > 0) {
|
||||
|
||||
@@ -3,14 +3,13 @@
|
||||
import { useCallback, useDeferredValue, useMemo, useState, useTransition, useEffect, useRef } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { Search, Eye } from "lucide-react"
|
||||
import { Eye } from "lucide-react"
|
||||
|
||||
import { Card, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/components/ui/select"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from "@/shared/components/ui/dialog"
|
||||
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
||||
import type { Question } from "@/modules/questions/types"
|
||||
import { updateExamAction } from "@/modules/exams/actions"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
@@ -384,38 +383,15 @@ export function ExamAssembly(props: ExamAssemblyProps) {
|
||||
{bankQuestions.length}{hasMore ? "+" : ""} loaded
|
||||
</span>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by content..."
|
||||
className="pl-9 h-9 text-sm"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="flex-1 h-8 text-xs bg-background"><SelectValue placeholder="Type" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="single_choice">Single Choice</SelectItem>
|
||||
<SelectItem value="multiple_choice">Multiple Choice</SelectItem>
|
||||
<SelectItem value="judgment">True/False</SelectItem>
|
||||
<SelectItem value="text">Short Answer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={difficultyFilter} onValueChange={setDifficultyFilter}>
|
||||
<SelectTrigger className="w-[80px] h-8 text-xs bg-background"><SelectValue placeholder="Diff" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="1">Lvl 1</SelectItem>
|
||||
<SelectItem value="2">Lvl 2</SelectItem>
|
||||
<SelectItem value="3">Lvl 3</SelectItem>
|
||||
<SelectItem value="4">Lvl 4</SelectItem>
|
||||
<SelectItem value="5">Lvl 5</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<QuestionBankFilters
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
type={typeFilter}
|
||||
onTypeChange={setTypeFilter}
|
||||
difficulty={difficultyFilter}
|
||||
onDifficultyChange={setDifficultyFilter}
|
||||
layout="compact"
|
||||
/>
|
||||
</CardHeader>
|
||||
|
||||
<ScrollArea className="flex-1 p-0 bg-muted/5">
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useQueryState, parseAsString } from "nuqs"
|
||||
import { Search, X } from "lucide-react"
|
||||
|
||||
import { Input } from "@/shared/components/ui/input"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -11,24 +9,29 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { FilterBar, FilterSearchInput } from "@/shared/components/ui/filter-bar"
|
||||
|
||||
export function ExamFilters() {
|
||||
const [search, setSearch] = useQueryState("q", parseAsString.withOptions({ shallow: false }))
|
||||
const [status, setStatus] = useQueryState("status", parseAsString.withOptions({ shallow: false }))
|
||||
const [difficulty, setDifficulty] = useQueryState("difficulty", parseAsString.withOptions({ shallow: false }))
|
||||
|
||||
const hasFilters = Boolean(search || (status && status !== "all") || (difficulty && difficulty !== "all"))
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<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 exams..."
|
||||
className="pl-9 bg-background border-muted-foreground/20"
|
||||
value={search || ""}
|
||||
onChange={(e) => setSearch(e.target.value || null)}
|
||||
/>
|
||||
</div>
|
||||
<FilterBar
|
||||
hasFilters={hasFilters}
|
||||
onReset={() => {
|
||||
setSearch(null)
|
||||
setStatus(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
>
|
||||
<FilterSearchInput
|
||||
value={search || ""}
|
||||
onChange={(v) => setSearch(v || null)}
|
||||
placeholder="Search exams..."
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto">
|
||||
<Select value={status || "all"} onValueChange={(val) => setStatus(val === "all" ? null : val)}>
|
||||
@@ -56,23 +59,7 @@ export function ExamFilters() {
|
||||
<SelectItem value="5">Hard (5)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{(search || (status && status !== "all") || (difficulty && difficulty !== "all")) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setSearch(null)
|
||||
setStatus(null)
|
||||
setDifficulty(null)
|
||||
}}
|
||||
className="h-10 px-3"
|
||||
>
|
||||
Reset
|
||||
<X className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</FilterBar>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -67,11 +67,11 @@ export function ExamForm() {
|
||||
} else {
|
||||
toast.error("Failed to load grades")
|
||||
}
|
||||
if (Array.isArray(aiProvidersResult)) {
|
||||
setAiProviders(aiProvidersResult)
|
||||
if (aiProvidersResult.success && aiProvidersResult.data) {
|
||||
setAiProviders(aiProvidersResult.data)
|
||||
const current = form.getValues("aiProviderId")
|
||||
if (!current) {
|
||||
const preferred = aiProvidersResult.find((item) => item.isDefault) ?? aiProvidersResult[0]
|
||||
const preferred = aiProvidersResult.data.find((item) => item.isDefault) ?? aiProvidersResult.data[0]
|
||||
if (preferred) {
|
||||
form.setValue("aiProviderId", preferred.id)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { db } from "@/shared/db"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers, subjects, grades } from "@/shared/db/schema"
|
||||
import { exams, examQuestions, examSubmissions, submissionAnswers } from "@/shared/db/schema"
|
||||
import { count, eq, desc, like, and, or, inArray } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access"
|
||||
import { getClassGradeIdsByClassIds } from "@/modules/classes/data-access"
|
||||
import { getSubjectNameById, getGradeNameById, getSubjectOptions, getGradeOptions } from "@/modules/school/data-access"
|
||||
|
||||
import type { Exam, ExamDifficulty, ExamStatus } from "./types"
|
||||
import type { AiGeneratedQuestion, AiGeneratedStructureNode } from "./ai-pipeline"
|
||||
@@ -208,22 +209,14 @@ export const omitScheduledAtFromDescription = (description: string | null): stri
|
||||
export const resolveSubjectGradeNames = async (input: {
|
||||
subjectId?: string
|
||||
gradeId?: string
|
||||
}) => {
|
||||
const [subjectRecord, gradeRecord] = await Promise.all([
|
||||
input.subjectId
|
||||
? db.query.subjects.findFirst({
|
||||
where: eq(subjects.id, input.subjectId),
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
input.gradeId
|
||||
? db.query.grades.findFirst({
|
||||
where: eq(grades.id, input.gradeId),
|
||||
})
|
||||
: Promise.resolve(null),
|
||||
}): Promise<{ subjectName: string | null; gradeName: string | null }> => {
|
||||
const [subjectName, gradeName] = await Promise.all([
|
||||
input.subjectId ? getSubjectNameById(input.subjectId) : Promise.resolve(null),
|
||||
input.gradeId ? getGradeNameById(input.gradeId) : Promise.resolve(null),
|
||||
])
|
||||
return {
|
||||
subjectName: subjectRecord?.name,
|
||||
gradeName: gradeRecord?.name,
|
||||
subjectName,
|
||||
gradeName,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,7 +262,7 @@ export const persistExamDraft = async (input: {
|
||||
const buildOrderedQuestionsFromStructure = (
|
||||
structure: AiGeneratedStructureNode[],
|
||||
generated: AiGeneratedQuestion[]
|
||||
) => {
|
||||
): Array<{ id: string; score: number }> => {
|
||||
const questionById = new Map(generated.map((q) => [q.id, q] as const))
|
||||
const orderedQuestions: Array<{ id: string; score: number }> = []
|
||||
const collectOrder = (nodes: AiGeneratedStructureNode[]) => {
|
||||
@@ -515,21 +508,19 @@ export const getExamPreview = async (
|
||||
|
||||
/**
|
||||
* Get all subjects for exam forms.
|
||||
* Delegates to school module data-access to avoid direct DB queries on subjects table.
|
||||
*/
|
||||
export const getExamSubjects = async (): Promise<Array<{ id: string; name: string }>> => {
|
||||
const allSubjects = await db.query.subjects.findMany({
|
||||
orderBy: (subjects, { asc }) => [asc(subjects.order), asc(subjects.name)],
|
||||
})
|
||||
const allSubjects = await getSubjectOptions()
|
||||
return allSubjects.map((s) => ({ id: s.id, name: s.name }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all grades for exam forms.
|
||||
* Delegates to school module data-access to avoid direct DB queries on grades table.
|
||||
*/
|
||||
export const getExamGrades = async (): Promise<Array<{ id: string; name: string }>> => {
|
||||
const allGrades = await db.query.grades.findMany({
|
||||
orderBy: (grades, { asc }) => [asc(grades.order), asc(grades.name)],
|
||||
})
|
||||
const allGrades = await getGradeOptions()
|
||||
return allGrades.map((g) => ({ id: g.id, name: g.name }))
|
||||
}
|
||||
|
||||
|
||||
54
src/modules/exams/utils/normalize-structure.ts
Normal file
54
src/modules/exams/utils/normalize-structure.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import type { ExamNode } from "../components/assembly/selected-question-list"
|
||||
|
||||
/**
|
||||
* Normalize raw exam structure data into typed `ExamNode[]`.
|
||||
*
|
||||
* - Validates each node's shape at runtime (type guard pattern, no `as`).
|
||||
* - Ensures every node has a unique id (generates one if missing or duplicate).
|
||||
* - Recursively normalizes group children.
|
||||
* - Returns `[]` for non-array input.
|
||||
*
|
||||
* Used by the exam build page to convert persisted `exam.structure` (unknown
|
||||
* JSON from DB) into a typed tree before passing to `<ExamAssembly />`.
|
||||
*/
|
||||
export function normalizeStructure(nodes: unknown): ExamNode[] {
|
||||
const seen = new Set<string>()
|
||||
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null
|
||||
|
||||
const normalize = (raw: unknown[]): ExamNode[] => {
|
||||
return raw
|
||||
.map((n): ExamNode | null => {
|
||||
if (!isRecord(n)) return null
|
||||
const type = n.type
|
||||
if (type !== "group" && type !== "question") return null
|
||||
|
||||
let id = typeof n.id === "string" && n.id.length > 0 ? n.id : createId()
|
||||
while (seen.has(id)) id = createId()
|
||||
seen.add(id)
|
||||
|
||||
if (type === "group") {
|
||||
return {
|
||||
id,
|
||||
type: "group",
|
||||
title: typeof n.title === "string" ? n.title : undefined,
|
||||
children: normalize(Array.isArray(n.children) ? n.children : []),
|
||||
} satisfies ExamNode
|
||||
}
|
||||
|
||||
if (typeof n.questionId !== "string" || n.questionId.length === 0) return null
|
||||
|
||||
return {
|
||||
id,
|
||||
type: "question",
|
||||
questionId: n.questionId,
|
||||
score: typeof n.score === "number" ? n.score : undefined,
|
||||
} satisfies ExamNode
|
||||
})
|
||||
.filter((n): n is ExamNode => n !== null)
|
||||
}
|
||||
|
||||
if (!Array.isArray(nodes)) return []
|
||||
return normalize(nodes)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import "server-only"
|
||||
|
||||
import { and, count, desc, eq, inArray, like, or, sql } from "drizzle-orm"
|
||||
import { and, count, desc, eq, inArray, like, or, sql, type SQL } from "drizzle-orm"
|
||||
import { cache } from "react"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
@@ -198,7 +198,7 @@ export const getFileAttachmentsWithFilters = cache(
|
||||
try {
|
||||
const { mimeType, search, limit = 100, offset = 0 } = params
|
||||
|
||||
const conditions = []
|
||||
const conditions: SQL[] = []
|
||||
if (mimeType) {
|
||||
if (mimeType.endsWith("/")) {
|
||||
conditions.push(like(fileAttachments.mimeType, `${mimeType}%`))
|
||||
|
||||
@@ -24,8 +24,8 @@ import {
|
||||
import {
|
||||
exportGradeRecordsToExcel,
|
||||
exportClassGradeReportToExcel,
|
||||
formatDateForFile,
|
||||
} from "./export"
|
||||
import { formatDateForFile } from "@/shared/lib/utils"
|
||||
import type { GradeQueryParams, GradeRecordListItem, GradeStats } from "./types"
|
||||
|
||||
export async function createGradeRecordAction(
|
||||
|
||||
90
src/modules/grades/components/analytics-filters.tsx
Normal file
90
src/modules/grades/components/analytics-filters.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { ChipNav } from "@/shared/components/ui/chip-nav"
|
||||
|
||||
interface AnalyticsFiltersProps {
|
||||
classes: Array<{ id: string; name: string }>
|
||||
grades: Array<{ id: string; name: string }>
|
||||
subjects: Array<{ id: string; name: string }>
|
||||
currentClassId: string
|
||||
currentSubjectId: string
|
||||
currentGradeId: string
|
||||
}
|
||||
|
||||
export function AnalyticsFilters({
|
||||
classes,
|
||||
grades,
|
||||
subjects,
|
||||
currentClassId,
|
||||
currentSubjectId,
|
||||
currentGradeId,
|
||||
}: AnalyticsFiltersProps): JSX.Element {
|
||||
const buildHref = (overrides: {
|
||||
classId?: string
|
||||
subjectId?: string
|
||||
gradeId?: string
|
||||
}): string => {
|
||||
const params = new URLSearchParams()
|
||||
params.set(
|
||||
"classId",
|
||||
overrides.classId !== undefined ? overrides.classId : currentClassId
|
||||
)
|
||||
params.set(
|
||||
"subjectId",
|
||||
overrides.subjectId !== undefined ? overrides.subjectId : currentSubjectId
|
||||
)
|
||||
if (
|
||||
overrides.gradeId !== undefined
|
||||
? overrides.gradeId
|
||||
: currentGradeId
|
||||
) {
|
||||
params.set(
|
||||
"gradeId",
|
||||
overrides.gradeId !== undefined ? overrides.gradeId : currentGradeId
|
||||
)
|
||||
}
|
||||
return `/teacher/grades/analytics?${params.toString()}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Class</div>
|
||||
<ChipNav
|
||||
options={classes}
|
||||
currentId={currentClassId}
|
||||
buildHref={(id) => buildHref({ classId: id })}
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">Subject</div>
|
||||
<ChipNav
|
||||
options={subjects}
|
||||
currentId={currentSubjectId}
|
||||
buildHref={(id) => buildHref({ subjectId: id })}
|
||||
size="xs"
|
||||
allOption={{ id: "all", label: "All" }}
|
||||
className="gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
Grade (for class comparison)
|
||||
</div>
|
||||
<ChipNav
|
||||
options={grades}
|
||||
currentId={currentGradeId}
|
||||
buildHref={(id) => buildHref({ gradeId: id })}
|
||||
size="xs"
|
||||
className="gap-1.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
|
||||
import type { ClassComparisonItem } from "@/modules/grades/types"
|
||||
|
||||
const chartConfig = {
|
||||
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
|
||||
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
excellentRate: { label: "Excellent (%)", color: "hsl(var(--chart-3))" },
|
||||
}
|
||||
|
||||
interface ClassComparisonChartProps {
|
||||
data: ClassComparisonItem[]
|
||||
}
|
||||
|
||||
export function ClassComparisonChart({ data }: ClassComparisonChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Class Comparison
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Compare average, pass rate, and excellent rate across classes.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No comparison data"
|
||||
description="Select a grade and subject to compare classes."
|
||||
className="border-none h-60"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
name: d.className,
|
||||
averageScore: d.averageScore,
|
||||
passRate: d.passRate,
|
||||
excellentRate: d.excellentRate,
|
||||
count: d.count,
|
||||
studentCount: d.studentCount,
|
||||
}))
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.map((d) => ({
|
||||
name: d.className,
|
||||
averageScore: d.averageScore,
|
||||
passRate: d.passRate,
|
||||
excellentRate: d.excellentRate,
|
||||
count: d.count,
|
||||
studentCount: d.studentCount,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Class Comparison
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Average score, pass rate (≥60%), and excellent rate (≥85%) per class.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[300px] w-full">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.4}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value: string) =>
|
||||
value.length > 8 ? `${value.slice(0, 8)}...` : value
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) => `${value}%`}
|
||||
width={36}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent className="w-[240px]" />} />
|
||||
<Legend />
|
||||
<Bar
|
||||
dataKey="averageScore"
|
||||
fill="var(--color-averageScore)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="passRate"
|
||||
fill="var(--color-passRate)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="excellentRate"
|
||||
fill="var(--color-excellentRate)"
|
||||
radius={[4, 4, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChartCardShell
|
||||
title="Class Comparison"
|
||||
description={
|
||||
isEmpty
|
||||
? "Compare average, pass rate, and excellent rate across classes."
|
||||
: "Average score, pass rate (≥60%), and excellent rate (≥85%) per class."
|
||||
}
|
||||
icon={BarChart3}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No comparison data"
|
||||
emptyDescription="Select a grade and subject to compare classes."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{ dataKey: "averageScore", name: "Average (%)", color: "hsl(var(--primary))" },
|
||||
{ dataKey: "passRate", name: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
{ dataKey: "excellentRate", name: "Excellent (%)", color: "hsl(var(--chart-3))" },
|
||||
]}
|
||||
xKey="name"
|
||||
xTruncateLength={8}
|
||||
yDomain={[0, 100]}
|
||||
yTickFormatter={(value: number) => `${value}%`}
|
||||
yWidth={36}
|
||||
heightClassName="h-[300px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
showLegend
|
||||
tooltipClassName="w-[240px]"
|
||||
/>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,25 +11,9 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { downloadBase64File } from "@/shared/lib/download"
|
||||
import { exportGradesAction } from "../actions"
|
||||
|
||||
function downloadBase64File(base64: string, filename: string) {
|
||||
const binary = atob(base64)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
|
||||
const blob = new Blob([bytes], {
|
||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
type ExportButtonProps = {
|
||||
classId: string
|
||||
subjectId?: string
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
"use client"
|
||||
|
||||
import { PieChart as PieChartIcon } from "lucide-react"
|
||||
import { Bar, BarChart, CartesianGrid, Cell, XAxis, YAxis } from "recharts"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { SimpleBarChart } from "@/shared/components/charts/simple-bar-chart"
|
||||
import type { GradeDistributionResult } from "@/modules/grades/types"
|
||||
|
||||
const BUCKET_COLORS: Record<string, string> = {
|
||||
@@ -26,113 +14,68 @@ const BUCKET_COLORS: Record<string, string> = {
|
||||
"<60": "hsl(0, 84%, 60%)",
|
||||
}
|
||||
|
||||
const chartConfig = {
|
||||
count: { label: "Students", color: "hsl(var(--primary))" },
|
||||
}
|
||||
|
||||
interface GradeDistributionChartProps {
|
||||
data: GradeDistributionResult | null
|
||||
}
|
||||
|
||||
export function GradeDistributionChart({ data }: GradeDistributionChartProps) {
|
||||
if (!data || data.totalCount === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChartIcon className="h-4 w-4" />
|
||||
Score Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Number of students in each score range (normalized to 0-100).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={PieChartIcon}
|
||||
title="No distribution data"
|
||||
description="Select a class and subject to view score distribution."
|
||||
className="border-none h-60"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const isEmpty = !data || data.totalCount === 0
|
||||
|
||||
const chartData = data.buckets.map((b) => ({
|
||||
label: b.label,
|
||||
count: b.count,
|
||||
percentage:
|
||||
data.totalCount > 0
|
||||
? Math.round((b.count / data.totalCount) * 1000) / 10
|
||||
: 0,
|
||||
}))
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.buckets.map((b) => ({
|
||||
label: b.label,
|
||||
count: b.count,
|
||||
percentage:
|
||||
data.totalCount > 0
|
||||
? Math.round((b.count / data.totalCount) * 1000) / 10
|
||||
: 0,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<PieChartIcon className="h-4 w-4" />
|
||||
Score Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{data.totalCount} grade record{data.totalCount === 1 ? "" : "s"} across
|
||||
score ranges.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[280px] w-full">
|
||||
<BarChart
|
||||
data={chartData}
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.4}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={32}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
className="w-[200px]"
|
||||
formatter={(payload: unknown) => {
|
||||
const item = (payload as { payload?: (typeof chartData)[number] })?.payload
|
||||
if (!item) return null
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{item.label}: {item.count} student
|
||||
{item.count === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{item.percentage}% of total
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
|
||||
{chartData.map((entry) => (
|
||||
<Cell key={entry.label} fill={BUCKET_COLORS[entry.label]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChartCardShell
|
||||
title="Score Distribution"
|
||||
description={
|
||||
isEmpty
|
||||
? "Number of students in each score range (normalized to 0-100)."
|
||||
: `${data.totalCount} grade record${data.totalCount === 1 ? "" : "s"} across score ranges.`
|
||||
}
|
||||
icon={PieChartIcon}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No distribution data"
|
||||
emptyDescription="Select a class and subject to view score distribution."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<SimpleBarChart
|
||||
data={chartData}
|
||||
bars={[
|
||||
{
|
||||
dataKey: "count",
|
||||
name: "Students",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
]}
|
||||
xKey="label"
|
||||
xTickFormatter={null}
|
||||
yAllowDecimals={false}
|
||||
yWidth={32}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
tooltipClassName="w-[200px]"
|
||||
cellColors={BUCKET_COLORS}
|
||||
tooltipFormatter={(payload: unknown) => {
|
||||
const item = (payload as { payload?: { label: string; count: number; percentage: number } })?.payload
|
||||
if (!item) return null
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-0.5">
|
||||
<span className="text-sm font-medium">
|
||||
{item.label}: {item.count} student{item.count === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{item.percentage}% of total</span>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,27 +1,8 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/components/ui/card"
|
||||
import { StatItem } from "@/shared/components/ui/stat-item"
|
||||
import { TrendingUp, TrendingDown, BarChart3, Target, Award, CheckCircle2 } from "lucide-react"
|
||||
import type { GradeStats } from "../types"
|
||||
|
||||
interface StatItemProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
hint?: string
|
||||
}
|
||||
|
||||
function StatItem({ label, value, icon, hint }: StatItemProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">{label}</span>
|
||||
<span className="text-muted-foreground">{icon}</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">{value}</span>
|
||||
{hint ? <span className="text-xs text-muted-foreground">{hint}</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GradeStatsCard({ stats }: { stats: GradeStats | null }) {
|
||||
if (!stats || stats.count === 0) {
|
||||
return (
|
||||
|
||||
@@ -1,137 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import { BarChart3 } from "lucide-react"
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { TrendLineChart } from "@/shared/components/charts/trend-line-chart"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import type { GradeTrendResult } from "@/modules/grades/types"
|
||||
|
||||
const chartConfig = {
|
||||
normalizedScore: {
|
||||
label: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
},
|
||||
}
|
||||
|
||||
interface GradeTrendChartProps {
|
||||
data: GradeTrendResult | null
|
||||
}
|
||||
|
||||
export function GradeTrendChart({ data }: GradeTrendChartProps) {
|
||||
if (!data || data.points.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Grade Trend
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Score progression over time (normalized to 0-100).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="No trend data"
|
||||
description="Select a class and subject to view the grade trend."
|
||||
className="border-none h-60"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const isEmpty = !data || data.points.length === 0
|
||||
|
||||
const chartData = data.points.map((p) => ({
|
||||
title: p.title,
|
||||
normalizedScore: p.normalizedScore,
|
||||
fullTitle: p.title,
|
||||
date: formatDate(p.date),
|
||||
rawScore: p.score,
|
||||
fullScore: p.fullScore,
|
||||
type: p.type,
|
||||
}))
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.points.map((p) => ({
|
||||
title: p.title,
|
||||
normalizedScore: p.normalizedScore,
|
||||
fullTitle: p.title,
|
||||
date: formatDate(p.date),
|
||||
rawScore: p.score,
|
||||
fullScore: p.fullScore,
|
||||
type: p.type,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
Grade Trend
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{data.label} · avg {data.averageScore.toFixed(1)}%
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[280px] w-full">
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
>
|
||||
<CartesianGrid
|
||||
vertical={false}
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.4}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="title"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(value: string) =>
|
||||
value.length > 10 ? `${value.slice(0, 10)}...` : value
|
||||
}
|
||||
/>
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value: number) => `${value}%`}
|
||||
width={36}
|
||||
/>
|
||||
<ChartTooltip
|
||||
cursor={{
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
strokeWidth: 1,
|
||||
strokeDasharray: "4 4",
|
||||
}}
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
indicator="line"
|
||||
labelKey="fullTitle"
|
||||
className="w-[220px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Line
|
||||
dataKey="normalizedScore"
|
||||
type="monotone"
|
||||
stroke="var(--color-normalizedScore)"
|
||||
strokeWidth={2}
|
||||
dot={{
|
||||
fill: "var(--color-normalizedScore)",
|
||||
r: 3,
|
||||
strokeWidth: 2,
|
||||
}}
|
||||
activeDot={{ r: 5, strokeWidth: 0 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChartCardShell
|
||||
title="Grade Trend"
|
||||
description={
|
||||
isEmpty
|
||||
? "Score progression over time (normalized to 0-100)."
|
||||
: `${data.label} · avg ${data.averageScore.toFixed(1)}%`
|
||||
}
|
||||
icon={BarChart3}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No trend data"
|
||||
emptyDescription="Select a class and subject to view the grade trend."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<TrendLineChart
|
||||
data={chartData}
|
||||
series={[
|
||||
{
|
||||
dataKey: "normalizedScore",
|
||||
name: "Score (%)",
|
||||
color: "hsl(var(--primary))",
|
||||
dotRadius: 3,
|
||||
activeDotRadius: 5,
|
||||
},
|
||||
]}
|
||||
heightClassName="h-[280px]"
|
||||
margin={{ left: 8, right: 8, top: 8, bottom: 8 }}
|
||||
yWidth={36}
|
||||
tooltipClassName="w-[220px]"
|
||||
/>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
41
src/modules/grades/components/stats-class-selector.tsx
Normal file
41
src/modules/grades/components/stats-class-selector.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { JSX } from "react"
|
||||
|
||||
import { ChipNav } from "@/shared/components/ui/chip-nav"
|
||||
|
||||
interface StatsClassSelectorProps {
|
||||
classes: Array<{ id: string; name: string }>
|
||||
subjects: Array<{ id: string; name: string }>
|
||||
currentClassId: string
|
||||
currentSubjectId: string
|
||||
}
|
||||
|
||||
export function StatsClassSelector({
|
||||
classes,
|
||||
subjects,
|
||||
currentClassId,
|
||||
currentSubjectId,
|
||||
}: StatsClassSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ChipNav
|
||||
options={classes}
|
||||
currentId={currentClassId}
|
||||
buildHref={(id) =>
|
||||
`/teacher/grades/stats?classId=${id}${currentSubjectId !== "all" ? `&subjectId=${currentSubjectId}` : ""}`
|
||||
}
|
||||
/>
|
||||
<div className="ml-auto">
|
||||
<ChipNav
|
||||
options={subjects}
|
||||
currentId={currentSubjectId}
|
||||
buildHref={(id) =>
|
||||
id === "all"
|
||||
? `/teacher/grades/stats?classId=${currentClassId}`
|
||||
: `/teacher/grades/stats?classId=${currentClassId}&subjectId=${id}`
|
||||
}
|
||||
allOption={{ id: "all", label: "All Subjects" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { Radar } from "lucide-react"
|
||||
import {
|
||||
PolarAngleAxis,
|
||||
PolarGrid,
|
||||
PolarRadiusAxis,
|
||||
Radar as RechartsRadar,
|
||||
RadarChart,
|
||||
} from "recharts"
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/components/ui/card"
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/shared/components/ui/chart"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { ChartCardShell } from "@/shared/components/charts/chart-card-shell"
|
||||
import { ComparisonRadarChart } from "@/shared/components/charts/comparison-radar-chart"
|
||||
import type { SubjectComparisonItem } from "@/modules/grades/types"
|
||||
|
||||
const chartConfig = {
|
||||
averageScore: { label: "Average (%)", color: "hsl(var(--primary))" },
|
||||
passRate: { label: "Pass Rate (%)", color: "hsl(var(--chart-2))" },
|
||||
}
|
||||
|
||||
interface SubjectComparisonChartProps {
|
||||
data: SubjectComparisonItem[]
|
||||
}
|
||||
|
||||
export function SubjectComparisonChart({ data }: SubjectComparisonChartProps) {
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radar className="h-4 w-4" />
|
||||
Subject Comparison
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Compare performance across subjects for the selected class.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EmptyState
|
||||
icon={Radar}
|
||||
title="No comparison data"
|
||||
description="Select a class to compare subject performance."
|
||||
className="border-none h-60"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
const isEmpty = !data || data.length === 0
|
||||
|
||||
const chartData = data.map((d) => ({
|
||||
subject: d.subjectName,
|
||||
averageScore: d.averageScore,
|
||||
passRate: d.passRate,
|
||||
excellentRate: d.excellentRate,
|
||||
count: d.count,
|
||||
}))
|
||||
const chartData = isEmpty
|
||||
? []
|
||||
: data.map((d) => ({
|
||||
subject: d.subjectName,
|
||||
averageScore: d.averageScore,
|
||||
passRate: d.passRate,
|
||||
excellentRate: d.excellentRate,
|
||||
count: d.count,
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Radar className="h-4 w-4" />
|
||||
Subject Comparison
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Average score and pass rate per subject (normalized to 0-100).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ChartContainer config={chartConfig} className="h-[300px] w-full">
|
||||
<RadarChart data={chartData} outerRadius="75%">
|
||||
<PolarGrid strokeOpacity={0.4} />
|
||||
<PolarAngleAxis
|
||||
dataKey="subject"
|
||||
tick={{ fontSize: 12 }}
|
||||
tickFormatter={(value: string) =>
|
||||
value.length > 6 ? `${value.slice(0, 6)}...` : value
|
||||
}
|
||||
/>
|
||||
<PolarRadiusAxis
|
||||
domain={[0, 100]}
|
||||
tickFormatter={(value: number) => `${value}%`}
|
||||
tick={{ fontSize: 10 }}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent className="w-[220px]" />} />
|
||||
<RechartsRadar
|
||||
name="Average"
|
||||
dataKey="averageScore"
|
||||
stroke="var(--color-averageScore)"
|
||||
fill="var(--color-averageScore)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
<RechartsRadar
|
||||
name="Pass Rate"
|
||||
dataKey="passRate"
|
||||
stroke="var(--color-passRate)"
|
||||
fill="var(--color-passRate)"
|
||||
fillOpacity={0.2}
|
||||
/>
|
||||
</RadarChart>
|
||||
</ChartContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<ChartCardShell
|
||||
title="Subject Comparison"
|
||||
description={
|
||||
isEmpty
|
||||
? "Compare performance across subjects for the selected class."
|
||||
: "Average score and pass rate per subject (normalized to 0-100)."
|
||||
}
|
||||
icon={Radar}
|
||||
isEmpty={isEmpty}
|
||||
emptyTitle="No comparison data"
|
||||
emptyDescription="Select a class to compare subject performance."
|
||||
emptyClassName="h-60"
|
||||
>
|
||||
<ComparisonRadarChart
|
||||
data={chartData}
|
||||
angleKey="subject"
|
||||
angleTickFormatter={(value: string) =>
|
||||
value.length > 6 ? `${value.slice(0, 6)}...` : value
|
||||
}
|
||||
heightClassName="h-[300px]"
|
||||
series={[
|
||||
{
|
||||
dataKey: "averageScore",
|
||||
name: "Average",
|
||||
color: "hsl(var(--primary))",
|
||||
fillOpacity: 0.4,
|
||||
},
|
||||
{
|
||||
dataKey: "passRate",
|
||||
name: "Pass Rate",
|
||||
color: "hsl(var(--chart-2))",
|
||||
fillOpacity: 0.2,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCardShell>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm"
|
||||
import { and, asc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
@@ -31,7 +31,7 @@ const normalize = (score: number, fullScore: number): number => {
|
||||
return Math.round((score / fullScore) * 10000) / 100
|
||||
}
|
||||
|
||||
const buildScopeClassFilter = (scope: DataScope) => {
|
||||
const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
||||
@@ -242,7 +242,6 @@ export const getSubjectComparison = cache(
|
||||
if (rows.length === 0) return []
|
||||
|
||||
// Fetch subject names via cross-module interface
|
||||
const subjectIds = Array.from(new Set(rows.map((r) => r.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const subjectOptions = await getSubjectOptions()
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only"
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
import { and, count, desc, eq, inArray, sql, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import { gradeRecords } from "@/shared/db/schema"
|
||||
@@ -54,7 +54,7 @@ const serializeRecord = (r: typeof gradeRecords.$inferSelect): GradeRecord => ({
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
const buildScopeClassFilter = (scope: DataScope) => {
|
||||
const buildScopeClassFilter = (scope: DataScope): SQL | null => {
|
||||
if (scope.type === "all") return null
|
||||
if (scope.type === "class_taught") {
|
||||
return scope.classIds.length > 0 ? inArray(gradeRecords.classId, scope.classIds) : sql`1=0`
|
||||
@@ -107,7 +107,6 @@ export const getGradeRecords = cache(
|
||||
// Batch fetch display names via cross-module interfaces
|
||||
const studentIds = Array.from(new Set(rows.map((r) => r.record.studentId)))
|
||||
const classIds = Array.from(new Set(rows.map((r) => r.record.classId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const subjectIds = Array.from(new Set(rows.map((r) => r.record.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const recorderIds = Array.from(new Set(rows.map((r) => r.record.recordedBy)))
|
||||
|
||||
const [studentNameMap, classNameMap, subjectOptions, recorderNameMap] = await Promise.all([
|
||||
@@ -270,9 +269,8 @@ export const getClassGradeStats = cache(
|
||||
}
|
||||
)
|
||||
|
||||
export async function getStudentGradeSummary(
|
||||
studentId: string
|
||||
): Promise<StudentGradeSummary | null> {
|
||||
export const getStudentGradeSummary = cache(
|
||||
async (studentId: string): Promise<StudentGradeSummary | null> => {
|
||||
const studentNameMap = await getUserNamesByIds([studentId])
|
||||
const studentName = studentNameMap.get(studentId)?.name ?? null
|
||||
if (!studentName && !studentNameMap.has(studentId)) return null
|
||||
@@ -297,7 +295,6 @@ export async function getStudentGradeSummary(
|
||||
|
||||
// Batch fetch display names via cross-module interfaces
|
||||
const classIds = Array.from(new Set(records.map((r) => r.record.classId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
const subjectIds = Array.from(new Set(records.map((r) => r.record.subjectId).filter((v): v is string => typeof v === "string" && v.length > 0)))
|
||||
|
||||
const [classNameMap, subjectOptions] = await Promise.all([
|
||||
getClassNamesByIds(classIds),
|
||||
@@ -336,7 +333,8 @@ export async function getStudentGradeSummary(
|
||||
averageScore: Math.round(avg * 100) / 100,
|
||||
rank: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export const getClassRanking = cache(
|
||||
async (
|
||||
@@ -374,9 +372,9 @@ export const getClassRanking = cache(
|
||||
}
|
||||
)
|
||||
|
||||
export async function getClassStudentsForEntry(classId: string): Promise<
|
||||
export const getClassStudentsForEntry = cache(async (classId: string): Promise<
|
||||
Array<{ id: string; name: string; email: string }>
|
||||
> {
|
||||
> => {
|
||||
const studentIds = await getActiveStudentIdsByClassId(classId)
|
||||
if (studentIds.length === 0) return []
|
||||
|
||||
@@ -392,42 +390,44 @@ export async function getClassStudentsForEntry(classId: string): Promise<
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
}
|
||||
})
|
||||
|
||||
export async function getClassGradeStatsWithMeta(
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ClassGradeStats | null> {
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) return null
|
||||
export const getClassGradeStatsWithMeta = cache(
|
||||
async (
|
||||
classId: string,
|
||||
subjectId?: string,
|
||||
examId?: string
|
||||
): Promise<ClassGradeStats | null> => {
|
||||
const classExists = await getClassExists(classId)
|
||||
if (!classExists) return null
|
||||
|
||||
const className = await getClassNameById(classId)
|
||||
const stats = await getClassGradeStats(classId, subjectId, examId)
|
||||
if (!stats) {
|
||||
return {
|
||||
classId,
|
||||
className: className ?? "Unknown",
|
||||
stats: {
|
||||
average: 0,
|
||||
median: 0,
|
||||
max: 0,
|
||||
min: 0,
|
||||
stdDev: 0,
|
||||
passRate: 0,
|
||||
excellentRate: 0,
|
||||
count: 0,
|
||||
},
|
||||
studentCount: 0,
|
||||
}
|
||||
}
|
||||
|
||||
const activeStudentIds = await getActiveStudentIdsByClassId(classId)
|
||||
|
||||
const className = await getClassNameById(classId)
|
||||
const stats = await getClassGradeStats(classId, subjectId, examId)
|
||||
if (!stats) {
|
||||
return {
|
||||
classId,
|
||||
className: className ?? "Unknown",
|
||||
stats: {
|
||||
average: 0,
|
||||
median: 0,
|
||||
max: 0,
|
||||
min: 0,
|
||||
stdDev: 0,
|
||||
passRate: 0,
|
||||
excellentRate: 0,
|
||||
count: 0,
|
||||
},
|
||||
studentCount: 0,
|
||||
stats,
|
||||
studentCount: activeStudentIds.length,
|
||||
}
|
||||
}
|
||||
|
||||
const activeStudentIds = await getActiveStudentIdsByClassId(classId)
|
||||
|
||||
return {
|
||||
classId,
|
||||
className: className ?? "Unknown",
|
||||
stats,
|
||||
studentCount: activeStudentIds.length,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -16,13 +16,6 @@ const TYPE_LABELS: Record<GradeRecordType, string> = {
|
||||
other: "其他",
|
||||
}
|
||||
|
||||
const formatDateForFile = (d = new Date()) => {
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0")
|
||||
const day = String(d.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出成绩单
|
||||
* Sheet 1: 成绩明细
|
||||
@@ -124,10 +117,13 @@ export async function exportClassGradeReportToExcel(params: {
|
||||
getUserNamesByIds(studentIds),
|
||||
])
|
||||
|
||||
const subjectNameById = new Map<string, string>()
|
||||
for (const s of subjectOptions) subjectNameById.set(s.id, s.name)
|
||||
|
||||
const subjectRows = subjectIds
|
||||
.map((id) => {
|
||||
const subject = subjectOptions.find((s) => s.id === id)
|
||||
return subject ? { id: subject.id, name: subject.name } : null
|
||||
const name = subjectNameById.get(id)
|
||||
return name ? { id, name } : null
|
||||
})
|
||||
.filter((s): s is { id: string; name: string } => s !== null)
|
||||
|
||||
@@ -202,5 +198,3 @@ export async function exportClassGradeReportToExcel(params: {
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
export { formatDateForFile }
|
||||
|
||||
@@ -452,10 +452,6 @@ export const getHomeworkSubmissionDetails = cache(async (submissionId: string):
|
||||
}
|
||||
})
|
||||
|
||||
// Re-export getDemoStudentUser from users module for backward compatibility.
|
||||
// New code should import getCurrentStudentUser from "@/modules/users/data-access" instead.
|
||||
export { getCurrentStudentUser as getDemoStudentUser } from "@/modules/users/data-access"
|
||||
|
||||
const toStudentProgressStatus = (v: string | null | undefined): StudentHomeworkProgressStatus => {
|
||||
if (v === "started") return "in_progress"
|
||||
if (v === "submitted") return "submitted"
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
} from "@/shared/components/ui/tooltip"
|
||||
import { cn } from "@/shared/lib/utils"
|
||||
import { usePermission } from "@/shared/hooks"
|
||||
import { type Permission } from "@/shared/types/permissions"
|
||||
import { useSidebar } from "./sidebar-provider"
|
||||
import { NAV_CONFIG, Role } from "../config/navigation"
|
||||
|
||||
@@ -42,17 +41,17 @@ export function AppSidebar({ mode }: AppSidebarProps) {
|
||||
currentRole = "parent"
|
||||
}
|
||||
|
||||
const allNavItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher
|
||||
const allNavItems = NAV_CONFIG[currentRole] ?? NAV_CONFIG.teacher ?? []
|
||||
|
||||
// Filter nav items by permission
|
||||
const navItems = allNavItems.filter((item) => {
|
||||
if (!item.permission) return true
|
||||
return permissions.includes(item.permission as Permission)
|
||||
return permissions.includes(item.permission)
|
||||
}).map((item) => ({
|
||||
...item,
|
||||
items: item.items?.filter((subItem) => {
|
||||
if (!subItem.permission) return true
|
||||
return permissions.includes(subItem.permission as Permission)
|
||||
return permissions.includes(subItem.permission)
|
||||
}),
|
||||
}))
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import { NAV_CONFIG } from "../config/navigation"
|
||||
// Build lookup map for breadcrumbs
|
||||
const BREADCRUMB_MAP = new Map<string, string>()
|
||||
Object.values(NAV_CONFIG).forEach((items) => {
|
||||
items.forEach((item) => {
|
||||
items?.forEach((item) => {
|
||||
BREADCRUMB_MAP.set(item.href, item.title)
|
||||
item.items?.forEach((subItem) => {
|
||||
BREADCRUMB_MAP.set(subItem.href, subItem.title)
|
||||
|
||||
@@ -22,18 +22,19 @@ import {
|
||||
} from "lucide-react"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
import type { Permission, Role } from "@/shared/types/permissions"
|
||||
|
||||
export type { Role }
|
||||
|
||||
export type NavItem = {
|
||||
title: string
|
||||
icon: LucideIcon
|
||||
href: string
|
||||
permission?: string
|
||||
items?: { title: string; href: string; permission?: string }[]
|
||||
permission?: Permission
|
||||
items?: { title: string; href: string; permission?: Permission }[]
|
||||
}
|
||||
|
||||
export type Role = "admin" | "teacher" | "student" | "parent"
|
||||
|
||||
export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
export const NAV_CONFIG: Partial<Record<Role, NavItem[]>> = {
|
||||
admin: [
|
||||
{
|
||||
title: "Dashboard",
|
||||
@@ -171,6 +172,12 @@ export const NAV_CONFIG: Record<Role, NavItem[]> = {
|
||||
href: "/teacher/course-plans",
|
||||
permission: Permissions.COURSE_PLAN_READ,
|
||||
},
|
||||
{
|
||||
title: "Lesson Plans",
|
||||
icon: PenTool,
|
||||
href: "/teacher/lesson-plans",
|
||||
permission: Permissions.LESSON_PLAN_READ,
|
||||
},
|
||||
{
|
||||
title: "Attendance",
|
||||
icon: CalendarCheck,
|
||||
|
||||
31
src/modules/lesson-preparation/actions-ai.ts
Normal file
31
src/modules/lesson-preparation/actions-ai.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
"use server";
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { suggestKnowledgePoints } from "./ai-suggest";
|
||||
import type { ActionState, LessonPlanDocument } from "./types";
|
||||
|
||||
export async function suggestKnowledgePointsAction(input: {
|
||||
doc: LessonPlanDocument;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
}): Promise<
|
||||
ActionState<{
|
||||
suggestions: { id: string; name: string; reason: string }[];
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
await requirePermission(Permissions.AI_CHAT);
|
||||
const suggestions = await suggestKnowledgePoints(
|
||||
input.doc,
|
||||
input.textbookId,
|
||||
input.chapterId,
|
||||
);
|
||||
return { success: true, data: { suggestions } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "AI 推荐失败,请检查 AI Provider 配置" };
|
||||
}
|
||||
}
|
||||
39
src/modules/lesson-preparation/actions-kp.ts
Normal file
39
src/modules/lesson-preparation/actions-kp.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
"use server";
|
||||
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import {
|
||||
getKnowledgePointsByTextbookId,
|
||||
getKnowledgePointsByChapterId,
|
||||
} from "@/modules/textbooks/data-access";
|
||||
import type { ActionState } from "./types";
|
||||
|
||||
// 加载知识点选项(供客户端知识点选择器使用)
|
||||
export async function getKnowledgePointOptionsAction(input: {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
}): Promise<
|
||||
ActionState<{ options: { id: string; name: string }[] }>
|
||||
> {
|
||||
try {
|
||||
await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
if (!input.textbookId) return { success: true, data: { options: [] } };
|
||||
|
||||
let kps;
|
||||
if (input.chapterId) {
|
||||
kps = await getKnowledgePointsByChapterId(input.chapterId);
|
||||
} else {
|
||||
kps = await getKnowledgePointsByTextbookId(input.textbookId);
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
options: kps.map((kp) => ({ id: kp.id, name: kp.name })),
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "加载知识点失败" };
|
||||
}
|
||||
}
|
||||
51
src/modules/lesson-preparation/actions-publish.ts
Normal file
51
src/modules/lesson-preparation/actions-publish.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import {
|
||||
requirePermission,
|
||||
PermissionDeniedError,
|
||||
} from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import { publishLessonPlanHomework } from "./publish-service";
|
||||
import type { ActionState } from "./types";
|
||||
|
||||
export async function publishLessonPlanHomeworkAction(input: {
|
||||
planId: string;
|
||||
blockId: string;
|
||||
classIds: string[];
|
||||
availableAt?: string;
|
||||
dueAt?: string;
|
||||
}): Promise<ActionState<{ examId: string; assignmentId: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(
|
||||
Permissions.LESSON_PLAN_PUBLISH,
|
||||
);
|
||||
await requirePermission(Permissions.HOMEWORK_CREATE);
|
||||
const result = await publishLessonPlanHomework({
|
||||
planId: input.planId,
|
||||
blockId: input.blockId,
|
||||
userId: ctx.userId,
|
||||
classIds: input.classIds,
|
||||
availableAt: input.availableAt
|
||||
? new Date(input.availableAt)
|
||||
: undefined,
|
||||
dueAt: input.dueAt ? new Date(input.dueAt) : undefined,
|
||||
});
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
revalidatePath("/teacher/homework");
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
examId: result.examId,
|
||||
assignmentId: result.assignmentId,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return {
|
||||
success: false,
|
||||
message: e instanceof Error ? e.message : "发布失败",
|
||||
};
|
||||
}
|
||||
}
|
||||
284
src/modules/lesson-preparation/actions.ts
Normal file
284
src/modules/lesson-preparation/actions.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requirePermission, PermissionDeniedError } from "@/shared/lib/auth-guard";
|
||||
import { Permissions } from "@/shared/types/permissions";
|
||||
import {
|
||||
getLessonPlans,
|
||||
getLessonPlanById,
|
||||
createLessonPlan,
|
||||
updateLessonPlanContent,
|
||||
softDeleteLessonPlan,
|
||||
duplicateLessonPlan,
|
||||
} from "./data-access";
|
||||
import {
|
||||
getLessonPlanVersions,
|
||||
createLessonPlanVersion,
|
||||
revertToVersion,
|
||||
pruneAutoVersions,
|
||||
} from "./data-access-versions";
|
||||
import {
|
||||
getLessonPlanTemplates,
|
||||
saveAsTemplate,
|
||||
deletePersonalTemplate,
|
||||
} from "./data-access-templates";
|
||||
import {
|
||||
createLessonPlanSchema,
|
||||
updateLessonPlanContentSchema,
|
||||
saveVersionSchema,
|
||||
revertVersionSchema,
|
||||
saveAsTemplateSchema,
|
||||
} from "./schema";
|
||||
import type { ActionState, LessonPlanDocument } from "./types";
|
||||
|
||||
// ---- 课案列表 ----
|
||||
export async function getLessonPlansAction(params: {
|
||||
query?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}): Promise<
|
||||
ActionState<{
|
||||
items: Awaited<ReturnType<typeof getLessonPlans>>;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const items = await getLessonPlans(params, ctx.dataScope, ctx.userId);
|
||||
return { success: true, data: { items } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "获取课案列表失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 单课案 ----
|
||||
export async function getLessonPlanByIdAction(
|
||||
planId: string,
|
||||
): Promise<
|
||||
ActionState<{ plan: Awaited<ReturnType<typeof getLessonPlanById>> }>
|
||||
> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const plan = await getLessonPlanById(planId, ctx.userId);
|
||||
if (!plan) return { success: false, message: "课案不存在或无权访问" };
|
||||
return { success: true, data: { plan } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "获取课案失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 创建 ----
|
||||
export async function createLessonPlanAction(
|
||||
prevState: ActionState | null,
|
||||
formData: FormData,
|
||||
): Promise<ActionState<{ planId: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||
const parsed = createLessonPlanSchema.safeParse({
|
||||
title: formData.get("title"),
|
||||
textbookId: formData.get("textbookId") || undefined,
|
||||
chapterId: formData.get("chapterId") || undefined,
|
||||
subjectId: formData.get("subjectId") || undefined,
|
||||
gradeId: formData.get("gradeId") || undefined,
|
||||
templateId: formData.get("templateId"),
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
}
|
||||
const { planId } = await createLessonPlan({
|
||||
...parsed.data,
|
||||
creatorId: ctx.userId,
|
||||
});
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
return { success: true, data: { planId } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "创建课案失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 更新 content(自动保存)----
|
||||
export async function updateLessonPlanAction(input: {
|
||||
planId: string;
|
||||
title?: string;
|
||||
content: LessonPlanDocument;
|
||||
}): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = updateLessonPlanContentSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
await updateLessonPlanContent(parsed.data.planId, ctx.userId, {
|
||||
...(parsed.data.title ? { title: parsed.data.title } : {}),
|
||||
content: parsed.data.content as LessonPlanDocument,
|
||||
});
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "保存失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 手动保存版本 ----
|
||||
export async function saveLessonPlanVersionAction(input: {
|
||||
planId: string;
|
||||
content: LessonPlanDocument;
|
||||
label?: string;
|
||||
}): Promise<ActionState<{ versionNo: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = saveVersionSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
const { versionNo } = await createLessonPlanVersion({
|
||||
planId: parsed.data.planId,
|
||||
content: input.content,
|
||||
userId: ctx.userId,
|
||||
isAuto: false,
|
||||
label: parsed.data.label,
|
||||
});
|
||||
await pruneAutoVersions(parsed.data.planId);
|
||||
return { success: true, data: { versionNo } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "保存版本失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 版本列表 ----
|
||||
export async function getLessonPlanVersionsAction(
|
||||
planId: string,
|
||||
): Promise<
|
||||
ActionState<{
|
||||
versions: Awaited<ReturnType<typeof getLessonPlanVersions>>;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const versions = await getLessonPlanVersions(planId, ctx.userId);
|
||||
return { success: true, data: { versions } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "获取版本失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 回退版本 ----
|
||||
export async function revertLessonPlanVersionAction(input: {
|
||||
planId: string;
|
||||
versionNo: number;
|
||||
}): Promise<ActionState<{ newVersionNo: number }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_UPDATE);
|
||||
const parsed = revertVersionSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
const result = await revertToVersion(
|
||||
parsed.data.planId,
|
||||
parsed.data.versionNo,
|
||||
ctx.userId,
|
||||
);
|
||||
if (!result) return { success: false, message: "版本不存在或无权操作" };
|
||||
revalidatePath(`/teacher/lesson-plans/${parsed.data.planId}/edit`);
|
||||
return { success: true, data: { newVersionNo: result.newVersionNo } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "回退失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 删除(软删除)----
|
||||
export async function deleteLessonPlanAction(
|
||||
planId: string,
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||
await softDeleteLessonPlan(planId, ctx.userId);
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "删除失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 复制 ----
|
||||
export async function duplicateLessonPlanAction(
|
||||
planId: string,
|
||||
): Promise<ActionState<{ newPlanId: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||
const { newPlanId } = await duplicateLessonPlan(planId, ctx.userId);
|
||||
revalidatePath("/teacher/lesson-plans");
|
||||
return { success: true, data: { newPlanId } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "复制失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 模板列表 ----
|
||||
export async function getLessonPlanTemplatesAction(): Promise<
|
||||
ActionState<{
|
||||
templates: Awaited<ReturnType<typeof getLessonPlanTemplates>>;
|
||||
}>
|
||||
> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_READ);
|
||||
const templates = await getLessonPlanTemplates(ctx.userId);
|
||||
return { success: true, data: { templates } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "获取模板失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 另存为模板 ----
|
||||
export async function saveAsTemplateAction(input: {
|
||||
sourcePlanId: string;
|
||||
name: string;
|
||||
}): Promise<ActionState<{ templateId: string }>> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_CREATE);
|
||||
const parsed = saveAsTemplateSchema.safeParse(input);
|
||||
if (!parsed.success)
|
||||
return { success: false, errors: parsed.error.flatten().fieldErrors };
|
||||
const { templateId } = await saveAsTemplate({
|
||||
...parsed.data,
|
||||
userId: ctx.userId,
|
||||
});
|
||||
return { success: true, data: { templateId } };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "保存模板失败" };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 删除模板 ----
|
||||
export async function deleteTemplateAction(
|
||||
templateId: string,
|
||||
): Promise<ActionState> {
|
||||
try {
|
||||
const ctx = await requirePermission(Permissions.LESSON_PLAN_DELETE);
|
||||
await deletePersonalTemplate(templateId, ctx.userId);
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
if (e instanceof PermissionDeniedError)
|
||||
return { success: false, message: e.message };
|
||||
return { success: false, message: "删除模板失败" };
|
||||
}
|
||||
}
|
||||
65
src/modules/lesson-preparation/ai-suggest.ts
Normal file
65
src/modules/lesson-preparation/ai-suggest.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import "server-only";
|
||||
|
||||
import { env } from "@/env.mjs";
|
||||
import { createAiChatCompletion } from "@/shared/lib/ai";
|
||||
import {
|
||||
getKnowledgePointsByTextbookId,
|
||||
getKnowledgePointsByChapterId,
|
||||
} from "@/modules/textbooks/data-access";
|
||||
import type { LessonPlanDocument } from "./types";
|
||||
|
||||
export async function suggestKnowledgePoints(
|
||||
doc: LessonPlanDocument,
|
||||
textbookId?: string,
|
||||
chapterId?: string,
|
||||
): Promise<{ id: string; name: string; reason: string }[]> {
|
||||
// 1. 提取课案纯文本
|
||||
const text = doc.nodes
|
||||
.map((b) => {
|
||||
const d = b.data as { html?: string; sourceText?: string };
|
||||
return d.html ?? d.sourceText ?? "";
|
||||
})
|
||||
.join("\n")
|
||||
.slice(0, 3000);
|
||||
|
||||
if (!text.trim()) return [];
|
||||
|
||||
// 2. 获取候选知识点池
|
||||
if (!textbookId) return [];
|
||||
const allKps = chapterId
|
||||
? await getKnowledgePointsByChapterId(chapterId)
|
||||
: await getKnowledgePointsByTextbookId(textbookId);
|
||||
if (allKps.length === 0) return [];
|
||||
|
||||
const kpList = allKps.map((kp) => ({ id: kp.id, name: kp.name })).slice(0, 100);
|
||||
|
||||
// 3. 调用 AI
|
||||
const prompt = `你是教学设计助手。以下是教师备课内容:
|
||||
---
|
||||
${text}
|
||||
---
|
||||
请从下列知识点中推荐最相关的 3-8 个,并说明理由。返回 JSON 数组,每项含 id/name/reason。
|
||||
候选知识点:${JSON.stringify(kpList)}`;
|
||||
|
||||
const { content } = await createAiChatCompletion({
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
model: env.AI_MODEL ?? "gpt-4o-mini",
|
||||
temperature: 0.3,
|
||||
});
|
||||
|
||||
try {
|
||||
// 尝试从返回内容中提取 JSON 数组
|
||||
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
||||
if (!jsonMatch) return [];
|
||||
const parsed = JSON.parse(jsonMatch[0]) as {
|
||||
id: string;
|
||||
name: string;
|
||||
reason: string;
|
||||
}[];
|
||||
// 过滤掉不在候选池中的 id
|
||||
const validIds = new Set(kpList.map((k) => k.id));
|
||||
return parsed.filter((p) => validIds.has(p.id));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
181
src/modules/lesson-preparation/components/block-renderer.tsx
Normal file
181
src/modules/lesson-preparation/components/block-renderer.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* @deprecated 已被 NodeEditor 替代,保留此文件用于向后兼容。
|
||||
* 列表式渲染器,使用新的 nodes API。
|
||||
*/
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
GripVertical,
|
||||
Trash2,
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
|
||||
import { RichTextBlock } from "./blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||
import type { LessonPlanNode, RichTextBlockData } from "../types";
|
||||
|
||||
interface BlockRendererProps {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
function SortableBlock({
|
||||
node,
|
||||
index,
|
||||
total,
|
||||
textbookId,
|
||||
chapterId,
|
||||
classes,
|
||||
}: {
|
||||
node: LessonPlanNode;
|
||||
index: number;
|
||||
total: number;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: node.id });
|
||||
const { updateNode, removeNode } = useLessonPlanEditor();
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="border border-outline-variant rounded-lg bg-surface-container-lowest"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-outline-variant bg-surface-container-low">
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab active:cursor-grabbing text-outline hover:text-on-surface"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
<input
|
||||
value={node.title}
|
||||
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
||||
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateNode(node.id, { order: index - 1 })}
|
||||
disabled={index === 0}
|
||||
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
|
||||
>
|
||||
<ChevronUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateNode(node.id, { order: index + 1 })}
|
||||
disabled={index === total - 1}
|
||||
className="p-1 text-outline hover:text-on-surface disabled:opacity-30"
|
||||
>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeNode(node.id)}
|
||||
className="p-1 text-error hover:text-error/80"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
{isRichText ? (
|
||||
<RichTextBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : node.type === "exercise" ? (
|
||||
<ExerciseBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
classes={classes ?? []}
|
||||
/>
|
||||
) : node.type === "text_study" ? (
|
||||
<TextStudyBlock
|
||||
blockId={node.id}
|
||||
data={node.data as never}
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-on-surface-variant text-sm p-4">
|
||||
未知 block 类型
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlockRenderer({
|
||||
textbookId,
|
||||
chapterId,
|
||||
classes,
|
||||
}: BlockRendererProps) {
|
||||
const { doc } = useLessonPlanEditor();
|
||||
|
||||
function onDragEnd(e: DragEndEvent) {
|
||||
const { active, over } = e;
|
||||
if (!over || active.id === over.id) return;
|
||||
// 拖拽排序仅更新 order 字段,实际位置由节点图管理
|
||||
const oldIndex = doc.nodes.findIndex((b) => b.id === active.id);
|
||||
const newIndex = doc.nodes.findIndex((b) => b.id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
// 交换 order
|
||||
const nodes = [...doc.nodes];
|
||||
const tmpOrder = nodes[oldIndex].order;
|
||||
nodes[oldIndex].order = nodes[newIndex].order;
|
||||
nodes[newIndex].order = tmpOrder;
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext
|
||||
items={doc.nodes.map((b) => b.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{doc.nodes.map((b, i) => (
|
||||
<SortableBlock
|
||||
key={b.id}
|
||||
node={b}
|
||||
index={i}
|
||||
total={doc.nodes.length}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
import { QuestionBankPicker } from "../question-bank-picker";
|
||||
import { InlineQuestionEditor } from "../inline-question-editor";
|
||||
import { PublishHomeworkDialog } from "../publish-homework-dialog";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type {
|
||||
ExerciseBlockData,
|
||||
ExerciseItem,
|
||||
} from "../../types";
|
||||
|
||||
interface Props {
|
||||
blockId: string;
|
||||
data: ExerciseBlockData;
|
||||
classes: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ExerciseBlock({ blockId, data, classes }: Props) {
|
||||
const { updateNode, planId } = useLessonPlanEditor();
|
||||
const [showBank, setShowBank] = useState(false);
|
||||
const [showInline, setShowInline] = useState(false);
|
||||
const [showPublish, setShowPublish] = useState(false);
|
||||
|
||||
function update(patch: Partial<ExerciseBlockData>) {
|
||||
updateNode(blockId, { data: { ...data, ...patch } });
|
||||
}
|
||||
|
||||
function addItems(items: ExerciseItem[]) {
|
||||
const next = [...data.items, ...items];
|
||||
update({
|
||||
items: next.map((it, i) => ({ ...it, order: i })),
|
||||
});
|
||||
}
|
||||
|
||||
function removeItem(idx: number) {
|
||||
update({
|
||||
items: data.items
|
||||
.filter((_, i) => i !== idx)
|
||||
.map((it, i) => ({ ...it, order: i })),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
value={data.purpose}
|
||||
onChange={(e) =>
|
||||
update({ purpose: e.target.value as never })
|
||||
}
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
>
|
||||
<option value="class_practice">课堂练习</option>
|
||||
<option value="after_class_homework">课后作业</option>
|
||||
</select>
|
||||
</div>
|
||||
{data.items.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm p-4 text-center border border-dashed rounded">
|
||||
暂无题目,点击下方按钮添加
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{data.items.map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center gap-2 border rounded p-2"
|
||||
>
|
||||
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||
{item.source === "bank" ? "题库" : "新建"}
|
||||
</span>
|
||||
<span className="text-sm flex-1 truncate">
|
||||
{item.source === "bank"
|
||||
? `题目 ${item.questionId.slice(0, 8)}`
|
||||
: "课案内新建题目"}
|
||||
</span>
|
||||
<span className="text-xs">{item.score}分</span>
|
||||
<button onClick={() => removeItem(idx)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBank(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
从题库添加
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowInline(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
新建题目
|
||||
</Button>
|
||||
{data.publishedAssignmentId ? (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="bg-tertiary-container/20 text-tertiary px-2 py-1 rounded">
|
||||
已发布为作业
|
||||
</span>
|
||||
<a
|
||||
href="/teacher/homework"
|
||||
className="text-primary underline"
|
||||
>
|
||||
查看
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
data.purpose === "after_class_homework" &&
|
||||
data.items.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowPublish(true)}
|
||||
>
|
||||
发布为作业
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{showBank && (
|
||||
<QuestionBankPicker
|
||||
existingIds={data.items.map((i) => i.questionId)}
|
||||
onPick={addItems}
|
||||
onClose={() => setShowBank(false)}
|
||||
/>
|
||||
)}
|
||||
{showInline && (
|
||||
<InlineQuestionEditor
|
||||
onAdd={(item) => {
|
||||
addItems([item]);
|
||||
setShowInline(false);
|
||||
}}
|
||||
onClose={() => setShowInline(false)}
|
||||
/>
|
||||
)}
|
||||
{showPublish && (
|
||||
<PublishHomeworkDialog
|
||||
planId={planId}
|
||||
blockId={blockId}
|
||||
classes={classes}
|
||||
onClose={() => setShowPublish(false)}
|
||||
onPublished={() => window.location.reload()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { RichTextBlock } from "./rich-text-block";
|
||||
import type { RichTextBlockData } from "../../types";
|
||||
|
||||
interface Props {
|
||||
data: RichTextBlockData;
|
||||
onUpdate: (data: RichTextBlockData) => void;
|
||||
}
|
||||
|
||||
export function ReflectionBlock(props: Props) {
|
||||
// 教学反思在 P1 阶段与普通富文本一致,P3 再扩展学情数据嵌入
|
||||
return <RichTextBlock {...props} hint="课后填写教学反思..." />;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { RichTextBlockData } from "../../types";
|
||||
import { KnowledgePointPicker } from "../knowledge-point-picker";
|
||||
import { Tag } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
data: RichTextBlockData;
|
||||
hint?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
onUpdate: (data: RichTextBlockData) => void;
|
||||
}
|
||||
|
||||
export function RichTextBlock({
|
||||
data,
|
||||
hint,
|
||||
textbookId,
|
||||
chapterId,
|
||||
onUpdate,
|
||||
}: Props) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder: hint ?? "输入内容..." }),
|
||||
],
|
||||
content: data.html,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
onUpdate({ ...data, html: editor.getHTML() });
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none min-h-[60px] px-3 py-2",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 外部 content 变化时同步(如版本回退)
|
||||
useEffect(() => {
|
||||
if (editor && !editor.isDestroyed && data.html !== editor.getHTML()) {
|
||||
editor.commands.setContent(data.html);
|
||||
}
|
||||
}, [data.html, editor]);
|
||||
|
||||
const [showKpPicker, setShowKpPicker] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<EditorContent editor={editor} />
|
||||
<div className="flex items-center gap-2 mt-2 px-3 flex-wrap">
|
||||
{data.knowledgePointIds.length > 0 && (
|
||||
<span className="text-xs text-on-surface-variant">
|
||||
已关联 {data.knowledgePointIds.length} 个知识点
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowKpPicker(true)}
|
||||
className="text-xs text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
<Tag className="w-3 h-3" />
|
||||
标注知识点
|
||||
</button>
|
||||
</div>
|
||||
{showKpPicker && (
|
||||
<KnowledgePointPicker
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
selectedIds={data.knowledgePointIds}
|
||||
onChange={(ids) => onUpdate({ ...data, knowledgePointIds: ids })}
|
||||
onClose={() => setShowKpPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useLessonPlanEditor } from "../../hooks/use-lesson-plan-editor";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type {
|
||||
TextStudyBlockData,
|
||||
TextStudyAnnotation,
|
||||
} from "../../types";
|
||||
|
||||
interface Props {
|
||||
blockId: string;
|
||||
data: TextStudyBlockData;
|
||||
}
|
||||
|
||||
export function TextStudyBlock({ blockId, data }: Props) {
|
||||
const { updateNode } = useLessonPlanEditor();
|
||||
const [selection, setSelection] = useState<{
|
||||
start: number;
|
||||
end: number;
|
||||
} | null>(null);
|
||||
|
||||
function update(patch: Partial<TextStudyBlockData>) {
|
||||
updateNode(blockId, { data: { ...data, ...patch } });
|
||||
}
|
||||
|
||||
function handleTextSelect() {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.rangeCount === 0) return;
|
||||
const range = sel.getRangeAt(0);
|
||||
// 简化:用相对 sourceText 的字符偏移
|
||||
const start = range.startOffset;
|
||||
const end = range.endOffset;
|
||||
if (end > start) setSelection({ start, end });
|
||||
}
|
||||
|
||||
function addAnnotation() {
|
||||
if (!selection) {
|
||||
alert("请先在课文中选中一段文本");
|
||||
return;
|
||||
}
|
||||
const ann: TextStudyAnnotation = {
|
||||
id: createId(),
|
||||
anchor: selection,
|
||||
nodeType: "language_feature",
|
||||
title: "教学节点",
|
||||
note: "",
|
||||
color: "yellow",
|
||||
};
|
||||
update({ annotations: [...data.annotations, ann] });
|
||||
setSelection(null);
|
||||
}
|
||||
|
||||
function removeAnnotation(id: string) {
|
||||
update({
|
||||
annotations: data.annotations.filter((a) => a.id !== id),
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">课文原文</label>
|
||||
<textarea
|
||||
value={data.sourceText}
|
||||
onChange={(e) => update({ sourceText: e.target.value })}
|
||||
onMouseUp={handleTextSelect}
|
||||
className="w-full border rounded p-2 mt-1 min-h-[120px] font-serif leading-loose"
|
||||
placeholder="粘贴课文原文,选中文本后可添加教学节点"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addAnnotation}
|
||||
disabled={!selection}
|
||||
>
|
||||
<Plus className="w-3 h-3 mr-1" />
|
||||
为选中文本添加节点
|
||||
</Button>
|
||||
{data.annotations.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{data.annotations.map((ann) => (
|
||||
<div
|
||||
key={ann.id}
|
||||
className="border-l-4 border-secondary-container pl-3 py-1"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<input
|
||||
value={ann.title}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
annotations: data.annotations.map((a) =>
|
||||
a.id === ann.id
|
||||
? { ...a, title: e.target.value }
|
||||
: a,
|
||||
),
|
||||
})
|
||||
}
|
||||
className="font-medium text-sm bg-transparent flex-1"
|
||||
/>
|
||||
<button onClick={() => removeAnnotation(ann.id)}>
|
||||
<Trash2 className="w-3 h-3 text-error" />
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
value={ann.note}
|
||||
onChange={(e) =>
|
||||
update({
|
||||
annotations: data.annotations.map((a) =>
|
||||
a.id === ann.id
|
||||
? { ...a, note: e.target.value }
|
||||
: a,
|
||||
),
|
||||
})
|
||||
}
|
||||
className="w-full text-sm border rounded p-1 mt-1 min-h-[40px]"
|
||||
placeholder="教学说明..."
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import type { ExerciseItem, InlineQuestionContent } from "../types";
|
||||
|
||||
interface Props {
|
||||
onAdd: (item: ExerciseItem) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function InlineQuestionEditor({ onAdd, onClose }: Props) {
|
||||
const [type, setType] = useState<
|
||||
"single_choice" | "text" | "judgment"
|
||||
>("single_choice");
|
||||
const [difficulty, setDifficulty] = useState(3);
|
||||
const [text, setText] = useState("");
|
||||
const [options, setOptions] = useState<string[]>(["", ""]);
|
||||
const [correctIdx, setCorrectIdx] = useState(0);
|
||||
const kpIds: string[] = [];
|
||||
|
||||
function handleAdd() {
|
||||
if (!text.trim()) {
|
||||
alert("请输入题干");
|
||||
return;
|
||||
}
|
||||
const content: Record<string, unknown> =
|
||||
type === "single_choice"
|
||||
? {
|
||||
text,
|
||||
options: options.map((o, i) => ({
|
||||
id: String(i),
|
||||
text: o,
|
||||
isCorrect: i === correctIdx,
|
||||
})),
|
||||
}
|
||||
: type === "judgment"
|
||||
? { text, correctAnswer: correctIdx === 0 }
|
||||
: { text };
|
||||
const inlineContent: InlineQuestionContent = {
|
||||
content,
|
||||
type,
|
||||
difficulty,
|
||||
knowledgePointIds: kpIds,
|
||||
};
|
||||
const item: ExerciseItem = {
|
||||
questionId: `inline_draft_${createId()}`,
|
||||
source: "inline",
|
||||
score: 5,
|
||||
order: 0,
|
||||
inlineContent,
|
||||
};
|
||||
onAdd(item);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[600px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">新建题目(课案内)</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">题型</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as never)}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
>
|
||||
<option value="single_choice">单选题</option>
|
||||
<option value="text">填空题</option>
|
||||
<option value="judgment">判断题</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">题干</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1 min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
{type === "single_choice" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
选项(勾选正确答案)
|
||||
</label>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-2 mt-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={correctIdx === i}
|
||||
onChange={() => setCorrectIdx(i)}
|
||||
/>
|
||||
<input
|
||||
value={opt}
|
||||
onChange={(e) =>
|
||||
setOptions(
|
||||
options.map((o, j) =>
|
||||
j === i ? e.target.value : o,
|
||||
),
|
||||
)
|
||||
}
|
||||
className="flex-1 border rounded px-2 py-1"
|
||||
/>
|
||||
{options.length > 2 && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setOptions(options.filter((_, j) => j !== i))
|
||||
}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{options.length < 6 && (
|
||||
<button
|
||||
onClick={() => setOptions([...options, ""])}
|
||||
className="text-sm text-primary mt-1"
|
||||
>
|
||||
+ 添加选项
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{type === "judgment" && (
|
||||
<div>
|
||||
<label className="text-sm font-medium">正确答案</label>
|
||||
<div className="flex gap-3 mt-1">
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={correctIdx === 0}
|
||||
onChange={() => setCorrectIdx(0)}
|
||||
/>
|
||||
正确
|
||||
</label>
|
||||
<label className="flex items-center gap-1">
|
||||
<input
|
||||
type="radio"
|
||||
checked={correctIdx === 1}
|
||||
onChange={() => setCorrectIdx(1)}
|
||||
/>
|
||||
错误
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="text-sm font-medium">难度</label>
|
||||
<select
|
||||
value={difficulty}
|
||||
onChange={(e) => setDifficulty(Number(e.target.value))}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((d) => (
|
||||
<option key={d} value={d}>
|
||||
{d}星
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleAdd}>添加</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
import { getKnowledgePointOptionsAction } from "../actions-kp";
|
||||
|
||||
interface KpOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
selectedIds: string[];
|
||||
onChange: (ids: string[]) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function KnowledgePointPicker({
|
||||
textbookId,
|
||||
chapterId,
|
||||
selectedIds,
|
||||
onChange,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const [options, setOptions] = useState<KpOption[]>([]);
|
||||
const [local, setLocal] = useState<string[]>(selectedIds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!textbookId) {
|
||||
return;
|
||||
}
|
||||
getKnowledgePointOptionsAction({ textbookId, chapterId }).then((res) => {
|
||||
if (res.success && res.data) setOptions(res.data.options);
|
||||
});
|
||||
}, [textbookId, chapterId]);
|
||||
|
||||
function toggle(id: string) {
|
||||
setLocal((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96 max-h-[70vh] flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 border-b border-outline-variant">
|
||||
<h3 className="font-title-md">选择知识点</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{options.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-sm">
|
||||
未找到知识点,请先在教材模块创建
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{options.map((kp) => (
|
||||
<label
|
||||
key={kp.id}
|
||||
className="flex items-center gap-2 p-2 hover:bg-surface-container-highest rounded cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={local.includes(kp.id)}
|
||||
onChange={() => toggle(kp.id)}
|
||||
/>
|
||||
<span className="text-sm">{kp.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-outline-variant flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onChange(local);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { LESSON_PLAN_STATUS_LABELS } from "../constants";
|
||||
import { duplicateLessonPlanAction, deleteLessonPlanAction } from "../actions";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
export function LessonPlanCard({ plan }: { plan: LessonPlanListItem }) {
|
||||
return (
|
||||
<div className="border border-outline-variant rounded-lg p-4 bg-surface-container-lowest hover:shadow-md transition-shadow">
|
||||
<Link
|
||||
href={`/teacher/lesson-plans/${plan.id}/edit`}
|
||||
className="block"
|
||||
>
|
||||
<h3 className="font-title-md text-title-md hover:text-primary">
|
||||
{plan.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<div className="text-sm text-on-surface-variant mt-1">
|
||||
{plan.textbookTitle ?? "无教材"} · {plan.chapterTitle ?? "无章节"}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-1">
|
||||
{plan.templateName ?? "无模板"} ·{" "}
|
||||
{LESSON_PLAN_STATUS_LABELS[plan.status]}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-2">
|
||||
最后保存:
|
||||
{plan.lastSavedAt
|
||||
? new Date(plan.lastSavedAt).toLocaleString()
|
||||
: "未保存"}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const res = await duplicateLessonPlanAction(plan.id);
|
||||
if (res.success) window.location.reload();
|
||||
}}
|
||||
>
|
||||
复制
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
if (!confirm("确认归档此课案?")) return;
|
||||
const res = await deleteLessonPlanAction(plan.id);
|
||||
if (res.success) window.location.reload();
|
||||
}}
|
||||
>
|
||||
归档
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/modules/lesson-preparation/components/lesson-plan-editor.tsx
Normal file
234
src/modules/lesson-preparation/components/lesson-plan-editor.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { NodeEditor } from "./node-editor";
|
||||
import { NodeEditPanel } from "./node-edit-panel";
|
||||
import { VersionHistoryDrawer } from "./version-history-drawer";
|
||||
import {
|
||||
updateLessonPlanAction,
|
||||
saveLessonPlanVersionAction,
|
||||
getLessonPlanByIdAction,
|
||||
} from "../actions";
|
||||
import { BLOCK_TYPE_LABELS } from "../constants";
|
||||
import type { BlockType } from "../types";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Plus, Save, History } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
initialTitle: string;
|
||||
initialDoc: import("../types").LessonPlanDocument;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const BLOCK_TYPES_TO_ADD: BlockType[] = [
|
||||
"objective",
|
||||
"key_point",
|
||||
"import",
|
||||
"new_teaching",
|
||||
"consolidation",
|
||||
"summary",
|
||||
"homework",
|
||||
"blackboard",
|
||||
"exercise",
|
||||
"text_study",
|
||||
"rich_text",
|
||||
"reflection",
|
||||
];
|
||||
|
||||
export function LessonPlanEditor({
|
||||
planId,
|
||||
initialTitle,
|
||||
initialDoc,
|
||||
textbookId,
|
||||
chapterId,
|
||||
classes,
|
||||
}: Props) {
|
||||
const editor = useLessonPlanEditor();
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showAddMenu, setShowAddMenu] = useState(false);
|
||||
const [panelOpen, setPanelOpen] = useState(false);
|
||||
const autoSaveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const versionTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const addMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 初始化:仅在 planId 变化时 hydrate(修复 P1-3)
|
||||
const initKey = planId;
|
||||
useEffect(() => {
|
||||
useLessonPlanEditor.getState().hydrate(planId, initialTitle, initialDoc);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [initKey]);
|
||||
|
||||
// 选中节点时打开侧边面板
|
||||
useEffect(() => {
|
||||
if (editor.selectedNodeId) setPanelOpen(true);
|
||||
}, [editor.selectedNodeId]);
|
||||
|
||||
// 自动保存(debounce 3s)- 用 getState() 获取最新值(修复 P1-4)
|
||||
useEffect(() => {
|
||||
if (!editor.isDirty) return;
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
autoSaveTimer.current = setTimeout(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
const res = await updateLessonPlanAction({
|
||||
planId: state.planId,
|
||||
title: state.title,
|
||||
content: state.doc,
|
||||
});
|
||||
state.setSaving(false);
|
||||
if (res.success) state.markSaved();
|
||||
}, 3000);
|
||||
return () => {
|
||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||
};
|
||||
}, [editor.isDirty, editor.doc, planId]);
|
||||
|
||||
// 定时自动版本(30min)
|
||||
useEffect(() => {
|
||||
versionTimer.current = setInterval(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
if (!state.isDirty) return;
|
||||
await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
label: "自动版本",
|
||||
});
|
||||
}, 30 * 60 * 1000);
|
||||
return () => {
|
||||
if (versionTimer.current) clearInterval(versionTimer.current);
|
||||
};
|
||||
}, [planId]);
|
||||
|
||||
// 离开未保存提示(P3-1)
|
||||
useEffect(() => {
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (useLessonPlanEditor.getState().isDirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
}
|
||||
}
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
}, []);
|
||||
|
||||
// 添加节点菜单点击外部关闭(P3-2)
|
||||
useEffect(() => {
|
||||
if (!showAddMenu) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (addMenuRef.current && !addMenuRef.current.contains(e.target as Node)) {
|
||||
setShowAddMenu(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [showAddMenu]);
|
||||
|
||||
const handleManualSave = useCallback(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
state.setSaving(true);
|
||||
const res = await saveLessonPlanVersionAction({
|
||||
planId: state.planId,
|
||||
content: state.doc,
|
||||
});
|
||||
state.setSaving(false);
|
||||
if (res.success) state.markSaved();
|
||||
}, []);
|
||||
|
||||
// 版本回退后刷新内容(修复 P1-1)
|
||||
const handleReverted = useCallback(async () => {
|
||||
const state = useLessonPlanEditor.getState();
|
||||
const res = await getLessonPlanByIdAction(state.planId);
|
||||
if (res.success && res.data?.plan) {
|
||||
state.hydrate(state.planId, res.data.plan.title, res.data.plan.content);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* 顶部工具栏 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant bg-surface">
|
||||
<input
|
||||
value={editor.title}
|
||||
onChange={(e) => editor.setTitle(e.target.value)}
|
||||
className="flex-1 bg-transparent font-headline-md text-headline-md focus:outline-none"
|
||||
/>
|
||||
<span className="text-on-surface-variant text-sm">
|
||||
{editor.isSaving
|
||||
? "保存中..."
|
||||
: editor.isDirty
|
||||
? "未保存"
|
||||
: "已保存"}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowVersions(true)}
|
||||
>
|
||||
<History className="w-4 h-4 mr-1" /> 版本
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleManualSave} disabled={editor.isSaving}>
|
||||
<Save className="w-4 h-4 mr-1" /> 保存版本
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 主区域:画布 + 侧边面板 */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 节点画布 */}
|
||||
<div className="flex-1 relative">
|
||||
<NodeEditor
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
{/* 添加节点浮动按钮 */}
|
||||
<div className="absolute bottom-4 left-4 z-10" ref={addMenuRef}>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setShowAddMenu(!showAddMenu)}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加节点
|
||||
</Button>
|
||||
{showAddMenu && (
|
||||
<div className="absolute bottom-12 left-0 bg-surface border border-outline-variant rounded-lg shadow-lg p-2 grid grid-cols-2 gap-1 w-72 max-h-[60vh] overflow-y-auto">
|
||||
{BLOCK_TYPES_TO_ADD.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => {
|
||||
editor.addNode(t);
|
||||
setShowAddMenu(false);
|
||||
}}
|
||||
className="text-left px-2 py-1 text-sm hover:bg-surface-container-highest rounded"
|
||||
>
|
||||
{BLOCK_TYPE_LABELS[t]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 侧边内容编辑面板 */}
|
||||
{panelOpen && editor.selectedNodeId && (
|
||||
<div className="w-[420px] flex-shrink-0">
|
||||
<NodeEditPanel
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
classes={classes}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<VersionHistoryDrawer
|
||||
open={showVersions}
|
||||
onClose={() => setShowVersions(false)}
|
||||
planId={planId}
|
||||
onReverted={handleReverted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce";
|
||||
|
||||
interface Props {
|
||||
onFilter: (params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) => void;
|
||||
subjects: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function LessonPlanFilters({ onFilter, subjects }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [subjectId, setSubjectId] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
// 修复 P1-6:搜索 debounce 300ms
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
|
||||
useEffect(() => {
|
||||
onFilter({
|
||||
query: debouncedQuery || undefined,
|
||||
subjectId: subjectId || undefined,
|
||||
status: status || undefined,
|
||||
});
|
||||
}, [debouncedQuery, subjectId, status, onFilter]);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<input
|
||||
placeholder="搜索标题..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
/>
|
||||
<select
|
||||
value={subjectId}
|
||||
onChange={(e) => setSubjectId(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">全部学科</option>
|
||||
{subjects.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="border border-outline-variant rounded-lg px-3 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { LessonPlanCard } from "./lesson-plan-card";
|
||||
import { LessonPlanFilters } from "./lesson-plan-filters";
|
||||
import { getLessonPlansAction } from "../actions";
|
||||
import type { LessonPlanListItem } from "../types";
|
||||
|
||||
interface Props {
|
||||
initialItems: LessonPlanListItem[];
|
||||
subjects: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function LessonPlanList({ initialItems, subjects }: Props) {
|
||||
const [items, setItems] = useState(initialItems);
|
||||
|
||||
async function handleFilter(params: {
|
||||
query?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
}) {
|
||||
const res = await getLessonPlansAction(params);
|
||||
if (res.success && res.data) setItems(res.data.items);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LessonPlanFilters onFilter={handleFilter} subjects={subjects} />
|
||||
{items.length === 0 ? (
|
||||
<p className="text-on-surface-variant text-center py-12">
|
||||
暂无课案,点击“新建课案”开始
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{items.map((p) => (
|
||||
<LessonPlanCard key={p.id} plan={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/modules/lesson-preparation/components/node-edit-panel.tsx
Normal file
103
src/modules/lesson-preparation/components/node-edit-panel.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { RICH_TEXT_BLOCK_TYPES } from "../constants";
|
||||
import { RichTextBlock } from "./blocks/rich-text-block";
|
||||
import { ExerciseBlock } from "./blocks/exercise-block";
|
||||
import { TextStudyBlock } from "./blocks/text-study-block";
|
||||
import { ReflectionBlock } from "./blocks/reflection-block";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { Trash2, X } from "lucide-react";
|
||||
import type {
|
||||
ExerciseBlockData,
|
||||
RichTextBlockData,
|
||||
TextStudyBlockData,
|
||||
} from "../types";
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function NodeEditPanel({ textbookId, chapterId, classes }: Props) {
|
||||
const { doc, selectedNodeId, updateNode, removeNode, selectNode } =
|
||||
useLessonPlanEditor();
|
||||
|
||||
const node = doc.nodes.find((n) => n.id === selectedNodeId);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-on-surface-variant text-sm p-4">
|
||||
点击节点编辑内容,或拖拽连线建立流程
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isRichText = RICH_TEXT_BLOCK_TYPES.includes(node.type);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col border-l border-outline-variant bg-surface">
|
||||
{/* 面板头部 */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-outline-variant">
|
||||
<input
|
||||
value={node.title}
|
||||
onChange={(e) => updateNode(node.id, { title: e.target.value })}
|
||||
className="flex-1 bg-transparent font-title-md text-title-md focus:outline-none"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => selectNode(null)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 内容编辑区 */}
|
||||
<div className="flex-1 overflow-y-auto p-3">
|
||||
{isRichText ? (
|
||||
<RichTextBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
textbookId={textbookId}
|
||||
chapterId={chapterId}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : node.type === "exercise" ? (
|
||||
<ExerciseBlock
|
||||
blockId={node.id}
|
||||
data={node.data as ExerciseBlockData}
|
||||
classes={classes ?? []}
|
||||
/>
|
||||
) : node.type === "text_study" ? (
|
||||
<TextStudyBlock
|
||||
blockId={node.id}
|
||||
data={node.data as TextStudyBlockData}
|
||||
/>
|
||||
) : node.type === "reflection" ? (
|
||||
<ReflectionBlock
|
||||
data={node.data as RichTextBlockData}
|
||||
onUpdate={(d) => updateNode(node.id, { data: d })}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-on-surface-variant text-sm p-4">
|
||||
未知节点类型
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部操作 */}
|
||||
<div className="px-4 py-2 border-t border-outline-variant">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-error"
|
||||
onClick={() => removeNode(node.id)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
删除此节点
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
155
src/modules/lesson-preparation/components/node-editor.tsx
Normal file
155
src/modules/lesson-preparation/components/node-editor.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
type Connection,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
BackgroundVariant,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { useLessonPlanEditor } from "../hooks/use-lesson-plan-editor";
|
||||
import { LessonNode } from "./nodes/lesson-node";
|
||||
import type { LessonPlanNode } from "../types";
|
||||
|
||||
const nodeTypes = { lesson: LessonNode };
|
||||
|
||||
interface Props {
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
classes?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function NodeEditor({ textbookId, chapterId, classes }: Props) {
|
||||
const { doc, selectedNodeId, updateNodePosition, removeNode, connect, selectNode, setEdges } =
|
||||
useLessonPlanEditor();
|
||||
|
||||
// 我们的 nodes → React Flow nodes
|
||||
const rfNodes: Node[] = useMemo(
|
||||
() =>
|
||||
doc.nodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: "lesson",
|
||||
position: n.position,
|
||||
data: n as unknown as Record<string, unknown>,
|
||||
selected: n.id === selectedNodeId,
|
||||
})),
|
||||
[doc.nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
// edges 直接兼容
|
||||
const rfEdges: Edge[] = useMemo(
|
||||
() =>
|
||||
doc.edges.map((e) => ({
|
||||
...e,
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
})),
|
||||
[doc.edges],
|
||||
);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === "position" && change.position) {
|
||||
updateNodePosition(change.id, change.position);
|
||||
} else if (change.type === "remove") {
|
||||
removeNode(change.id);
|
||||
} else if (change.type === "select") {
|
||||
selectNode(change.selected ? change.id : null);
|
||||
}
|
||||
});
|
||||
// applyNodeChanges 用于内部状态同步,但我们用 zustand 管理,这里不需要
|
||||
void applyNodeChanges;
|
||||
},
|
||||
[updateNodePosition, removeNode, selectNode],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(conn: Connection) => {
|
||||
if (conn.source && conn.target) {
|
||||
connect(conn.source, conn.target);
|
||||
}
|
||||
},
|
||||
[connect],
|
||||
);
|
||||
|
||||
// 同步 edges 变化(如拖拽重连)
|
||||
const onEdgesChangeSync = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
// 简单处理:删除时调用 disconnect
|
||||
const nextEdges = applyEdgeChanges(changes, rfEdges);
|
||||
const ourEdges = nextEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle ?? null,
|
||||
targetHandle: e.targetHandle ?? null,
|
||||
}));
|
||||
setEdges(ourEdges);
|
||||
},
|
||||
[rfEdges, setEdges],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChangeSync}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={(_, node) => selectNode(node.id)}
|
||||
onPaneClick={() => selectNode(null)}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2, maxZoom: 1.2 }}
|
||||
defaultEdgeOptions={{
|
||||
animated: true,
|
||||
style: { stroke: "#1976d2", strokeWidth: 2 },
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
className="bg-surface-container-low"
|
||||
>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={20}
|
||||
size={1}
|
||||
color="#ccc"
|
||||
/>
|
||||
<Controls className="!bg-surface !border-outline-variant" />
|
||||
<MiniMap
|
||||
className="!bg-surface !border-outline-variant"
|
||||
nodeColor={(n) => {
|
||||
const data = n.data as unknown as LessonPlanNode;
|
||||
const colors: Record<string, string> = {
|
||||
objective: "#4caf50",
|
||||
key_point: "#f44336",
|
||||
import: "#2196f3",
|
||||
new_teaching: "#9c27b0",
|
||||
consolidation: "#ff9800",
|
||||
summary: "#607d8b",
|
||||
homework: "#795548",
|
||||
blackboard: "#009688",
|
||||
text_study: "#3f51b5",
|
||||
exercise: "#e91e63",
|
||||
rich_text: "#9e9e9e",
|
||||
reflection: "#cddc39",
|
||||
};
|
||||
return colors[data.type] ?? "#9e9e9e";
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
{/* 隐藏的 props 传递,避免 unused 警告 */}
|
||||
<span className="hidden" data-textbook={textbookId} data-chapter={chapterId} data-classes={classes?.length} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
||||
import { BLOCK_TYPE_LABELS } from "../../constants";
|
||||
import type { LessonPlanNode } from "../../types";
|
||||
|
||||
// 节点类型 → 图标颜色(Material Design 色板)
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
objective: "#4caf50",
|
||||
key_point: "#f44336",
|
||||
import: "#2196f3",
|
||||
new_teaching: "#9c27b0",
|
||||
consolidation: "#ff9800",
|
||||
summary: "#607d8b",
|
||||
homework: "#795548",
|
||||
blackboard: "#009688",
|
||||
text_study: "#3f51b5",
|
||||
exercise: "#e91e63",
|
||||
rich_text: "#9e9e9e",
|
||||
reflection: "#cddc39",
|
||||
};
|
||||
|
||||
function getNodeSummary(node: LessonPlanNode): string {
|
||||
const data = node.data as {
|
||||
html?: string;
|
||||
sourceText?: string;
|
||||
items?: unknown[];
|
||||
knowledgePointIds?: string[];
|
||||
};
|
||||
if (data.items !== undefined) {
|
||||
return `${data.items.length} 道题`;
|
||||
}
|
||||
if (data.sourceText !== undefined && data.sourceText) {
|
||||
return `${data.sourceText.length} 字`;
|
||||
}
|
||||
if (data.html) {
|
||||
// 去标签后取前 40 字
|
||||
const text = data.html.replace(/<[^>]+>/g, "").trim();
|
||||
return text.slice(0, 40) || "空";
|
||||
}
|
||||
return "空";
|
||||
}
|
||||
|
||||
export const LessonNode = memo(function LessonNode({
|
||||
data,
|
||||
selected,
|
||||
}: NodeProps) {
|
||||
const nodeData = data as unknown as LessonPlanNode;
|
||||
const color = NODE_COLORS[nodeData.type] ?? "#9e9e9e";
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 bg-surface shadow-md min-w-[200px] max-w-[260px] transition-shadow"
|
||||
style={{
|
||||
borderColor: selected ? "#1976d2" : color,
|
||||
boxShadow: selected ? "0 0 0 2px rgba(25,118,210,0.3)" : undefined,
|
||||
}}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
|
||||
/>
|
||||
<div
|
||||
className="px-3 py-2 rounded-t-md text-white text-xs font-medium flex items-center gap-1"
|
||||
style={{ backgroundColor: color }}
|
||||
>
|
||||
<span>{BLOCK_TYPE_LABELS[nodeData.type] ?? nodeData.type}</span>
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-sm font-medium text-on-surface truncate">
|
||||
{nodeData.title}
|
||||
</div>
|
||||
<div className="text-xs text-on-surface-variant mt-1">
|
||||
{getNodeSummary(nodeData)}
|
||||
</div>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className="!bg-on-surface !w-3 !h-3 !border-2 !border-surface"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { publishLessonPlanHomeworkAction } from "../actions-publish";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
planId: string;
|
||||
blockId: string;
|
||||
classes: { id: string; name: string }[];
|
||||
onClose: () => void;
|
||||
onPublished: () => void;
|
||||
}
|
||||
|
||||
export function PublishHomeworkDialog({
|
||||
planId,
|
||||
blockId,
|
||||
classes,
|
||||
onClose,
|
||||
onPublished,
|
||||
}: Props) {
|
||||
const [selectedClasses, setSelectedClasses] = useState<string[]>([]);
|
||||
const [availableAt, setAvailableAt] = useState("");
|
||||
const [dueAt, setDueAt] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handlePublish() {
|
||||
if (selectedClasses.length === 0) {
|
||||
setError("请选择至少一个班级");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await publishLessonPlanHomeworkAction({
|
||||
planId,
|
||||
blockId,
|
||||
classIds: selectedClasses,
|
||||
availableAt: availableAt || undefined,
|
||||
dueAt: dueAt || undefined,
|
||||
});
|
||||
setLoading(false);
|
||||
if (res.success) {
|
||||
onPublished();
|
||||
onClose();
|
||||
} else {
|
||||
setError(res.message ?? "发布失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-96">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">发布为作业</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium">下发班级</label>
|
||||
<div className="mt-1 space-y-1 max-h-40 overflow-y-auto">
|
||||
{classes.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedClasses.includes(c.id)}
|
||||
onChange={() =>
|
||||
setSelectedClasses(
|
||||
selectedClasses.includes(c.id)
|
||||
? selectedClasses.filter((x) => x !== c.id)
|
||||
: [...selectedClasses, c.id],
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm">{c.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
开始时间(可选)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={availableAt}
|
||||
onChange={(e) => setAvailableAt(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
截止时间(可选)
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={dueAt}
|
||||
onChange={(e) => setDueAt(e.target.value)}
|
||||
className="w-full border rounded px-2 py-1 mt-1"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handlePublish} disabled={loading}>
|
||||
{loading ? "发布中..." : "发布"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { getQuestionsAction } from "@/modules/questions/actions"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { useDebounce } from "@/shared/hooks/use-debounce"
|
||||
import { X } from "lucide-react"
|
||||
import { QuestionBankFilters } from "@/shared/components/question/question-bank-filters"
|
||||
import type { ExerciseItem } from "../types"
|
||||
import type { QuestionType } from "@/modules/questions/types"
|
||||
|
||||
interface QuestionRow {
|
||||
id: string
|
||||
type: string
|
||||
difficulty: number
|
||||
content: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
onPick: (items: ExerciseItem[]) => void
|
||||
onClose: () => void
|
||||
existingIds: string[]
|
||||
}
|
||||
|
||||
export function QuestionBankPicker({ onPick, onClose, existingIds }: Props) {
|
||||
const [questions, setQuestions] = useState<QuestionRow[]>([])
|
||||
const [picked, setPicked] = useState<ExerciseItem[]>([])
|
||||
|
||||
// QuestionBankFilters 使用字符串值,这里转换为 filters 对象
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
const [typeValue, setTypeValue] = useState<string>("all")
|
||||
const [difficultyValue, setDifficultyValue] = useState<string>("all")
|
||||
|
||||
const filters = useMemo<{
|
||||
q?: string
|
||||
type?: QuestionType
|
||||
difficulty?: number
|
||||
}>(() => {
|
||||
const newFilters: {
|
||||
q?: string
|
||||
type?: QuestionType
|
||||
difficulty?: number
|
||||
} = {}
|
||||
if (searchValue) newFilters.q = searchValue
|
||||
if (typeValue !== "all") newFilters.type = typeValue as QuestionType
|
||||
if (difficultyValue !== "all") newFilters.difficulty = Number(difficultyValue)
|
||||
return newFilters
|
||||
}, [searchValue, typeValue, difficultyValue])
|
||||
|
||||
// 修复 P1-5:搜索 debounce 300ms
|
||||
const debouncedFilters = useDebounce(filters, 300)
|
||||
|
||||
useEffect(() => {
|
||||
getQuestionsAction(debouncedFilters).then((res) => {
|
||||
if (res.success && res.data) {
|
||||
const data = res.data.data
|
||||
setQuestions(
|
||||
data.map((q) => ({
|
||||
id: q.id,
|
||||
type: q.type,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content,
|
||||
})),
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [debouncedFilters])
|
||||
|
||||
function add(q: QuestionRow) {
|
||||
if (existingIds.includes(q.id) || picked.some((p) => p.questionId === q.id)) return
|
||||
setPicked((prev) => [
|
||||
...prev,
|
||||
{
|
||||
questionId: q.id,
|
||||
source: "bank",
|
||||
score: 5,
|
||||
order: prev.length,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
function previewText(content: unknown): string {
|
||||
if (typeof content === "string") return content.slice(0, 80)
|
||||
try {
|
||||
return JSON.stringify(content).slice(0, 80)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
|
||||
<div className="bg-surface rounded-lg shadow-xl w-[700px] max-h-[80vh] flex flex-col">
|
||||
<div className="flex justify-between items-center p-4 border-b">
|
||||
<h3 className="font-title-md">从题库选择题目</h3>
|
||||
<button onClick={onClose}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 border-b">
|
||||
<QuestionBankFilters
|
||||
search={searchValue}
|
||||
onSearchChange={setSearchValue}
|
||||
type={typeValue}
|
||||
onTypeChange={setTypeValue}
|
||||
difficulty={difficultyValue}
|
||||
onDifficultyChange={setDifficultyValue}
|
||||
layout="default"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-2">
|
||||
{questions.map((q) => (
|
||||
<div
|
||||
key={q.id}
|
||||
className="border rounded p-2 flex justify-between items-center"
|
||||
>
|
||||
<span className="text-sm truncate flex-1 mr-2">{previewText(q.content)}</span>
|
||||
<span className="text-xs text-on-surface-variant mr-2">
|
||||
{q.type} · {q.difficulty}星
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={() => add(q)}>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-between">
|
||||
<span className="text-sm">已选 {picked.length} 题</span>
|
||||
<Button
|
||||
onClick={() => {
|
||||
onPick(picked)
|
||||
onClose()
|
||||
}}
|
||||
>
|
||||
插入
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createLessonPlanAction } from "../actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import { SYSTEM_TEMPLATES } from "../constants";
|
||||
|
||||
export function TemplatePicker() {
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<string>("");
|
||||
const [title, setTitle] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(formData: FormData) {
|
||||
setError(null);
|
||||
formData.set("templateId", selected);
|
||||
formData.set("title", title);
|
||||
const res = await createLessonPlanAction(null, formData);
|
||||
if (res.success && res.data) {
|
||||
router.push(`/teacher/lesson-plans/${res.data.planId}/edit`);
|
||||
} else {
|
||||
setError(res.message ?? "创建失败");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={handleSubmit} className="max-w-3xl mx-auto p-6 space-y-6">
|
||||
<div>
|
||||
<label className="font-title-md block mb-2">课案标题</label>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
className="w-full border border-outline-variant rounded-lg px-3 py-2"
|
||||
placeholder="例如:《秋天》第一课时"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-title-md block mb-2">选择模板</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{SYSTEM_TEMPLATES.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => setSelected(t.id)}
|
||||
className={`text-left p-4 border-2 rounded-lg transition-colors ${
|
||||
selected === t.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-outline-variant hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="font-title-md">{t.name}</div>
|
||||
<div className="text-sm text-on-surface-variant mt-1">
|
||||
{t.blocks.length === 0
|
||||
? "从空白开始"
|
||||
: `${t.blocks.length} 个环节`}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="text-error text-sm">{error}</p>}
|
||||
<Button type="submit" disabled={!selected || !title}>
|
||||
创建课案
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
getLessonPlanVersionsAction,
|
||||
revertLessonPlanVersionAction,
|
||||
} from "../actions";
|
||||
import { Button } from "@/shared/components/ui/button";
|
||||
import type { LessonPlanVersion } from "../types";
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
planId: string;
|
||||
onReverted: () => void;
|
||||
}
|
||||
|
||||
export function VersionHistoryDrawer({
|
||||
open,
|
||||
onClose,
|
||||
planId,
|
||||
onReverted,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<LessonPlanVersion[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let cancelled = false;
|
||||
// 用微任务延迟避免同步 setState 触发级联渲染
|
||||
queueMicrotask(() => {
|
||||
if (cancelled) return;
|
||||
setLoading(true);
|
||||
getLessonPlanVersionsAction(planId).then((res) => {
|
||||
if (cancelled) return;
|
||||
if (res.success && res.data) setVersions(res.data.versions);
|
||||
setLoading(false);
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, planId]);
|
||||
|
||||
async function handleRevert(versionNo: number) {
|
||||
if (!confirm(`确认回退到 v${versionNo}?将生成新版本。`)) return;
|
||||
const res = await revertLessonPlanVersionAction({ planId, versionNo });
|
||||
if (res.success) {
|
||||
onReverted();
|
||||
onClose();
|
||||
} else {
|
||||
alert(res.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
<div className="flex-1 bg-black/30" onClick={onClose} />
|
||||
<div className="w-96 bg-surface border-l border-outline-variant overflow-y-auto p-4">
|
||||
<h3 className="font-headline-md text-headline-md mb-4">版本历史</h3>
|
||||
{loading ? (
|
||||
<p>加载中...</p>
|
||||
) : versions.length === 0 ? (
|
||||
<p className="text-on-surface-variant">暂无版本</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{versions.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
className="border border-outline-variant rounded-lg p-3"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-title-md">v{v.versionNo}</span>
|
||||
{v.isAuto && (
|
||||
<span className="text-xs bg-surface-container-highest px-2 py-0.5 rounded">
|
||||
自动
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-on-surface-variant">
|
||||
{v.label ?? "手动保存"}
|
||||
</p>
|
||||
<p className="text-xs text-on-surface-variant mt-1">
|
||||
{new Date(v.createdAt).toLocaleString()}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => handleRevert(v.versionNo)}
|
||||
>
|
||||
回退到此版本
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
107
src/modules/lesson-preparation/constants.ts
Normal file
107
src/modules/lesson-preparation/constants.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { BlockType, TemplateBlockSkeleton, TemplateScope } from "./types";
|
||||
|
||||
// block 类型 → 中文默认标题
|
||||
export const BLOCK_TYPE_LABELS: Record<BlockType, string> = {
|
||||
objective: "教学目标",
|
||||
key_point: "教学重难点",
|
||||
import: "导入",
|
||||
new_teaching: "新授",
|
||||
consolidation: "巩固练习",
|
||||
summary: "课堂小结",
|
||||
homework: "作业布置",
|
||||
blackboard: "板书设计",
|
||||
text_study: "文本研习",
|
||||
exercise: "练习/作业",
|
||||
rich_text: "自定义环节",
|
||||
reflection: "教学反思",
|
||||
};
|
||||
|
||||
// 富文本类 block(共享同一编辑组件)
|
||||
export const RICH_TEXT_BLOCK_TYPES: BlockType[] = [
|
||||
"objective",
|
||||
"key_point",
|
||||
"import",
|
||||
"new_teaching",
|
||||
"consolidation",
|
||||
"summary",
|
||||
"homework",
|
||||
"blackboard",
|
||||
"rich_text",
|
||||
"reflection",
|
||||
];
|
||||
|
||||
// 系统预设模板骨架(seed 用)
|
||||
export interface SystemTemplateDef {
|
||||
id: string; // 固定 ID,便于幂等
|
||||
name: string;
|
||||
scope: TemplateScope;
|
||||
blocks: TemplateBlockSkeleton[];
|
||||
}
|
||||
|
||||
export const SYSTEM_TEMPLATES: SystemTemplateDef[] = [
|
||||
{
|
||||
id: "tpl_regular",
|
||||
name: "常规课",
|
||||
scope: "regular",
|
||||
blocks: [
|
||||
{ type: "objective", title: "教学目标", hint: "明确本课的知识、能力、情感目标" },
|
||||
{ type: "key_point", title: "教学重难点", hint: "标注重点与难点及突破策略" },
|
||||
{ type: "import", title: "导入", hint: "情境导入/复习导入/问题导入" },
|
||||
{ type: "new_teaching", title: "新授", hint: "核心教学活动设计" },
|
||||
{ type: "consolidation", title: "巩固练习", hint: "课堂练习,检验学习效果" },
|
||||
{ type: "summary", title: "课堂小结", hint: "归纳本课要点" },
|
||||
{ type: "homework", title: "作业布置", hint: "课后作业说明(如需下发请用练习块)" },
|
||||
{ type: "blackboard", title: "板书设计", hint: "板书结构示意" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl_review",
|
||||
name: "复习课",
|
||||
scope: "review",
|
||||
blocks: [
|
||||
{ type: "objective", title: "复习目标" },
|
||||
{ type: "rich_text", title: "知识网络梳理", hint: "构建知识结构图" },
|
||||
{ type: "rich_text", title: "典型例题精讲" },
|
||||
{ type: "rich_text", title: "变式训练" },
|
||||
{ type: "exercise", title: "当堂检测", hint: "purpose 选 class_practice" },
|
||||
{ type: "summary", title: "课堂小结" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl_experiment",
|
||||
name: "实验课",
|
||||
scope: "experiment",
|
||||
blocks: [
|
||||
{ type: "objective", title: "实验目的" },
|
||||
{ type: "rich_text", title: "器材准备" },
|
||||
{ type: "rich_text", title: "实验步骤" },
|
||||
{ type: "rich_text", title: "观察记录表" },
|
||||
{ type: "rich_text", title: "交流讨论" },
|
||||
{ type: "summary", title: "课堂小结" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl_inquiry",
|
||||
name: "探究课",
|
||||
scope: "inquiry",
|
||||
blocks: [
|
||||
{ type: "rich_text", title: "情境导入" },
|
||||
{ type: "rich_text", title: "问题驱动" },
|
||||
{ type: "rich_text", title: "小组探究" },
|
||||
{ type: "rich_text", title: "成果展示" },
|
||||
{ type: "rich_text", title: "归纳提升" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "tpl_blank",
|
||||
name: "空白模板",
|
||||
scope: "blank",
|
||||
blocks: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const LESSON_PLAN_STATUS_LABELS: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "已归档",
|
||||
};
|
||||
44
src/modules/lesson-preparation/data-access-knowledge.ts
Normal file
44
src/modules/lesson-preparation/data-access-knowledge.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import "server-only";
|
||||
|
||||
import { like } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { lessonPlans } from "@/shared/db/schema";
|
||||
import { normalizeDocument } from "./data-access";
|
||||
import type { LessonPlanListItem } from "./types";
|
||||
|
||||
// 查询关联了某知识点的课案
|
||||
export async function getLessonPlansByKnowledgePoint(
|
||||
knowledgePointId: string,
|
||||
): Promise<LessonPlanListItem[]> {
|
||||
// content 是 JSON,用 LIKE 粗筛后内存精确过滤
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(like(lessonPlans.content, `%${knowledgePointId}%`));
|
||||
return rows.filter((r) => {
|
||||
const doc = normalizeDocument(r.content);
|
||||
return doc.nodes.some((b) => {
|
||||
const data = b.data as { knowledgePointIds?: string[] };
|
||||
return data?.knowledgePointIds?.includes(knowledgePointId);
|
||||
});
|
||||
}) as unknown as LessonPlanListItem[];
|
||||
}
|
||||
|
||||
// 查询使用了某题目的课案
|
||||
export async function getLessonPlansByQuestion(
|
||||
questionId: string,
|
||||
): Promise<LessonPlanListItem[]> {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(like(lessonPlans.content, `%${questionId}%`));
|
||||
return rows.filter((r) => {
|
||||
const doc = normalizeDocument(r.content);
|
||||
return doc.nodes.some((b) => {
|
||||
if (b.type !== "exercise") return false;
|
||||
const data = b.data as { items?: Array<{ questionId: string }> };
|
||||
return data?.items?.some((it) => it.questionId === questionId);
|
||||
});
|
||||
}) as unknown as LessonPlanListItem[];
|
||||
}
|
||||
92
src/modules/lesson-preparation/data-access-templates.ts
Normal file
92
src/modules/lesson-preparation/data-access-templates.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { lessonPlanTemplates, lessonPlans } from "@/shared/db/schema";
|
||||
import { SYSTEM_TEMPLATES } from "./constants";
|
||||
import { normalizeDocument } from "./data-access";
|
||||
import type {
|
||||
LessonPlanTemplate,
|
||||
TemplateBlockSkeleton,
|
||||
} from "./types";
|
||||
|
||||
export async function getLessonPlanTemplates(
|
||||
userId: string,
|
||||
): Promise<LessonPlanTemplate[]> {
|
||||
// system 模板(内存)+ personal 模板(DB)
|
||||
const systemTemplates: LessonPlanTemplate[] = SYSTEM_TEMPLATES.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
type: "system",
|
||||
scope: t.scope,
|
||||
blocks: t.blocks,
|
||||
creatorId: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
}));
|
||||
|
||||
const personalRows = await db
|
||||
.select()
|
||||
.from(lessonPlanTemplates)
|
||||
.where(
|
||||
and(
|
||||
eq(lessonPlanTemplates.type, "personal"),
|
||||
eq(lessonPlanTemplates.creatorId, userId),
|
||||
),
|
||||
);
|
||||
const personalTemplates =
|
||||
personalRows as unknown as LessonPlanTemplate[];
|
||||
|
||||
return [...systemTemplates, ...personalTemplates];
|
||||
}
|
||||
|
||||
export async function saveAsTemplate(input: {
|
||||
sourcePlanId: string;
|
||||
name: string;
|
||||
userId: string;
|
||||
}): Promise<{ templateId: string }> {
|
||||
// 从课案 content 提取 block 骨架
|
||||
const plan = await db
|
||||
.select({ content: lessonPlans.content })
|
||||
.from(lessonPlans)
|
||||
.where(
|
||||
and(
|
||||
eq(lessonPlans.id, input.sourcePlanId),
|
||||
eq(lessonPlans.creatorId, input.userId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (plan.length === 0) throw new Error("课案不存在或无权访问");
|
||||
|
||||
const doc = normalizeDocument(plan[0].content);
|
||||
const skeleton: TemplateBlockSkeleton[] = doc.nodes.map((b) => ({
|
||||
type: b.type,
|
||||
title: b.title,
|
||||
}));
|
||||
|
||||
const templateId = createId();
|
||||
await db.insert(lessonPlanTemplates).values({
|
||||
id: templateId,
|
||||
name: input.name,
|
||||
type: "personal",
|
||||
scope: "custom",
|
||||
blocks: skeleton,
|
||||
creatorId: input.userId,
|
||||
});
|
||||
return { templateId };
|
||||
}
|
||||
|
||||
export async function deletePersonalTemplate(
|
||||
templateId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await db.delete(lessonPlanTemplates).where(
|
||||
and(
|
||||
eq(lessonPlanTemplates.id, templateId),
|
||||
eq(lessonPlanTemplates.type, "personal"),
|
||||
eq(lessonPlanTemplates.creatorId, userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
143
src/modules/lesson-preparation/data-access-versions.ts
Normal file
143
src/modules/lesson-preparation/data-access-versions.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, desc, eq, inArray, max } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import { lessonPlanVersions, lessonPlans } from "@/shared/db/schema";
|
||||
import { normalizeDocument } from "./data-access";
|
||||
import type { LessonPlanDocument, LessonPlanVersion } from "./types";
|
||||
|
||||
export async function getLessonPlanVersions(
|
||||
planId: string,
|
||||
userId: string,
|
||||
): Promise<LessonPlanVersion[]> {
|
||||
// 校验归属
|
||||
const plan = await db
|
||||
.select({ id: lessonPlans.id })
|
||||
.from(lessonPlans)
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
if (plan.length === 0) return [];
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlanVersions)
|
||||
.where(eq(lessonPlanVersions.planId, planId))
|
||||
.orderBy(desc(lessonPlanVersions.versionNo));
|
||||
return rows as unknown as LessonPlanVersion[];
|
||||
}
|
||||
|
||||
export async function createLessonPlanVersion(input: {
|
||||
planId: string;
|
||||
content: LessonPlanDocument;
|
||||
userId: string;
|
||||
isAuto: boolean;
|
||||
label?: string;
|
||||
}): Promise<{ versionNo: number }> {
|
||||
// 取当前最大 versionNo
|
||||
const maxRow = await db
|
||||
.select({ maxNo: max(lessonPlanVersions.versionNo) })
|
||||
.from(lessonPlanVersions)
|
||||
.where(eq(lessonPlanVersions.planId, input.planId));
|
||||
const nextNo = (maxRow[0]?.maxNo ?? 0) + 1;
|
||||
|
||||
await db.insert(lessonPlanVersions).values({
|
||||
id: createId(),
|
||||
planId: input.planId,
|
||||
versionNo: nextNo,
|
||||
label: input.label ?? null,
|
||||
content: input.content,
|
||||
isAuto: input.isAuto,
|
||||
creatorId: input.userId,
|
||||
});
|
||||
return { versionNo: nextNo };
|
||||
}
|
||||
|
||||
export async function getVersionContent(
|
||||
planId: string,
|
||||
versionNo: number,
|
||||
userId: string,
|
||||
): Promise<LessonPlanDocument | null> {
|
||||
// 校验归属
|
||||
const plan = await db
|
||||
.select({ id: lessonPlans.id })
|
||||
.from(lessonPlans)
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
)
|
||||
.limit(1);
|
||||
if (plan.length === 0) return null;
|
||||
|
||||
const rows = await db
|
||||
.select({ content: lessonPlanVersions.content })
|
||||
.from(lessonPlanVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(lessonPlanVersions.planId, planId),
|
||||
eq(lessonPlanVersions.versionNo, versionNo),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (rows.length === 0) return null;
|
||||
return normalizeDocument(rows[0].content);
|
||||
}
|
||||
|
||||
export async function revertToVersion(
|
||||
planId: string,
|
||||
versionNo: number,
|
||||
userId: string,
|
||||
): Promise<{ newVersionNo: number } | null> {
|
||||
const content = await getVersionContent(planId, versionNo, userId);
|
||||
if (!content) return null;
|
||||
|
||||
// 用该版本 content 覆盖当前 + 生成新版本
|
||||
await db
|
||||
.update(lessonPlans)
|
||||
.set({ content, lastSavedAt: new Date() })
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
|
||||
const { versionNo: newNo } = await createLessonPlanVersion({
|
||||
planId,
|
||||
content,
|
||||
userId,
|
||||
isAuto: false,
|
||||
label: `回退到 v${versionNo}`,
|
||||
});
|
||||
return { newVersionNo: newNo };
|
||||
}
|
||||
|
||||
export async function pruneAutoVersions(
|
||||
planId: string,
|
||||
keep = 50,
|
||||
): Promise<void> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lessonPlanVersions.id,
|
||||
isAuto: lessonPlanVersions.isAuto,
|
||||
versionNo: lessonPlanVersions.versionNo,
|
||||
})
|
||||
.from(lessonPlanVersions)
|
||||
.where(eq(lessonPlanVersions.planId, planId))
|
||||
.orderBy(desc(lessonPlanVersions.versionNo));
|
||||
|
||||
if (rows.length <= keep) return;
|
||||
// 保留前 keep 条;超出部分只删 isAuto=true 的
|
||||
const toDelete = rows.slice(keep).filter((r) => r.isAuto);
|
||||
if (toDelete.length === 0) return;
|
||||
await db
|
||||
.delete(lessonPlanVersions)
|
||||
.where(
|
||||
and(
|
||||
eq(lessonPlanVersions.planId, planId),
|
||||
inArray(
|
||||
lessonPlanVersions.id,
|
||||
toDelete.map((r) => r.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
318
src/modules/lesson-preparation/data-access.ts
Normal file
318
src/modules/lesson-preparation/data-access.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import "server-only";
|
||||
|
||||
import { cache } from "react";
|
||||
import { and, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import {
|
||||
lessonPlans,
|
||||
lessonPlanTemplates,
|
||||
textbooks,
|
||||
chapters,
|
||||
subjects,
|
||||
grades,
|
||||
users,
|
||||
} from "@/shared/db/schema";
|
||||
import type { DataScope } from "@/shared/types/permissions";
|
||||
import { SYSTEM_TEMPLATES } from "./constants";
|
||||
import type {
|
||||
LessonPlan,
|
||||
LessonPlanDocument,
|
||||
LessonPlanDocumentV1,
|
||||
LessonPlanEdge,
|
||||
LessonPlanListItem,
|
||||
LessonPlanNode,
|
||||
LessonPlanTemplate,
|
||||
TemplateBlockSkeleton,
|
||||
} from "./types";
|
||||
|
||||
// ---- v1 → v2 迁移:将旧 blocks 数组转换为 nodes + 线性 edges ----
|
||||
export function migrateV1ToV2(doc: LessonPlanDocumentV1): LessonPlanDocument {
|
||||
const nodes: LessonPlanNode[] = doc.blocks.map((b, i) => ({
|
||||
...b,
|
||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||
}));
|
||||
const edges: LessonPlanEdge[] = [];
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
edges.push({
|
||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||
source: nodes[i].id,
|
||||
target: nodes[i + 1].id,
|
||||
});
|
||||
}
|
||||
return { version: 2, nodes, edges };
|
||||
}
|
||||
|
||||
// ---- 规范化:确保 content 是 v2 格式(兼容旧数据)----
|
||||
export function normalizeDocument(
|
||||
content: unknown,
|
||||
): LessonPlanDocument {
|
||||
if (content && typeof content === "object") {
|
||||
const c = content as { version?: number };
|
||||
if (c.version === 2) {
|
||||
return content as LessonPlanDocument;
|
||||
}
|
||||
if (c.version === 1) {
|
||||
return migrateV1ToV2(content as LessonPlanDocumentV1);
|
||||
}
|
||||
}
|
||||
// 空文档
|
||||
return { version: 2, nodes: [], edges: [] };
|
||||
}
|
||||
|
||||
// ---- 模板初始化:根据骨架生成初始 content(v2)----
|
||||
export function buildInitialContent(
|
||||
blocks: TemplateBlockSkeleton[],
|
||||
): LessonPlanDocument {
|
||||
const nodes: LessonPlanNode[] = blocks.map((b, i) => ({
|
||||
id: createId(),
|
||||
type: b.type,
|
||||
title: b.title,
|
||||
data:
|
||||
b.type === "exercise"
|
||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
||||
: b.type === "text_study"
|
||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
||||
: { html: "", knowledgePointIds: [] },
|
||||
order: i,
|
||||
position: { x: 80 + (i % 4) * 280, y: 80 + Math.floor(i / 4) * 200 },
|
||||
}));
|
||||
const edges: LessonPlanEdge[] = [];
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
edges.push({
|
||||
id: `e_${nodes[i].id}_${nodes[i + 1].id}`,
|
||||
source: nodes[i].id,
|
||||
target: nodes[i + 1].id,
|
||||
});
|
||||
}
|
||||
return { version: 2, nodes, edges };
|
||||
}
|
||||
|
||||
// ---- DataScope → 查询条件 ----
|
||||
function buildScopeCondition(scope: DataScope, userId: string): SQL[] {
|
||||
switch (scope.type) {
|
||||
case "all":
|
||||
return [];
|
||||
case "owned":
|
||||
return [eq(lessonPlans.creatorId, userId)];
|
||||
case "class_taught":
|
||||
case "grade_managed":
|
||||
case "class_members":
|
||||
case "children":
|
||||
// 教师看自己创建的 + published 的
|
||||
return [
|
||||
or(
|
||||
eq(lessonPlans.creatorId, userId),
|
||||
eq(lessonPlans.status, "published"),
|
||||
)!,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 课案列表 ----
|
||||
export const getLessonPlans = cache(
|
||||
async (
|
||||
params: {
|
||||
query?: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
status?: string;
|
||||
},
|
||||
scope: DataScope,
|
||||
userId: string,
|
||||
): Promise<LessonPlanListItem[]> => {
|
||||
const conditions: SQL[] = [
|
||||
sql`${lessonPlans.status} != 'archived'`,
|
||||
];
|
||||
conditions.push(...buildScopeCondition(scope, userId));
|
||||
|
||||
if (params.query) {
|
||||
conditions.push(like(lessonPlans.title, `%${params.query}%`));
|
||||
}
|
||||
if (params.textbookId)
|
||||
conditions.push(eq(lessonPlans.textbookId, params.textbookId));
|
||||
if (params.chapterId)
|
||||
conditions.push(eq(lessonPlans.chapterId, params.chapterId));
|
||||
if (params.subjectId)
|
||||
conditions.push(eq(lessonPlans.subjectId, params.subjectId));
|
||||
if (params.status) conditions.push(eq(lessonPlans.status, params.status));
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: lessonPlans.id,
|
||||
title: lessonPlans.title,
|
||||
textbookId: lessonPlans.textbookId,
|
||||
chapterId: lessonPlans.chapterId,
|
||||
coursePlanItemId: lessonPlans.coursePlanItemId,
|
||||
subjectId: lessonPlans.subjectId,
|
||||
gradeId: lessonPlans.gradeId,
|
||||
templateId: lessonPlans.templateId,
|
||||
templateName: lessonPlans.templateName,
|
||||
content: lessonPlans.content,
|
||||
status: lessonPlans.status,
|
||||
creatorId: lessonPlans.creatorId,
|
||||
lastSavedAt: lessonPlans.lastSavedAt,
|
||||
createdAt: lessonPlans.createdAt,
|
||||
updatedAt: lessonPlans.updatedAt,
|
||||
textbookTitle: textbooks.title,
|
||||
chapterTitle: chapters.title,
|
||||
subjectName: subjects.name,
|
||||
gradeName: grades.name,
|
||||
creatorName: users.name,
|
||||
})
|
||||
.from(lessonPlans)
|
||||
.leftJoin(textbooks, eq(lessonPlans.textbookId, textbooks.id))
|
||||
.leftJoin(chapters, eq(lessonPlans.chapterId, chapters.id))
|
||||
.leftJoin(subjects, eq(lessonPlans.subjectId, subjects.id))
|
||||
.leftJoin(grades, eq(lessonPlans.gradeId, grades.id))
|
||||
.leftJoin(users, eq(lessonPlans.creatorId, users.id))
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(lessonPlans.updatedAt));
|
||||
|
||||
const items = rows as unknown as LessonPlanListItem[];
|
||||
items.forEach((it) => {
|
||||
it.content = normalizeDocument(it.content);
|
||||
});
|
||||
return items;
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 单课案 ----
|
||||
export const getLessonPlanById = cache(
|
||||
async (id: string, userId: string): Promise<LessonPlan | null> => {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(eq(lessonPlans.id, id))
|
||||
.limit(1);
|
||||
if (rows.length === 0) return null;
|
||||
const row = rows[0];
|
||||
// 权限:creator 可看 draft;非 creator 仅 published
|
||||
if (row.creatorId !== userId && row.status !== "published") return null;
|
||||
const plan = row as unknown as LessonPlan;
|
||||
plan.content = normalizeDocument(plan.content);
|
||||
return plan;
|
||||
},
|
||||
);
|
||||
|
||||
// ---- 创建 ----
|
||||
export async function createLessonPlan(input: {
|
||||
title: string;
|
||||
textbookId?: string;
|
||||
chapterId?: string;
|
||||
subjectId?: string;
|
||||
gradeId?: string;
|
||||
templateId: string;
|
||||
creatorId: string;
|
||||
}): Promise<{ planId: string }> {
|
||||
const template = await getTemplateById(input.templateId);
|
||||
if (!template) throw new Error("模板不存在");
|
||||
|
||||
const planId = createId();
|
||||
const content = buildInitialContent(template.blocks);
|
||||
|
||||
await db.insert(lessonPlans).values({
|
||||
id: planId,
|
||||
title: input.title,
|
||||
textbookId: input.textbookId ?? null,
|
||||
chapterId: input.chapterId ?? null,
|
||||
subjectId: input.subjectId ?? null,
|
||||
gradeId: input.gradeId ?? null,
|
||||
templateId: template.id,
|
||||
templateName: template.name,
|
||||
content,
|
||||
status: "draft",
|
||||
creatorId: input.creatorId,
|
||||
lastSavedAt: new Date(),
|
||||
});
|
||||
|
||||
return { planId };
|
||||
}
|
||||
|
||||
// ---- 更新 content(自动保存,不生成版本)----
|
||||
export async function updateLessonPlanContent(
|
||||
planId: string,
|
||||
userId: string,
|
||||
patch: { title?: string; content: LessonPlanDocument },
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(lessonPlans)
|
||||
.set({
|
||||
...(patch.title ? { title: patch.title } : {}),
|
||||
content: patch.content,
|
||||
lastSavedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 软删除 ----
|
||||
export async function softDeleteLessonPlan(
|
||||
planId: string,
|
||||
userId: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(lessonPlans)
|
||||
.set({ status: "archived" })
|
||||
.where(
|
||||
and(eq(lessonPlans.id, planId), eq(lessonPlans.creatorId, userId)),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 复制 ----
|
||||
export async function duplicateLessonPlan(
|
||||
planId: string,
|
||||
userId: string,
|
||||
): Promise<{ newPlanId: string }> {
|
||||
const src = await getLessonPlanById(planId, userId);
|
||||
if (!src) throw new Error("课案不存在或无权访问");
|
||||
|
||||
const newId = createId();
|
||||
await db.insert(lessonPlans).values({
|
||||
id: newId,
|
||||
title: `${src.title} - 副本`,
|
||||
textbookId: src.textbookId,
|
||||
chapterId: src.chapterId,
|
||||
subjectId: src.subjectId,
|
||||
gradeId: src.gradeId,
|
||||
templateId: src.templateId,
|
||||
templateName: src.templateName,
|
||||
content: src.content,
|
||||
status: "draft",
|
||||
creatorId: userId,
|
||||
lastSavedAt: new Date(),
|
||||
});
|
||||
return { newPlanId: newId };
|
||||
}
|
||||
|
||||
// ---- 模板查询(内部)----
|
||||
export async function getTemplateById(
|
||||
templateId: string,
|
||||
): Promise<LessonPlanTemplate | null> {
|
||||
// 先查 system 固定模板
|
||||
const sysDef = SYSTEM_TEMPLATES.find((t) => t.id === templateId);
|
||||
if (sysDef) {
|
||||
return {
|
||||
id: sysDef.id,
|
||||
name: sysDef.name,
|
||||
type: "system",
|
||||
scope: sysDef.scope,
|
||||
blocks: sysDef.blocks,
|
||||
creatorId: null,
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
};
|
||||
}
|
||||
// 再查 DB(personal 模板)
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlanTemplates)
|
||||
.where(eq(lessonPlanTemplates.id, templateId))
|
||||
.limit(1);
|
||||
return rows.length > 0
|
||||
? (rows[0] as unknown as LessonPlanTemplate)
|
||||
: null;
|
||||
}
|
||||
170
src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
Normal file
170
src/modules/lesson-preparation/hooks/use-lesson-plan-editor.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import type {
|
||||
Block,
|
||||
BlockType,
|
||||
LessonPlanDocument,
|
||||
LessonPlanEdge,
|
||||
LessonPlanNode,
|
||||
} from "../types";
|
||||
import { BLOCK_TYPE_LABELS } from "../constants";
|
||||
|
||||
interface EditorState {
|
||||
planId: string;
|
||||
title: string;
|
||||
doc: LessonPlanDocument;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
lastSavedAt: number | null;
|
||||
selectedNodeId: string | null;
|
||||
|
||||
setTitle: (title: string) => void;
|
||||
setPlanId: (planId: string) => void;
|
||||
hydrate: (planId: string, title: string, doc: LessonPlanDocument) => void;
|
||||
|
||||
addNode: (type: BlockType, position?: { x: number; y: number }) => string;
|
||||
updateNode: (id: string, patch: Partial<Block>) => void;
|
||||
updateNodePosition: (id: string, position: { x: number; y: number }) => void;
|
||||
removeNode: (id: string) => void;
|
||||
|
||||
connect: (source: string, target: string) => void;
|
||||
disconnect: (edgeId: string) => void;
|
||||
setEdges: (edges: LessonPlanEdge[]) => void;
|
||||
|
||||
selectNode: (id: string | null) => void;
|
||||
|
||||
markSaved: () => void;
|
||||
setSaving: (saving: boolean) => void;
|
||||
replaceDoc: (doc: LessonPlanDocument) => void;
|
||||
}
|
||||
|
||||
function reindex(nodes: LessonPlanNode[]): LessonPlanNode[] {
|
||||
return nodes.map((n, i) => ({ ...n, order: i }));
|
||||
}
|
||||
|
||||
function defaultData(type: BlockType): Block["data"] {
|
||||
return type === "exercise"
|
||||
? { items: [], purpose: "class_practice", knowledgePointIds: [] }
|
||||
: type === "text_study"
|
||||
? { sourceText: "", annotations: [], knowledgePointIds: [] }
|
||||
: { html: "", knowledgePointIds: [] };
|
||||
}
|
||||
|
||||
export const useLessonPlanEditor = create<EditorState>((set, get) => ({
|
||||
planId: "",
|
||||
title: "",
|
||||
doc: { version: 2, nodes: [], edges: [] },
|
||||
isDirty: false,
|
||||
isSaving: false,
|
||||
lastSavedAt: null,
|
||||
selectedNodeId: null,
|
||||
|
||||
setTitle: (title) => set({ title, isDirty: true }),
|
||||
|
||||
setPlanId: (planId) => set({ planId }),
|
||||
|
||||
// 仅在 planId 变化时调用,避免覆盖用户编辑内容(修复 P1-3)
|
||||
hydrate: (planId, title, doc) =>
|
||||
set({
|
||||
planId,
|
||||
title,
|
||||
doc,
|
||||
isDirty: false,
|
||||
lastSavedAt: Date.now(),
|
||||
selectedNodeId: null,
|
||||
}),
|
||||
|
||||
addNode: (type, position) => {
|
||||
const id = createId();
|
||||
const nodeCount = get().doc.nodes.length;
|
||||
const node: LessonPlanNode = {
|
||||
id,
|
||||
type,
|
||||
title: BLOCK_TYPE_LABELS[type],
|
||||
data: defaultData(type),
|
||||
order: nodeCount,
|
||||
position: position ?? {
|
||||
x: 80 + (nodeCount % 4) * 280,
|
||||
y: 80 + Math.floor(nodeCount / 4) * 200,
|
||||
},
|
||||
};
|
||||
set((s) => ({
|
||||
doc: { ...s.doc, nodes: [...s.doc.nodes, node] },
|
||||
isDirty: true,
|
||||
selectedNodeId: id,
|
||||
}));
|
||||
return id;
|
||||
},
|
||||
|
||||
updateNode: (id, patch) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
nodes: s.doc.nodes.map((n) =>
|
||||
n.id === id ? { ...n, ...patch } : n,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
updateNodePosition: (id, position) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
nodes: s.doc.nodes.map((n) =>
|
||||
n.id === id ? { ...n, position } : n,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
removeNode: (id) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
nodes: reindex(s.doc.nodes.filter((n) => n.id !== id)),
|
||||
edges: s.doc.edges.filter(
|
||||
(e) => e.source !== id && e.target !== id,
|
||||
),
|
||||
},
|
||||
isDirty: true,
|
||||
selectedNodeId:
|
||||
s.selectedNodeId === id ? null : s.selectedNodeId,
|
||||
})),
|
||||
|
||||
connect: (source, target) =>
|
||||
set((s) => {
|
||||
// 避免重复连线
|
||||
if (
|
||||
s.doc.edges.some(
|
||||
(e) => e.source === source && e.target === target,
|
||||
)
|
||||
)
|
||||
return s;
|
||||
const edge: LessonPlanEdge = {
|
||||
id: `e_${source}_${target}_${createId().slice(0, 6)}`,
|
||||
source,
|
||||
target,
|
||||
};
|
||||
return { doc: { ...s.doc, edges: [...s.doc.edges, edge] }, isDirty: true };
|
||||
}),
|
||||
|
||||
disconnect: (edgeId) =>
|
||||
set((s) => ({
|
||||
doc: {
|
||||
...s.doc,
|
||||
edges: s.doc.edges.filter((e) => e.id !== edgeId),
|
||||
},
|
||||
isDirty: true,
|
||||
})),
|
||||
|
||||
setEdges: (edges) => set((s) => ({ doc: { ...s.doc, edges }, isDirty: true })),
|
||||
|
||||
selectNode: (id) => set({ selectedNodeId: id }),
|
||||
|
||||
markSaved: () => set({ isDirty: false, lastSavedAt: Date.now() }),
|
||||
setSaving: (saving) => set({ isSaving: saving }),
|
||||
replaceDoc: (doc) => set({ doc, isDirty: false }),
|
||||
}));
|
||||
175
src/modules/lesson-preparation/publish-service.ts
Normal file
175
src/modules/lesson-preparation/publish-service.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import "server-only";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
|
||||
import { db } from "@/shared/db";
|
||||
import {
|
||||
lessonPlans,
|
||||
examQuestions,
|
||||
} from "@/shared/db/schema";
|
||||
import { createQuestionWithRelations } from "@/modules/questions/data-access";
|
||||
import { persistExamDraft } from "@/modules/exams/data-access";
|
||||
import { createHomeworkAssignment } from "@/modules/homework/data-access-write";
|
||||
import { normalizeDocument } from "./data-access";
|
||||
import type { LessonPlanDocument, ExerciseBlockData } from "./types";
|
||||
|
||||
interface PublishInput {
|
||||
planId: string;
|
||||
blockId: string;
|
||||
userId: string;
|
||||
classIds: string[];
|
||||
availableAt?: Date;
|
||||
dueAt?: Date;
|
||||
}
|
||||
|
||||
interface PublishResult {
|
||||
examId: string;
|
||||
assignmentId: string;
|
||||
updatedContent: LessonPlanDocument;
|
||||
}
|
||||
|
||||
// 查询班级学生列表(避免直接依赖 classes 模块的内部表)
|
||||
async function getStudentIdsByClassIds(
|
||||
classIds: string[],
|
||||
): Promise<string[]> {
|
||||
if (classIds.length === 0) return [];
|
||||
const { inArray } = await import("drizzle-orm");
|
||||
const { classEnrollments } = await import("@/shared/db/schema");
|
||||
const rows = await db
|
||||
.select({ studentId: classEnrollments.studentId })
|
||||
.from(classEnrollments)
|
||||
.where(inArray(classEnrollments.classId, classIds));
|
||||
return rows.map((r) => r.studentId);
|
||||
}
|
||||
|
||||
export async function publishLessonPlanHomework(
|
||||
input: PublishInput,
|
||||
): Promise<PublishResult> {
|
||||
// 1. 读取课案
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(lessonPlans)
|
||||
.where(eq(lessonPlans.id, input.planId))
|
||||
.limit(1);
|
||||
if (rows.length === 0) throw new Error("课案不存在");
|
||||
const row = rows[0] as unknown as {
|
||||
id: string;
|
||||
content: unknown;
|
||||
creatorId: string;
|
||||
title: string;
|
||||
textbookId: string | null;
|
||||
chapterId: string | null;
|
||||
subjectId: string | null;
|
||||
gradeId: string | null;
|
||||
};
|
||||
const plan = {
|
||||
...row,
|
||||
content: normalizeDocument(row.content),
|
||||
};
|
||||
if (plan.creatorId !== input.userId)
|
||||
throw new Error("无权发布");
|
||||
|
||||
// 2. 定位 exercise block
|
||||
const block = plan.content.nodes.find((b) => b.id === input.blockId);
|
||||
if (!block || block.type !== "exercise")
|
||||
throw new Error("练习块不存在");
|
||||
const data = block.data as ExerciseBlockData;
|
||||
if (data.items.length === 0) throw new Error("练习块无题目");
|
||||
if (data.publishedAssignmentId)
|
||||
throw new Error("该练习块已发布,请使用'重新发布'");
|
||||
|
||||
// 3. inline 题目入库,替换占位 ID
|
||||
const newContent: LessonPlanDocument = JSON.parse(
|
||||
JSON.stringify(plan.content),
|
||||
);
|
||||
const newBlock = newContent.nodes.find((b) => b.id === input.blockId);
|
||||
if (!newBlock || newBlock.type !== "exercise")
|
||||
throw new Error("练习块不存在");
|
||||
const newData = newBlock.data as ExerciseBlockData;
|
||||
|
||||
for (let i = 0; i < newData.items.length; i++) {
|
||||
const item = newData.items[i];
|
||||
if (item.source === "inline" && item.inlineContent) {
|
||||
const questionId = await createQuestionWithRelations(
|
||||
{
|
||||
content: item.inlineContent.content,
|
||||
type: item.inlineContent.type as never,
|
||||
difficulty: item.inlineContent.difficulty,
|
||||
knowledgePointIds: item.inlineContent.knowledgePointIds,
|
||||
},
|
||||
input.userId,
|
||||
);
|
||||
newData.items[i] = {
|
||||
...item,
|
||||
questionId,
|
||||
inlineContent: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 打包 exam 草稿
|
||||
const examId = createId();
|
||||
if (!plan.subjectId || !plan.gradeId) {
|
||||
throw new Error("课案缺少学科或年级信息,无法发布");
|
||||
}
|
||||
await persistExamDraft({
|
||||
examId,
|
||||
title: `${plan.title} - 作业`,
|
||||
creatorId: input.userId,
|
||||
subjectId: plan.subjectId,
|
||||
gradeId: plan.gradeId,
|
||||
scheduledAt: undefined,
|
||||
description: `来自课案:${plan.title}`,
|
||||
});
|
||||
// 插入 examQuestions
|
||||
if (newData.items.length > 0) {
|
||||
await db.insert(examQuestions).values(
|
||||
newData.items.map((it, i) => ({
|
||||
examId,
|
||||
questionId: it.questionId,
|
||||
score: it.score,
|
||||
order: i,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// 5. 下发作业
|
||||
const assignmentId = createId();
|
||||
const targetStudentIds = await getStudentIdsByClassIds(input.classIds);
|
||||
if (targetStudentIds.length === 0) {
|
||||
throw new Error("所选班级无学生");
|
||||
}
|
||||
await createHomeworkAssignment({
|
||||
assignmentId,
|
||||
sourceExamId: examId,
|
||||
title: `${plan.title} - 作业`,
|
||||
description: `来自课案:${plan.title}`,
|
||||
structure: null,
|
||||
status: "published",
|
||||
creatorId: input.userId,
|
||||
availableAt: input.availableAt ?? null,
|
||||
dueAt: input.dueAt ?? null,
|
||||
allowLate: false,
|
||||
lateDueAt: null,
|
||||
maxAttempts: 1,
|
||||
publish: true,
|
||||
questions: newData.items.map((it, i) => ({
|
||||
questionId: it.questionId,
|
||||
score: it.score,
|
||||
order: i,
|
||||
})),
|
||||
targetStudentIds,
|
||||
});
|
||||
|
||||
// 6. 回写溯源标记
|
||||
newData.publishedExamId = examId;
|
||||
newData.publishedAssignmentId = assignmentId;
|
||||
newData.publishedAt = new Date().toISOString();
|
||||
await db
|
||||
.update(lessonPlans)
|
||||
.set({ content: newContent })
|
||||
.where(eq(lessonPlans.id, input.planId));
|
||||
|
||||
return { examId, assignmentId, updatedContent: newContent };
|
||||
}
|
||||
34
src/modules/lesson-preparation/schema.ts
Normal file
34
src/modules/lesson-preparation/schema.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const createLessonPlanSchema = z.object({
|
||||
title: z.string().min(1, "请输入课案标题").max(255),
|
||||
textbookId: z.string().optional(),
|
||||
chapterId: z.string().optional(),
|
||||
subjectId: z.string().optional(),
|
||||
gradeId: z.string().optional(),
|
||||
templateId: z.string().min(1, "请选择模板"),
|
||||
});
|
||||
|
||||
export const updateLessonPlanContentSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
title: z.string().min(1).max(255).optional(),
|
||||
content: z.unknown(), // Block 文档结构由 types 守卫,运行时只校验存在
|
||||
});
|
||||
|
||||
export const saveVersionSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
label: z.string().max(100).optional(),
|
||||
});
|
||||
|
||||
export const revertVersionSchema = z.object({
|
||||
planId: z.string().min(1),
|
||||
versionNo: z.number().int().positive(),
|
||||
});
|
||||
|
||||
export const saveAsTemplateSchema = z.object({
|
||||
sourcePlanId: z.string().min(1),
|
||||
name: z.string().min(1).max(100),
|
||||
});
|
||||
|
||||
export type CreateLessonPlanInput = z.infer<typeof createLessonPlanSchema>;
|
||||
export type UpdateLessonPlanContentInput = z.infer<typeof updateLessonPlanContentSchema>;
|
||||
9
src/modules/lesson-preparation/seed-templates.ts
Normal file
9
src/modules/lesson-preparation/seed-templates.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SYSTEM_TEMPLATES } from "./constants";
|
||||
|
||||
// 系统模板以内存常量形式存在(固定 ID),无需 DB seed。
|
||||
// 此函数供 scripts/seed.ts 调用以保持调用约定一致,当前为空操作。
|
||||
export async function seedLessonPlanTemplates(): Promise<void> {
|
||||
// 预留:若未来需要将 system 模板落库以便管理后台编辑,在此实现。
|
||||
void SYSTEM_TEMPLATES;
|
||||
return;
|
||||
}
|
||||
177
src/modules/lesson-preparation/types.ts
Normal file
177
src/modules/lesson-preparation/types.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// 课案状态
|
||||
export type LessonPlanStatus = "draft" | "published" | "archived";
|
||||
|
||||
// Block 类型枚举
|
||||
export type BlockType =
|
||||
| "objective"
|
||||
| "key_point"
|
||||
| "import"
|
||||
| "new_teaching"
|
||||
| "consolidation"
|
||||
| "summary"
|
||||
| "homework"
|
||||
| "blackboard"
|
||||
| "text_study"
|
||||
| "exercise"
|
||||
| "rich_text"
|
||||
| "reflection";
|
||||
|
||||
// 富文本类 block 的 data
|
||||
export interface RichTextBlockData {
|
||||
html: string;
|
||||
knowledgePointIds: string[];
|
||||
}
|
||||
|
||||
// 文本研习 block 的 data
|
||||
export interface TextStudyAnnotation {
|
||||
id: string;
|
||||
anchor: { start: number; end: number };
|
||||
nodeType: string;
|
||||
title: string;
|
||||
note: string;
|
||||
color: "yellow" | "green";
|
||||
}
|
||||
|
||||
export interface TextStudyBlockData {
|
||||
sourceText: string;
|
||||
annotations: TextStudyAnnotation[];
|
||||
knowledgePointIds: string[];
|
||||
}
|
||||
|
||||
// 练习 block 的 data
|
||||
export type ExercisePurpose = "class_practice" | "after_class_homework";
|
||||
|
||||
export interface InlineQuestionContent {
|
||||
content: unknown; // 与 questions.content 对齐
|
||||
type: string; // 与 questionTypeEnum 对齐
|
||||
difficulty: number;
|
||||
knowledgePointIds: string[];
|
||||
}
|
||||
|
||||
export interface ExerciseItem {
|
||||
questionId: string; // bank=真实ID;inline=占位 inline_draft_xxx
|
||||
source: "bank" | "inline";
|
||||
score: number;
|
||||
order: number;
|
||||
inlineContent?: InlineQuestionContent; // 仅 inline
|
||||
}
|
||||
|
||||
export interface ExerciseBlockData {
|
||||
items: ExerciseItem[];
|
||||
purpose: ExercisePurpose;
|
||||
knowledgePointIds: string[];
|
||||
publishedAssignmentId?: string;
|
||||
publishedExamId?: string;
|
||||
publishedAt?: string;
|
||||
}
|
||||
|
||||
// Block 联合
|
||||
export interface Block {
|
||||
id: string;
|
||||
type: BlockType;
|
||||
title: string;
|
||||
data: RichTextBlockData | TextStudyBlockData | ExerciseBlockData;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// 节点(Block + 画布坐标)
|
||||
export interface LessonPlanNode extends Block {
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
// 连线(节点间数据流/流程顺序)
|
||||
export interface LessonPlanEdge {
|
||||
id: string;
|
||||
source: string; // 源节点 id
|
||||
target: string; // 目标节点 id
|
||||
sourceHandle?: string | null;
|
||||
targetHandle?: string | null;
|
||||
}
|
||||
|
||||
// 文档 v1(旧格式,向后兼容读取)
|
||||
export interface LessonPlanDocumentV1 {
|
||||
version: 1;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
// 文档 v2(节点图格式)
|
||||
export interface LessonPlanDocument {
|
||||
version: 2;
|
||||
nodes: LessonPlanNode[];
|
||||
edges: LessonPlanEdge[];
|
||||
}
|
||||
|
||||
// 课案
|
||||
export interface LessonPlan {
|
||||
id: string;
|
||||
title: string;
|
||||
textbookId: string | null;
|
||||
chapterId: string | null;
|
||||
coursePlanItemId: string | null;
|
||||
subjectId: string | null;
|
||||
gradeId: string | null;
|
||||
templateId: string | null;
|
||||
templateName: string | null;
|
||||
content: LessonPlanDocument;
|
||||
status: LessonPlanStatus;
|
||||
creatorId: string;
|
||||
lastSavedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 版本
|
||||
export interface LessonPlanVersion {
|
||||
id: string;
|
||||
planId: string;
|
||||
versionNo: number;
|
||||
label: string | null;
|
||||
content: LessonPlanDocument;
|
||||
isAuto: boolean;
|
||||
creatorId: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 模板
|
||||
export type TemplateType = "system" | "personal";
|
||||
export type TemplateScope =
|
||||
| "regular"
|
||||
| "review"
|
||||
| "experiment"
|
||||
| "inquiry"
|
||||
| "blank"
|
||||
| "custom";
|
||||
|
||||
export interface TemplateBlockSkeleton {
|
||||
type: BlockType;
|
||||
title: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
export interface LessonPlanTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TemplateType;
|
||||
scope: TemplateScope;
|
||||
blocks: TemplateBlockSkeleton[];
|
||||
creatorId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 列表项(带教材/章节名)
|
||||
export interface LessonPlanListItem extends LessonPlan {
|
||||
textbookTitle: string | null;
|
||||
chapterTitle: string | null;
|
||||
subjectName: string | null;
|
||||
gradeName: string | null;
|
||||
creatorName: string | null;
|
||||
}
|
||||
|
||||
// ActionState(与项目现有约定一致)
|
||||
export type ActionState<T = unknown> = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
errors?: Record<string, string[]>;
|
||||
data?: T;
|
||||
};
|
||||
@@ -17,23 +17,23 @@ import {
|
||||
createMessage,
|
||||
markMessageAsRead,
|
||||
deleteMessage,
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getRecipients,
|
||||
} from "./data-access"
|
||||
import {
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
} from "@/modules/notifications/data-access"
|
||||
import {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "./notification-preferences"
|
||||
} from "@/modules/notifications/preferences"
|
||||
import type { Message, MessageType, RecipientOption } from "./types"
|
||||
import type {
|
||||
Message,
|
||||
Notification,
|
||||
MessageType,
|
||||
NotificationPreferences,
|
||||
RecipientOption,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "./types"
|
||||
} from "@/modules/notifications/types"
|
||||
|
||||
export async function sendMessageAction(
|
||||
prevState: ActionState<string> | null,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { toast } from "sonner"
|
||||
import { ArrowLeft, Send } from "lucide-react"
|
||||
@@ -70,9 +71,9 @@ export function MessageCompose({
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<a href={backHref}>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
<CardTitle>{parentMessageId ? "Reply" : "New Message"}</CardTitle>
|
||||
</div>
|
||||
|
||||
@@ -76,9 +76,9 @@ export function MessageDetail({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="ghost" size="icon">
|
||||
<a href={backHref}>
|
||||
<Link href={backHref}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</a>
|
||||
</Link>
|
||||
</Button>
|
||||
<h2 className="text-2xl font-bold tracking-tight">Message</h2>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent, CardHeader } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/shared/components/ui/tabs"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
import { usePermission } from "@/shared/hooks/use-permission"
|
||||
import { Permissions } from "@/shared/types/permissions"
|
||||
|
||||
@@ -79,7 +79,7 @@ export function MessageList({
|
||||
const unread = isReceived && !m.isRead
|
||||
return (
|
||||
<Link key={m.id} href={`/messages/${m.id}`} className="block">
|
||||
<Card className={`transition-colors hover:bg-accent/50 ${unread ? "border-primary/40" : ""}`}>
|
||||
<Card className={cn("transition-colors hover:bg-accent/50", unread && "border-primary/40")}>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-2 space-y-0 pb-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -88,7 +88,7 @@ export function MessageList({
|
||||
) : (
|
||||
<MailOpen className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${unread ? "text-primary" : ""}`}>
|
||||
<span className={cn("text-sm font-medium", unread && "text-primary")}>
|
||||
{m.subject ?? "(no subject)"}
|
||||
</span>
|
||||
{unread ? <Badge variant="default" className="text-xs">New</Badge> : null}
|
||||
|
||||
@@ -16,14 +16,14 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/components/ui/dropdown-menu"
|
||||
import { ScrollArea } from "@/shared/components/ui/scroll-area"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import {
|
||||
getNotificationsAction,
|
||||
markAllNotificationsAsReadAction,
|
||||
markNotificationAsReadAction,
|
||||
} from "../actions"
|
||||
import type { Notification, NotificationType } from "../types"
|
||||
import type { Notification, NotificationType } from "@/modules/notifications/types"
|
||||
|
||||
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
message: MessageSquare,
|
||||
@@ -131,7 +131,7 @@ export function NotificationDropdown() {
|
||||
{!n.isRead ? (
|
||||
<span className="bg-primary size-1.5 shrink-0 rounded-full" />
|
||||
) : null}
|
||||
<span className={`text-xs ${!n.isRead ? "font-semibold" : "font-medium"}`}>
|
||||
<span className={cn("text-xs", !n.isRead ? "font-semibold" : "font-medium")}>
|
||||
{n.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -10,10 +10,10 @@ import { Badge } from "@/shared/components/ui/badge"
|
||||
import { Button } from "@/shared/components/ui/button"
|
||||
import { Card, CardContent } from "@/shared/components/ui/card"
|
||||
import { EmptyState } from "@/shared/components/ui/empty-state"
|
||||
import { formatDate } from "@/shared/lib/utils"
|
||||
import { cn, formatDate } from "@/shared/lib/utils"
|
||||
|
||||
import { markAllNotificationsAsReadAction, markNotificationAsReadAction } from "../actions"
|
||||
import type { Notification, NotificationType } from "../types"
|
||||
import type { Notification, NotificationType } from "@/modules/notifications/types"
|
||||
|
||||
const TYPE_ICON: Record<NotificationType, typeof Bell> = {
|
||||
message: MessageSquare,
|
||||
@@ -91,7 +91,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
return (
|
||||
<Card
|
||||
key={n.id}
|
||||
className={`transition-colors ${!n.isRead ? "border-primary/40 bg-primary/5" : ""}`}
|
||||
className={cn("transition-colors", !n.isRead && "border-primary/40 bg-primary/5")}
|
||||
>
|
||||
<CardContent className="flex items-start gap-3 py-4">
|
||||
<div className="bg-muted flex size-9 shrink-0 items-center justify-center rounded-full">
|
||||
@@ -99,7 +99,7 @@ export function NotificationList({ notifications }: { notifications: Notificatio
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm ${!n.isRead ? "font-semibold" : "font-medium"}`}>
|
||||
<span className={cn("text-sm", !n.isRead ? "font-semibold" : "font-medium")}>
|
||||
{n.title}
|
||||
</span>
|
||||
{!n.isRead ? <Badge variant="default" className="text-xs">New</Badge> : null}
|
||||
|
||||
@@ -9,31 +9,35 @@ import "server-only"
|
||||
* - getUnreadMessageCount: 未读私信计数
|
||||
* - getRecipients: 获取收件人列表(按 DataScope 过滤)
|
||||
*
|
||||
* 注意: 通知相关函数(createNotification / getNotifications /
|
||||
* 通知相关函数(createNotification / getNotifications /
|
||||
* markNotificationAsRead / markAllNotificationsAsRead / getUnreadNotificationCount)
|
||||
* 已迁移到 notifications/data-access.ts(P0-4 / P1-5 修复)。
|
||||
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
|
||||
* 已迁移到 notifications/data-access.ts,请直接从该模块导入。
|
||||
*/
|
||||
|
||||
import { cache } from "react"
|
||||
import { createId } from "@paralleldrive/cuid2"
|
||||
import { and, count, desc, eq, inArray, or } from "drizzle-orm"
|
||||
import { and, count, desc, eq, inArray, or, type SQL } from "drizzle-orm"
|
||||
|
||||
import { db } from "@/shared/db"
|
||||
import {
|
||||
messages,
|
||||
users,
|
||||
classEnrollments,
|
||||
classes,
|
||||
} from "@/shared/db/schema"
|
||||
import {
|
||||
getClassesByGradeId,
|
||||
getStudentIdsByClassIds,
|
||||
getTeacherIdsByClassIds,
|
||||
getStudentActiveClassId,
|
||||
} from "@/modules/classes/data-access"
|
||||
import { getUserNamesByIds } from "@/modules/users/data-access"
|
||||
import type { DataScope } from "@/shared/types/permissions"
|
||||
import type {
|
||||
Message,
|
||||
GetMessagesParams,
|
||||
CreateMessageInput,
|
||||
PaginatedResult,
|
||||
RecipientOption,
|
||||
} from "./types"
|
||||
import type { PaginatedResult } from "@/modules/notifications/types"
|
||||
|
||||
const toIso = (d: Date | null | undefined): string | null => (d ? d.toISOString() : null)
|
||||
|
||||
@@ -81,7 +85,7 @@ export const getMessages = cache(
|
||||
const pageSize = Math.max(1, params.pageSize ?? 20)
|
||||
const offset = (page - 1) * pageSize
|
||||
|
||||
const conds = []
|
||||
const conds: SQL[] = []
|
||||
if (params.type === "inbox") conds.push(eq(messages.receiverId, params.userId))
|
||||
else if (params.type === "sent") conds.push(eq(messages.senderId, params.userId))
|
||||
else {
|
||||
@@ -171,34 +175,42 @@ export const getRecipients = cache(
|
||||
return all.filter((r) => r.id !== userId).map((r) => ({ ...r, name: r.name ?? r.email }))
|
||||
}
|
||||
if (scope.type === "class_taught" && scope.classIds.length > 0) {
|
||||
const rows = await db
|
||||
.selectDistinct({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.innerJoin(classEnrollments, eq(classEnrollments.studentId, users.id))
|
||||
.where(inArray(classEnrollments.classId, scope.classIds))
|
||||
return rows.map((r) => ({ ...r, name: r.name ?? r.email, role: "student" }))
|
||||
// 通过 classes data-access 获取学生 ID,避免直接 JOIN classEnrollments 表
|
||||
const studentIds = await getStudentIdsByClassIds(scope.classIds)
|
||||
const userMap = await getUserNamesByIds(studentIds)
|
||||
return Array.from(userMap.values())
|
||||
.filter((u) => u.id !== userId)
|
||||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" }))
|
||||
}
|
||||
if (scope.type === "grade_managed" && scope.gradeIds.length > 0) {
|
||||
const rows = await db
|
||||
.selectDistinct({ id: users.id, name: users.name, email: users.email })
|
||||
.from(users)
|
||||
.innerJoin(classEnrollments, eq(classEnrollments.studentId, users.id))
|
||||
.innerJoin(classes, eq(classes.id, classEnrollments.classId))
|
||||
.where(inArray(classes.gradeId, scope.gradeIds))
|
||||
return rows.map((r) => ({ ...r, name: r.name ?? r.email, role: "student" }))
|
||||
// 通过 classes data-access 获取年级下所有班级,再获取学生 ID,
|
||||
// 避免直接 JOIN classes / classEnrollments 表
|
||||
const classLists = await Promise.all(scope.gradeIds.map((g) => getClassesByGradeId(g)))
|
||||
const classIds = classLists.flat().map((c) => c.id)
|
||||
const studentIds = await getStudentIdsByClassIds(classIds)
|
||||
const userMap = await getUserNamesByIds(studentIds)
|
||||
return Array.from(userMap.values())
|
||||
.filter((u) => u.id !== userId)
|
||||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "student" }))
|
||||
}
|
||||
if (scope.type === "class_members" && scope.classIds.length > 0) {
|
||||
// 学生可以给自己班级的任课教师/班主任发消息
|
||||
const teacherIds = await getTeacherIdsByClassIds(scope.classIds)
|
||||
const userMap = await getUserNamesByIds(teacherIds)
|
||||
return Array.from(userMap.values())
|
||||
.filter((u) => u.id !== userId)
|
||||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" }))
|
||||
}
|
||||
if (scope.type === "children" && scope.childrenIds.length > 0) {
|
||||
// 家长可以给孩子的班主任/任课教师发消息
|
||||
const classIds = await Promise.all(scope.childrenIds.map((id) => getStudentActiveClassId(id)))
|
||||
const validClassIds = classIds.filter((id): id is string => id !== null)
|
||||
const teacherIds = await getTeacherIdsByClassIds(validClassIds)
|
||||
const userMap = await getUserNamesByIds(teacherIds)
|
||||
return Array.from(userMap.values())
|
||||
.filter((u) => u.id !== userId)
|
||||
.map((u) => ({ id: u.id, name: u.name ?? u.email, email: u.email, role: "teacher" }))
|
||||
}
|
||||
return []
|
||||
}
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 向后兼容 re-export:通知 CRUD 已迁移到 notifications/data-access.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export {
|
||||
createNotification,
|
||||
getNotifications,
|
||||
markNotificationAsRead,
|
||||
markAllNotificationsAsRead,
|
||||
getUnreadNotificationCount,
|
||||
} from "@/modules/notifications/data-access"
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* 通知偏好数据访问(向后兼容重导出)
|
||||
*
|
||||
* 注意: 通知偏好函数已迁移到 notifications/preferences.ts(P0-4 / P1-5 修复)。
|
||||
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
|
||||
*/
|
||||
|
||||
export {
|
||||
getNotificationPreferences,
|
||||
upsertNotificationPreferences,
|
||||
} from "@/modules/notifications/preferences"
|
||||
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* 私信模块类型定义
|
||||
*
|
||||
* 注意: 通知相关类型(NotificationType, Notification, NotificationPreferences,
|
||||
* 通知相关类型(NotificationType, Notification, NotificationPreferences,
|
||||
* UpdateNotificationPreferencesInput, CreateNotificationInput, GetNotificationsParams,
|
||||
* PaginatedResult)已迁移到 notifications/types.ts(P0-4 / P1-5 修复)。
|
||||
* 本文件通过 re-export 保持向后兼容,现有调用方无需修改 import 路径。
|
||||
* PaginatedResult)已迁移到 notifications/types.ts,请直接从该模块导入。
|
||||
*/
|
||||
|
||||
export type MessageType = "inbox" | "sent" | "all"
|
||||
@@ -50,18 +49,3 @@ export interface RecipientOption {
|
||||
email: string
|
||||
role?: string
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 向后兼容 re-export:通知相关类型已迁移到 notifications/types.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type {
|
||||
NotificationType,
|
||||
Notification,
|
||||
NotificationListItem,
|
||||
GetNotificationsParams,
|
||||
CreateNotificationInput,
|
||||
PaginatedResult,
|
||||
NotificationPreferences,
|
||||
UpdateNotificationPreferencesInput,
|
||||
} from "@/modules/notifications/types"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user