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:
100
src/modules/grades/hooks/use-batch-grade-entry-undo.ts
Normal file
100
src/modules/grades/hooks/use-batch-grade-entry-undo.ts
Normal 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)
|
||||
* - 撤销令牌持久化在 sessionStorage,5 分钟内有效
|
||||
*/
|
||||
|
||||
/** 撤销令牌存储结构 */
|
||||
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 }
|
||||
}
|
||||
184
src/modules/grades/hooks/use-draft-lock.ts
Normal file
184
src/modules/grades/hooks/use-draft-lock.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user