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:
171
src/shared/lib/action-utils.ts
Normal file
171
src/shared/lib/action-utils.ts
Normal 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, "\\$&")
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user