feat(business): update audit, auth, course-plans, announcements, exams

- audit: update retention.ts, types.ts, audit-retention-settings.tsx

- auth: update actions.ts and types.ts

- course-plans: update actions.ts, course-plan-detail.tsx, template-picker-dialog.tsx,

  add lib/track-event.ts

- announcements: update page.tsx, announcement-list.tsx, announcement-pagination.tsx

- exams: update actions.ts
This commit is contained in:
SpecialX
2026-07-04 10:23:03 +08:00
parent b63d116b6c
commit 98429e87eb
13 changed files with 114 additions and 61 deletions

View File

@@ -35,14 +35,11 @@ export default async function AnnouncementsPage({
PAGE_SIZE PAGE_SIZE
) )
// 构建分页 URL保留 ?status= 过滤参数(与 AnnouncementList 的过滤机制一致) // v3: 不再将 buildPageHref 函数传给 Client ComponentServer Component 不能传函数)。
const buildPageHref = (targetPage: number): string => { // 改为传 basePath + statusFilter由 AnnouncementPagination 在客户端自行构建 URL。
const params = new URLSearchParams() const statusFilter = sp.status === "published" || sp.status === "draft" || sp.status === "archived"
if (sp.status) params.set("status", sp.status) ? sp.status
if (targetPage > 1) params.set("page", String(targetPage)) : "all"
const qs = params.toString()
return qs ? `/announcements?${qs}` : "/announcements"
}
return ( return (
<AnnouncementsServiceProvider> <AnnouncementsServiceProvider>
@@ -56,12 +53,13 @@ export default async function AnnouncementsPage({
<AnnouncementList <AnnouncementList
announcements={items} announcements={items}
detailHrefPrefix="/announcements" detailHrefPrefix="/announcements"
initialStatus={sp.status === "published" || sp.status === "draft" || sp.status === "archived" ? sp.status : "all"} initialStatus={statusFilter}
pagination={{ pagination={{
page: currentPage, page: currentPage,
pageSize, pageSize,
total, total,
buildPageHref, basePath: "/announcements",
statusFilter,
}} }}
/> />
</div> </div>

View File

@@ -34,8 +34,9 @@ type Filter = "all" | AnnouncementStatus
* P2-4: 删除了死 prop `detailHrefBuilder`,仅保留 `detailHrefPrefix`Server Component 安全)。 * P2-4: 删除了死 prop `detailHrefBuilder`,仅保留 `detailHrefPrefix`Server Component 安全)。
* P1-2: 用户端canManage=false时调用 `getReadStatus` 批量获取已读状态, * P1-2: 用户端canManage=false时调用 `getReadStatus` 批量获取已读状态,
* 传入卡片做已读/未读视觉区分。 * 传入卡片做已读/未读视觉区分。
* P2-6: 新增分页支持。传入 `pagination` prop 时在列表底部渲染 `AnnouncementPagination` * P2-6: 新增分页支持。传入 `pagination` prop 时在列表底部渲染 `AnnouncementPagination`
* 通过 `buildPageHref` 回调生成页码 URL与 `?status=` 过滤查询参数合并)。 * v3: pagination 不再接收 `buildPageHref` 函数Server Component 不能传函数),
* 改为传 `basePath` + `statusFilter`,由 AnnouncementPagination 自行构建 URL。
*/ */
export function AnnouncementList({ export function AnnouncementList({
announcements, announcements,
@@ -54,7 +55,8 @@ export function AnnouncementList({
page: number page: number
pageSize: number pageSize: number
total: number total: number
buildPageHref: (page: number) => string basePath: string
statusFilter?: string
} }
}) { }) {
const t = useTranslations("announcements") const t = useTranslations("announcements")
@@ -152,7 +154,8 @@ export function AnnouncementList({
page={pagination.page} page={pagination.page}
pageSize={pagination.pageSize} pageSize={pagination.pageSize}
total={pagination.total} total={pagination.total}
buildHref={pagination.buildPageHref} basePath={pagination.basePath}
statusFilter={pagination.statusFilter}
/> />
) : null} ) : null}
</div> </div>

View File

@@ -19,18 +19,23 @@ import { cn } from "@/shared/lib/utils"
* - a11y上一页/下一页按钮使用 aria-label当前页用 aria-current="page" * - a11y上一页/下一页按钮使用 aria-label当前页用 aria-current="page"
* - 边界处理total=0 时不渲染分页;首页时上一页禁用;末页时下一页禁用 * - 边界处理total=0 时不渲染分页;首页时上一页禁用;末页时下一页禁用
* - 计算窗口:当总页数 > 7 时显示首末页 + 当前页 ±1 + 省略号,避免溢出 * - 计算窗口:当总页数 > 7 时显示首末页 + 当前页 ±1 + 省略号,避免溢出
*
* v3: 不再接收 `buildHref` 函数 propServer Component 不能传函数给 Client Component
* 改为接收 `basePath` + `statusFilter`,在客户端自行构建分页 URL。
*/ */
export function AnnouncementPagination({ export function AnnouncementPagination({
page, page,
pageSize, pageSize,
total, total,
buildHref, basePath,
statusFilter,
className, className,
}: { }: {
page: number page: number
pageSize: number pageSize: number
total: number total: number
buildHref: (page: number) => string basePath: string
statusFilter?: string
className?: string className?: string
}) { }) {
const t = useTranslations("announcements") const t = useTranslations("announcements")
@@ -38,6 +43,15 @@ export function AnnouncementPagination({
const totalPages = Math.max(1, Math.ceil(total / pageSize)) const totalPages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(Math.max(1, page), totalPages) const currentPage = Math.min(Math.max(1, page), totalPages)
// 客户端构建分页 URL保留 ?status= 过滤参数
const buildHref = (targetPage: number): string => {
const params = new URLSearchParams()
if (statusFilter && statusFilter !== "all") params.set("status", statusFilter)
if (targetPage > 1) params.set("page", String(targetPage))
const qs = params.toString()
return qs ? `${basePath}?${qs}` : basePath
}
const pages = useMemo(() => { const pages = useMemo(() => {
if (totalPages <= 7) { if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_, i) => i + 1) return Array.from({ length: totalPages }, (_, i) => i + 1)

View File

@@ -20,7 +20,7 @@ import { useAuditService, useAuditAnalytics } from "@/modules/audit/services/aud
import { import {
MIN_RETENTION_DAYS, MIN_RETENTION_DAYS,
MAX_RETENTION_DAYS, MAX_RETENTION_DAYS,
} from "@/modules/audit/retention" } from "@/modules/audit/types"
import type { AuditRetentionConfig } from "@/modules/audit/types" import type { AuditRetentionConfig } from "@/modules/audit/types"
export function AuditRetentionSettings(): ReactNode { export function AuditRetentionSettings(): ReactNode {

View File

@@ -8,19 +8,21 @@ import {
getSystemSetting, getSystemSetting,
upsertSystemSetting, upsertSystemSetting,
} from "@/modules/settings/data-access-system-settings" } from "@/modules/settings/data-access-system-settings"
import {
DEFAULT_RETENTION_DAYS,
DEFAULT_LOGIN_LOG_RETENTION_DAYS,
MIN_RETENTION_DAYS,
MAX_RETENTION_DAYS,
} from "./types"
import type { AuditRetentionConfig, PurgeResult } from "./types" import type { AuditRetentionConfig, PurgeResult } from "./types"
/** 默认保留天数 */ // 保留天数常量从 types.ts 重新导出,保持现有 import 路径兼容
export const DEFAULT_RETENTION_DAYS = 180 export {
DEFAULT_RETENTION_DAYS,
/** audit-P2-5: 登录日志默认保留天数1 年,比审计日志更长以支持安全取证) */ DEFAULT_LOGIN_LOG_RETENTION_DAYS,
export const DEFAULT_LOGIN_LOG_RETENTION_DAYS = 365 MIN_RETENTION_DAYS,
MAX_RETENTION_DAYS,
/** 最小保留天数(防止误设为 0 导致全量删除) */ }
export const MIN_RETENTION_DAYS = 30
/** 最大保留天数 */
export const MAX_RETENTION_DAYS = 3650
/** /**
* 计算保留阈值日期(早于此日期的日志将被清理) * 计算保留阈值日期(早于此日期的日志将被清理)

View File

@@ -31,6 +31,24 @@ export const DATA_CHANGE_ACTION_CLASS_NAME: StatusClassNameMap<DataChangeAction>
delete: "bg-red-600 hover:bg-red-700 border-transparent", delete: "bg-red-600 hover:bg-red-700 border-transparent",
} }
/**
* 审计日志保留天数常量。
*
* 定义在 types.ts而非 retention.ts以便客户端组件直接导入
* 避免 "use client" 组件经 retention.ts 间接拉入 mysql2 / server-only 依赖。
*/
/** 默认保留天数 */
export const DEFAULT_RETENTION_DAYS = 180
/** audit-P2-5: 登录日志默认保留天数1 年,比审计日志更长以支持安全取证) */
export const DEFAULT_LOGIN_LOG_RETENTION_DAYS = 365
/** 最小保留天数(防止误设为 0 导致全量删除) */
export const MIN_RETENTION_DAYS = 30
/** 最大保留天数 */
export const MAX_RETENTION_DAYS = 3650
export interface AuditLog { export interface AuditLog {
id: string id: string
userId: string userId: string

View File

@@ -17,7 +17,11 @@ import {
import { RegisterSchema } from "./schema" import { RegisterSchema } from "./schema"
import { buildRegisterInput, createUser, isEmailAvailable } from "./data-access" import { buildRegisterInput, createUser, isEmailAvailable } from "./data-access"
import { preflightTwoFactorByEmail } from "./services/two-factor-service" import { preflightTwoFactorByEmail } from "./services/two-factor-service"
import type { RegisterResult } from "./types" import {
REGISTER_ERROR_CODES,
type RegisterErrorCode,
type RegisterResult,
} from "./types"
/** /**
* 注册速率限制规则。 * 注册速率限制规则。
@@ -30,22 +34,6 @@ const REGISTER_RATE_LIMIT = {
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
} as const } as const
/**
* 注册错误码枚举(前端通过 t(`register.errors.${errorCode}`) 查 i18n 翻译)。
*/
export const REGISTER_ERROR_CODES = {
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
VALIDATION_FAILED: "VALIDATION_FAILED",
EMAIL_TAKEN: "EMAIL_TAKEN",
BREACHED_PASSWORD: "BREACHED_PASSWORD",
INVITATION_CODE_INVALID: "INVITATION_CODE_INVALID",
INVITATION_CODE_USED: "INVITATION_CODE_USED",
DEFAULT_ROLE_MISSING: "DEFAULT_ROLE_MISSING",
UNKNOWN: "UNKNOWN",
} as const
export type RegisterErrorCode = typeof REGISTER_ERROR_CODES[keyof typeof REGISTER_ERROR_CODES]
/** /**
* 将原始数据库/系统错误归一化为 RegisterErrorCode。 * 将原始数据库/系统错误归一化为 RegisterErrorCode。
* *

View File

@@ -43,3 +43,22 @@ export interface RegisterInput {
export interface EmailAvailabilityResult { export interface EmailAvailabilityResult {
available: boolean available: boolean
} }
/**
* 注册错误码枚举(前端通过 t(`register.errors.${errorCode}`) 查 i18n 翻译)。
*
* 定义在 types.ts而非 actions.ts以便客户端组件直接导入
* 避免 "use server" 文件导出非 async 值Next.js 16 规则)。
*/
export const REGISTER_ERROR_CODES = {
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
VALIDATION_FAILED: "VALIDATION_FAILED",
EMAIL_TAKEN: "EMAIL_TAKEN",
BREACHED_PASSWORD: "BREACHED_PASSWORD",
INVITATION_CODE_INVALID: "INVITATION_CODE_INVALID",
INVITATION_CODE_USED: "INVITATION_CODE_USED",
DEFAULT_ROLE_MISSING: "DEFAULT_ROLE_MISSING",
UNKNOWN: "UNKNOWN",
} as const
export type RegisterErrorCode = (typeof REGISTER_ERROR_CODES)[keyof typeof REGISTER_ERROR_CODES]

View File

@@ -483,14 +483,5 @@ export async function getGradeCoursePlanProgressAction(
} }
// ── 监控埋点接口P2-8──────────────────────────────────── // ── 监控埋点接口P2-8────────────────────────────────────
// 预留埋点接口,供前端调用以记录关键操作。 // trackCoursePlanEvent 已移至 lib/track-event.ts因其为同步函数
// 当前为空实现,后续接入监控 SDK 时只需修改此函数 // 不允许出现在 "use server" 文件中Next.js 16 规则)
export function trackCoursePlanEvent(
event: string,
properties?: Record<string, unknown>
): void {
// 预留:接入监控 SDK 后实现
if (process.env.NODE_ENV === "development") {
console.debug(`[course-plans] ${event}`, properties)
}
}

View File

@@ -47,8 +47,8 @@ import {
deleteCoursePlanAction, deleteCoursePlanAction,
bulkToggleItemsAction, bulkToggleItemsAction,
reorderCoursePlanItemsAction, reorderCoursePlanItemsAction,
trackCoursePlanEvent,
} from "../actions" } from "../actions"
import { trackCoursePlanEvent } from "../lib/track-event"
import { exportCoursePlanReport } from "../lib/export-utils" import { exportCoursePlanReport } from "../lib/export-utils"
import type { CoursePlanWithItems, ReorderCoursePlanItemInput } from "../types" import type { CoursePlanWithItems, ReorderCoursePlanItemInput } from "../types"

View File

@@ -25,7 +25,7 @@ import {
getTemplateCandidatesAction, getTemplateCandidatesAction,
copyCoursePlanAction, copyCoursePlanAction,
} from "../actions" } from "../actions"
import { trackCoursePlanEvent } from "../actions" import { trackCoursePlanEvent } from "../lib/track-event"
import type { CoursePlanListItem } from "../types" import type { CoursePlanListItem } from "../types"
interface TemplatePickerDialogProps { interface TemplatePickerDialogProps {

View File

@@ -0,0 +1,19 @@
/**
* 课程计划监控埋点接口P2-8
*
* 供客户端组件直接调用以记录关键操作。
* 当前为空实现,后续接入监控 SDK 时只需修改此函数。
*
* 注意:此文件不得标记 "use server",因为 trackCoursePlanEvent 是同步函数,
* 不涉及服务端操作。Next.js 16 要求 "use server" 文件中所有导出必须是 async 函数。
*/
export function trackCoursePlanEvent(
event: string,
properties?: Record<string, unknown>,
): void {
// 预留:接入监控 SDK 后实现
if (process.env.NODE_ENV === "development") {
console.debug(`[course-plans] ${event}`, properties)
}
}

View File

@@ -44,10 +44,11 @@ import {
successState, successState,
} from "./actions-helpers" } from "./actions-helpers"
// Re-export 从拆分文件迁移的导出,保持外部 import 路径不变 // Re-export 从拆分文件迁移的类型导出,保持外部 import 路径不变
// 注意Next.js 16 禁止在 "use server" 文件中 re-export 值(仅允许 async 函数导出),
// autoMarkExamAction / createExamFromRichEditorAction / updateExamFromRichEditorAction
// 的值 re-export 已移除,调用方直接从源文件 import。
export type { AutoMarkResult } from "./ai-pipeline/auto-mark" export type { AutoMarkResult } from "./ai-pipeline/auto-mark"
export { autoMarkExamAction } from "./ai-pipeline/auto-mark"
export { createExamFromRichEditorAction, updateExamFromRichEditorAction } from "./actions-rich-editor"
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline" export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
const ExamCreateSchema = z.object({ const ExamCreateSchema = z.object({