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
|
PAGE_SIZE
|
||||||
)
|
)
|
||||||
|
|
||||||
// 构建分页 URL:保留 ?status= 过滤参数(与 AnnouncementList 的过滤机制一致)
|
// v3: 不再将 buildPageHref 函数传给 Client Component(Server 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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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` 函数 prop(Server 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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算保留阈值日期(早于此日期的日志将被清理)
|
* 计算保留阈值日期(早于此日期的日志将被清理)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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。
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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,
|
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({
|
||||||
|
|||||||
Reference in New Issue
Block a user