feat(shared): add UI components, hooks, form fields, and action utils

- Add UI components: confirm-delete-dialog, empty-table-row, list-pagination, pagination, status-badge

- Add form-fields directory for reusable form field components

- Add hooks: use-action-mutation, use-action-query for server action integration

- Add action-utils lib for action state helpers

- Update a11y components, charts, global-search, onboarding-gate, question components

- Update UI components: chip-nav, filter-bar, page-header, stat-card, stat-item, switch, table

- Update hooks: use-action-with-toast, use-aria-live, use-debounce, use-local-storage, use-media-query, use-permission

- Update lib: a11y, ai, audit-logger, auth-guard, bcrypt-utils, change-logger, download, excel, file-storage, http-utils, login-logger, password-policy, password-security-service, permissions, rate-limit, role-utils, search-params, session, storage-provider

- Update types: action-state, permissions

- Update i18n messages (en, zh-CN) for dashboard, diagnostic, grades, lesson-preparation, settings
This commit is contained in:
SpecialX
2026-06-23 17:38:14 +08:00
parent 9ceb2b7b67
commit c4d3433cc9
25 changed files with 1986 additions and 28 deletions

View File

@@ -0,0 +1,171 @@
/**
* 共享错误处理工具:统一 Server Action 的错误响应与客户端 Action 调用模式。
*
* 设计目标:
* 1. 避免将内部错误消息(如 SQL 错误、堆栈信息)直接暴露给客户端
* 2. 统一 ActionState<T> 的失败结构
* 3. 为客户端调用 Server Action 提供 try/catch/finally 包装,防止 UI 永久卡 loading
*/
import type { ActionState } from "@/shared/types/action-state"
import { PermissionDeniedError } from "@/shared/lib/auth-guard"
/**
* 已知的业务错误类型,消息可以安全返回给客户端。
* 其他 Error 一律视为系统错误,返回通用消息。
*/
export class BusinessError extends Error {
constructor(
message: string,
public readonly code?: string
) {
super(message)
this.name = "BusinessError"
}
}
/**
* 资源不存在错误。消息可安全返回客户端。
*/
export class NotFoundError extends BusinessError {
constructor(resource: string) {
super(`${resource} 不存在`, "not_found")
this.name = "NotFoundError"
}
}
/**
* 输入校验错误。消息可安全返回客户端。
*/
export class ValidationError extends BusinessError {
constructor(message: string) {
super(message, "validation_error")
this.name = "ValidationError"
}
}
/**
* 统一的 Server Action 错误处理器。
*
* - PermissionDeniedError:返回权限不足消息(可安全暴露)
* - BusinessError / NotFoundError / ValidationError:返回其 message(可安全暴露)
* - 其他 Error:返回通用消息,原始错误通过 console.error 记录到服务端日志
*
* @returns ActionState<never> 的失败分支
*/
export function handleActionError(e: unknown): ActionState<never> {
// 权限错误:消息已由 PermissionDeniedError 构造为用户友好文案
if (e instanceof PermissionDeniedError) {
return { success: false, message: e.message }
}
// 业务错误:消息可安全暴露
if (e instanceof BusinessError) {
return { success: false, message: e.message }
}
// 未知错误:不暴露内部细节,仅记录服务端日志
if (e instanceof Error) {
console.error("[ActionError]", e.name, e.message, e.stack)
return { success: false, message: "操作失败,请稍后重试" }
}
console.error("[ActionError] Unknown error:", e)
return { success: false, message: "操作失败,请稍后重试" }
}
/**
* 安全地调用 Server Action,自动处理 try/catch/finally。
*
* 用于客户端组件中调用 Server Action,确保:
* 1. 网络错误或 Action 抛出异常时,catch 块执行 onError 回调
* 2. 无论成功失败,finally 块执行 onFinally 回调(用于重置 loading 状态)
*
* @example
* ```tsx
* const [isSubmitting, setIsSubmitting] = useState(false)
* const result = await safeActionCall(
* () => createGradeRecordAction(null, formData),
* {
* onError: () => toast.error("保存失败"),
* onFinally: () => setIsSubmitting(false),
* }
* )
* if (result?.success) { toast.success("保存成功") }
* ```
*/
export async function safeActionCall<T>(
action: () => Promise<ActionState<T>>,
options?: {
onError?: (error: unknown) => void
onFinally?: () => void
}
): Promise<ActionState<T> | null> {
try {
return await action()
} catch (e) {
// Action 抛出异常(非返回 failure),如网络错误、序列化错误等
options?.onError?.(e)
console.error("[SafeActionCall]", e)
return null
} finally {
options?.onFinally?.()
}
}
/**
* 安全解析 JSON 字符串,失败时抛出 ValidationError。
*
* 用于 Server Action 中包装 JSON.parse,避免 SyntaxError 被外层 catch
* 捕获后暴露解析细节给客户端。
*
* @example
* ```ts
* const records = safeJsonParse(recordsJson, "成绩数据格式无效")
* ```
*/
export function safeJsonParse<T>(json: string, errorMessage: string): T {
try {
return JSON.parse(json) as T
} catch {
throw new ValidationError(errorMessage)
}
}
/**
* 校验日期字符串是否有效,无效则抛出 ValidationError。
*
* @returns 解析后的 Date 对象
*/
export function safeParseDate(value: string, fieldName: string): Date {
const d = new Date(value)
if (Number.isNaN(d.getTime())) {
throw new ValidationError(`${fieldName} 格式无效`)
}
return d
}
/**
* 校验数字字符串,无效则抛出 ValidationError。
*
* @returns 解析后的 number
*/
export function safeParseNumber(value: string, fieldName: string): number {
const n = Number(value)
if (!Number.isFinite(n)) {
throw new ValidationError(`${fieldName} 必须是有效数字`)
}
return n
}
/**
* 转义 SQL LIKE 通配符(% 和 _),防止用户输入干扰模糊查询。
*
* @example
* ```ts
* const needle = `%${escapeLikePattern(q)}%`
* ```
*/
export function escapeLikePattern(input: string): string {
return input.replace(/[%_\\]/g, "\\$&")
}

View File

@@ -63,6 +63,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.FILE_READ,
Permissions.FILE_DELETE,
Permissions.DASHBOARD_ADMIN_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
],
teacher: [
Permissions.EXAM_CREATE,
@@ -107,6 +108,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.LESSON_PLAN_DELETE,
Permissions.LESSON_PLAN_PUBLISH,
Permissions.DASHBOARD_TEACHER_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
],
student: [
Permissions.EXAM_READ,
@@ -128,6 +130,8 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.ELECTIVE_READ,
Permissions.DIAGNOSTIC_READ,
Permissions.DASHBOARD_STUDENT_READ,
Permissions.ERROR_BOOK_READ,
Permissions.ERROR_BOOK_MANAGE,
],
parent: [
Permissions.EXAM_READ,
@@ -141,6 +145,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.MESSAGE_READ,
Permissions.MESSAGE_DELETE,
Permissions.DASHBOARD_PARENT_READ,
Permissions.ERROR_BOOK_READ,
],
grade_head: [
Permissions.EXAM_CREATE,
@@ -178,6 +183,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_MANAGE,
Permissions.DIAGNOSTIC_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
],
teaching_head: [
Permissions.EXAM_CREATE,
@@ -210,6 +216,7 @@ export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
Permissions.ELECTIVE_READ,
Permissions.EXAM_PROCTOR_READ,
Permissions.DIAGNOSTIC_READ,
Permissions.ERROR_BOOK_ANALYTICS_READ,
],
}