feat(grades): add appeals, drafts, import, report card, and growth archive

- Add actions-appeal, actions-draft, actions-import, actions-lock for grade workflow

- Add data-access-appeals, data-access-drafts, data-access-exam-entry for data layer

- Add batch-grade-entry-dialog, batch-grade-entry-stats, batch-grade-entry-table

- Add draft-lock-banner, excel-import-dialog for import and draft management

- Add growth-archive-chart, knowledge-point-mastery-chart for analytics

- Add report-card-view, report-card-print-action, report-card-print-button

- Add import-export, lib/notify, lib/report-card, scope-check test, stats-service test

- Add hooks directory
This commit is contained in:
SpecialX
2026-07-03 10:25:01 +08:00
parent 2b95fd668b
commit e85a5f05dd
51 changed files with 6871 additions and 916 deletions

View File

@@ -0,0 +1,100 @@
"use client"
import { useState, useCallback } from "react"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
import { useTranslations } from "next-intl"
import { undoBatchCreateGradeRecordsAction } from "../actions"
/**
* 撤销批量成绩录入的 Hook。
*
* P1-6/P1-7 重构:从 batch-grade-entry.tsx 拆分而来。
* - 用 `isUndoData` 类型守卫替代 `as` 断言P1-7
* - 撤销令牌持久化在 sessionStorage5 分钟内有效
*/
/** 撤销令牌存储结构 */
interface UndoData {
ids: string[]
timestamp: number
}
/** 类型守卫:校验从 sessionStorage 解析的撤销数据结构 */
export function isUndoData(v: unknown): v is UndoData {
if (typeof v !== "object" || v === null) return false
const obj = v
return (
"ids" in obj &&
Array.isArray(obj.ids) &&
obj.ids.every((id) => typeof id === "string") &&
"timestamp" in obj &&
typeof obj.timestamp === "number"
)
}
const STORAGE_KEY = "lastBatchGradeRecordIds"
const UNDO_TTL_MS = 5 * 60 * 1000
/** 序列化撤销数据 */
function serializeUndoData(ids: string[]): string {
return JSON.stringify({ ids, timestamp: Date.now() })
}
export function useBatchGradeEntryUndo(): {
saveUndoToken: (ids: string[]) => void
handleUndo: () => Promise<void>
} {
const router = useRouter()
const t = useTranslations("grades")
const [isUndoing, setIsUndoing] = useState(false)
const saveUndoToken = useCallback((ids: string[]): void => {
if (ids.length === 0) return
try {
sessionStorage.setItem(STORAGE_KEY, serializeUndoData(ids))
} catch {
// sessionStorage 不可用时静默失败
}
}, [])
const handleUndo = useCallback(async (): Promise<void> => {
if (isUndoing) return
setIsUndoing(true)
try {
const raw = sessionStorage.getItem(STORAGE_KEY)
if (!raw) {
toast.error(t("batchByExam.undoNoRecord"))
return
}
const parsed: unknown = JSON.parse(raw)
// P1-7 修复:使用类型守卫替代 `as` 断言
if (!isUndoData(parsed)) {
toast.error(t("batchByExam.undoFailed"))
return
}
if (Date.now() - parsed.timestamp > UNDO_TTL_MS) {
toast.error(t("batchByExam.undoExpired"))
return
}
const result = await undoBatchCreateGradeRecordsAction(parsed.ids)
if (result.success) {
sessionStorage.removeItem(STORAGE_KEY)
toast.success(result.message)
router.refresh()
} else {
toast.error(result.message || t("batchByExam.undoFailed"))
}
} catch {
toast.error(t("batchByExam.undoFailed"))
} finally {
setIsUndoing(false)
}
}, [isUndoing, router, t])
return { saveUndoToken, handleUndo }
}

View File

@@ -0,0 +1,184 @@
"use client"
import { useState, useEffect, useCallback, useRef } from "react"
import { toast } from "sonner"
import { useTranslations } from "next-intl"
import { safeActionCall } from "@/shared/lib/action-utils"
import {
acquireDraftLockAction,
renewDraftLockAction,
releaseDraftLockAction,
} from "../actions-lock"
import type { DraftLockStatus } from "../data-access-drafts"
/**
* P3-6 协同录入锁管理 Hook。
*
* 职责:
* 1. 进入编辑页时自动获取锁
* 2. 每 3 分钟心跳续期TTL 为 5 分钟,留足余量)
* 3. 组件卸载或提交成功时释放锁
* 4. 锁被他人持有时暴露 conflict 状态供 UI 展示
*
* 使用方:成绩批量录入组件,传入 classId+subjectId+type 三元组。
*/
const HEARTBEAT_INTERVAL_MS = 3 * 60 * 1000
interface UseDraftLockParams {
classId: string
subjectId: string
type: string
/** 是否激活锁机制(如未选择科目/类型时不激活) */
enabled: boolean
}
interface UseDraftLockReturn {
status: DraftLockStatus | null
lockToken: string | null
isAcquiring: boolean
isConflict: boolean
/** 手动重试获取锁(被抢占后用户点击"重试" */
retryAcquire: () => Promise<void>
/** 提交成功后手动释放锁 */
releaseLock: () => Promise<void>
}
export function useDraftLock(params: UseDraftLockParams): UseDraftLockReturn {
const { classId, subjectId, type, enabled } = params
const t = useTranslations("grades")
const [status, setStatus] = useState<DraftLockStatus | null>(null)
const [lockToken, setLockToken] = useState<string | null>(null)
const [isAcquiring, setIsAcquiring] = useState(false)
// 用 ref 保存最新 token避免心跳闭包捕获旧值
const tokenRef = useRef<string | null>(null)
// 防止卸载时重复 release
const releasedRef = useRef(false)
// 防止并发 acquire
const acquiringRef = useRef(false)
const isConflict = status !== null && !status.isMine
const doAcquire = useCallback(async () => {
if (acquiringRef.current) return
if (!classId || !subjectId || !type) return
acquiringRef.current = true
setIsAcquiring(true)
const result = await safeActionCall(() =>
acquireDraftLockAction({ classId, subjectId, type })
)
setIsAcquiring(false)
acquiringRef.current = false
if (result?.success && result.data?.acquired) {
const token = result.data.lockToken
setLockToken(token)
tokenRef.current = token
setStatus(result.data.status)
releasedRef.current = false
} else if (result?.data) {
// 锁被他人持有
setStatus(result.data.status)
setLockToken(null)
tokenRef.current = null
} else {
toast.error(t("collabLock.acquireFailed"))
}
}, [classId, subjectId, type, t])
// 进入页面 / 参数变化时获取锁
useEffect(() => {
if (!enabled) return
void doAcquire()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, classId, subjectId, type])
// 心跳续期
useEffect(() => {
if (!enabled || !lockToken) return
const timer = setInterval(async () => {
const currentToken = tokenRef.current
if (!currentToken) return
const result = await safeActionCall(() =>
renewDraftLockAction({
classId,
subjectId,
type,
lockToken: currentToken,
})
)
if (result?.success && result.data?.acquired) {
const newToken = result.data.lockToken
setLockToken(newToken)
tokenRef.current = newToken
setStatus(result.data.status)
} else {
// 续期失败:锁被抢占或过期
setStatus(result?.data?.status ?? null)
setLockToken(null)
tokenRef.current = null
toast.error(t("collabLock.heartbeatError"))
}
}, HEARTBEAT_INTERVAL_MS)
return () => clearInterval(timer)
}, [enabled, lockToken, classId, subjectId, type, t])
// 卸载时释放锁
useEffect(() => {
return () => {
const token = tokenRef.current
if (!token || releasedRef.current) return
releasedRef.current = true
// 使用 sendBeacon 或 fire-and-forget避免页面卸载时请求被取消
void releaseDraftLockAction({
classId,
subjectId,
type,
lockToken: token,
}).catch(() => {
// 静默失败:锁会自动过期
})
}
}, [classId, subjectId, type])
const retryAcquire = useCallback(async () => {
await doAcquire()
}, [doAcquire])
const releaseLock = useCallback(async () => {
const token = tokenRef.current
if (!token) return
releasedRef.current = true
const result = await safeActionCall(() =>
releaseDraftLockAction({
classId,
subjectId,
type,
lockToken: token,
})
)
if (result?.success) {
setLockToken(null)
tokenRef.current = null
setStatus(null)
}
}, [classId, subjectId, type])
return {
status,
lockToken,
isAcquiring,
isConflict,
retryAcquire,
releaseLock,
}
}