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:
@@ -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 Component(Server 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -19,18 +19,23 @@ import { cn } from "@/shared/lib/utils"
|
||||
* - a11y:上一页/下一页按钮使用 aria-label,当前页用 aria-current="page"
|
||||
* - 边界处理:total=0 时不渲染分页;首页时上一页禁用;末页时下一页禁用
|
||||
* - 计算窗口:当总页数 > 7 时显示首末页 + 当前页 ±1 + 省略号,避免溢出
|
||||
*
|
||||
* v3: 不再接收 `buildHref` 函数 prop(Server 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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算保留阈值日期(早于此日期的日志将被清理)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
*
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 规则)。
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
19
src/modules/course-plans/lib/track-event.ts
Normal file
19
src/modules/course-plans/lib/track-event.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user