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

View File

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

View File

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

View File

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

View File

@@ -8,19 +8,21 @@ import {
getSystemSetting,
upsertSystemSetting,
} 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"
/** 默认保留天数 */
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
// 保留天数常量从 types.ts 重新导出,保持现有 import 路径兼容
export {
DEFAULT_RETENTION_DAYS,
DEFAULT_LOGIN_LOG_RETENTION_DAYS,
MIN_RETENTION_DAYS,
MAX_RETENTION_DAYS,
}
/**
* 计算保留阈值日期(早于此日期的日志将被清理)

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",
}
/**
* 审计日志保留天数常量。
*
* 定义在 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 {
id: string
userId: string

View File

@@ -17,7 +17,11 @@ import {
import { RegisterSchema } from "./schema"
import { buildRegisterInput, createUser, isEmailAvailable } from "./data-access"
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,
} 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。
*

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ import {
getTemplateCandidatesAction,
copyCoursePlanAction,
} from "../actions"
import { trackCoursePlanEvent } from "../actions"
import { trackCoursePlanEvent } from "../lib/track-event"
import type { CoursePlanListItem } from "../types"
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,
} 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 { autoMarkExamAction } from "./ai-pipeline/auto-mark"
export { createExamFromRichEditorAction, updateExamFromRichEditorAction } from "./actions-rich-editor"
export type { AiPreviewData, AiRewriteQuestionData } from "./ai-pipeline"
const ExamCreateSchema = z.object({