docs: ai 协作文档体系重构与多 ai 仲裁结果落地
1.AI 协作文档体系重构(objections/worklines/contracts+matrix.md) 2.coord 仲裁文档(final-decisions/cross-review/final-rulings/orchestration) 3.各服务 01/02 文档补全 4.共享包初始化(shared-ts/shared-go/hooks/ui-components/ui-tokens) 5.Proto 契约补全 6.004 架构影响地图更新 7.端口分配表 8.设计规格文档
This commit is contained in:
24
packages/hooks/package.json
Normal file
24
packages/hooks/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@edu/hooks",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edu/ui-components": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
62
packages/hooks/src/index.ts
Normal file
62
packages/hooks/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* @edu/hooks - 共享 Hooks 库
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联文档:teacher-portal 02-architecture-design.md §8
|
||||
*
|
||||
* 包含:
|
||||
* - useAuth:会话状态(user/token/login/logout/refresh)
|
||||
* - usePermission:权限查询(hasPermission/hasAny/hasAll + dataScope)
|
||||
* - useViewports:视口列表(按 scope 过滤)
|
||||
* - useApi:ApiClient 实例(注入 token + 401 处理 + trace_id)
|
||||
* - useA11yId:唯一 ARIA ID 生成
|
||||
* - useAriaLive:aria-live 区域管理
|
||||
* - useToast:全局 toast 通知
|
||||
* - useTraceId:当前会话 trace_id
|
||||
*
|
||||
* 设计原则:
|
||||
* - hooks 不直接调 API,保持纯客户端逻辑(API 调用由 apps/* 注入)
|
||||
* - 权限/视口数据通过 props 注入,避免 hooks 包耦合 iam
|
||||
* - token 存储使用 localStorage(F12 裁决,P2 阶段)
|
||||
*/
|
||||
|
||||
export { useAuth } from "./use-auth.js";
|
||||
export type { UseAuthReturn } from "./use-auth.js";
|
||||
|
||||
export { usePermission } from "./use-permission.js";
|
||||
export type {
|
||||
UsePermissionProps,
|
||||
UsePermissionReturn,
|
||||
} from "./use-permission.js";
|
||||
|
||||
export { useViewports } from "./use-viewports.js";
|
||||
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports.js";
|
||||
|
||||
export { useApi } from "./use-api.js";
|
||||
export type { UseApiProps, UseApiReturn } from "./use-api.js";
|
||||
|
||||
export {
|
||||
useA11yId,
|
||||
useA11yIds,
|
||||
mergeA11yProps,
|
||||
describeInput,
|
||||
} from "./use-a11y-id.js";
|
||||
|
||||
export { useAriaLive } from "./use-aria-live.js";
|
||||
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live.js";
|
||||
|
||||
export { useToast } from "./use-toast.js";
|
||||
export type { UseToastReturn } from "./use-toast.js";
|
||||
|
||||
export { useTraceId } from "./use-trace-id.js";
|
||||
export type { UseTraceIdReturn } from "./use-trace-id.js";
|
||||
|
||||
// 共享类型
|
||||
export type {
|
||||
UserSession,
|
||||
DataScope,
|
||||
PermissionContext,
|
||||
Viewport,
|
||||
ToastMessage,
|
||||
AuthState,
|
||||
} from "./types.js";
|
||||
68
packages/hooks/src/types.ts
Normal file
68
packages/hooks/src/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 共享类型定义(hooks 包内部使用)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户会话信息
|
||||
*/
|
||||
export interface UserSession {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
roles: string[];
|
||||
/** 数据范围(DataScope 6 级) */
|
||||
dataScope?: DataScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围(对齐 iam DataScope 6 级)
|
||||
*/
|
||||
export type DataScope =
|
||||
"ALL" | "SCHOOL" | "GRADE" | "CLASS" | "SUBJECT" | "SELF";
|
||||
|
||||
/**
|
||||
* 权限点检查结果
|
||||
*/
|
||||
export interface PermissionContext {
|
||||
/** 当前用户拥有的权限点列表 */
|
||||
permissions: string[];
|
||||
/** 当前用户的数据范围 */
|
||||
dataScope: DataScope;
|
||||
/** 当前用户的角色列表 */
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 视口(Viewport)- iam 4 层视口模型
|
||||
*/
|
||||
export interface Viewport {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "admin" | "teacher" | "student" | "parent";
|
||||
scope: DataScope;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知消息(Toast)
|
||||
*/
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: "success" | "error" | "warning" | "info";
|
||||
title: string;
|
||||
description?: string;
|
||||
/** 自动关闭时长(ms),0 表示不自动关闭 */
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 鉴权状态
|
||||
*/
|
||||
export interface AuthState {
|
||||
user: UserSession | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
119
packages/hooks/src/use-a11y-id.ts
Normal file
119
packages/hooks/src/use-a11y-id.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
/**
|
||||
* useA11yId - 唯一 ARIA ID 生成器
|
||||
*
|
||||
* 用途:为表单元素生成 aria-labelledby / aria-describedby 关联的唯一 ID。
|
||||
* 迁移自 CICD 项目 A11y 工具集。
|
||||
*
|
||||
* @example
|
||||
* const id = useA11yId("email-input");
|
||||
* <input id={id} aria-describedby={`${id}-error`} />
|
||||
* <span id={`${id}-error`}>{errorMessage}</span>
|
||||
*/
|
||||
|
||||
let idCounter = 0;
|
||||
|
||||
/**
|
||||
* 生成唯一 ID(带可选前缀)。
|
||||
* 使用计数器 + 随机数,避免 SSR/CSR hydration mismatch。
|
||||
*/
|
||||
export function useA11yId(prefix?: string): string {
|
||||
const [id] = useState(() => {
|
||||
idCounter += 1;
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
const base = prefix
|
||||
? `${prefix}-${idCounter}-${random}`
|
||||
: `a11y-${idCounter}-${random}`;
|
||||
return base;
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量生成关联 ID(用于表单 input + label + error + description 关联)
|
||||
*
|
||||
* @example
|
||||
* const ids = useA11yIds("email");
|
||||
* // ids = { input: "email-input-1-xxx", label: "email-label-1-xxx", error: "email-error-1-xxx", description: "email-description-1-xxx" }
|
||||
*/
|
||||
export function useA11yIds(prefix: string): {
|
||||
input: string;
|
||||
label: string;
|
||||
error: string;
|
||||
description: string;
|
||||
} {
|
||||
const inputId = useA11yId(`${prefix}-input`);
|
||||
const labelId = useA11yId(`${prefix}-label`);
|
||||
const errorId = useA11yId(`${prefix}-error`);
|
||||
const descriptionId = useA11yId(`${prefix}-description`);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
input: inputId,
|
||||
label: labelId,
|
||||
error: errorId,
|
||||
description: descriptionId,
|
||||
}),
|
||||
[inputId, labelId, errorId, descriptionId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 ARIA 属性(覆盖优先级:后者覆盖前者)
|
||||
*
|
||||
* @example
|
||||
* mergeA11yProps({ "aria-label": "默认" }, { "aria-label": "自定义" })
|
||||
* // => { "aria-label": "自定义" }
|
||||
*/
|
||||
export function mergeA11yProps(
|
||||
...props: Array<Record<string, unknown> | undefined>
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const prop of props) {
|
||||
if (prop) {
|
||||
Object.assign(result, prop);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 描述输入框的 ARIA 属性
|
||||
*
|
||||
* @example
|
||||
* const a11y = describeInput({ label: "邮箱", required: true, error: "邮箱格式错误", description: "请输入工作邮箱" });
|
||||
* <input {...a11y} />
|
||||
*/
|
||||
export function describeInput(options: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
error?: string;
|
||||
description?: string;
|
||||
invalid?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
const { label, required, error, description, invalid } = options;
|
||||
const props: Record<string, unknown> = {
|
||||
"aria-label": label,
|
||||
};
|
||||
|
||||
if (required) {
|
||||
props["aria-required"] = true;
|
||||
}
|
||||
|
||||
if (invalid || error) {
|
||||
props["aria-invalid"] = true;
|
||||
}
|
||||
|
||||
// aria-describedby 由调用方拼接(需关联 error/description 的 ID)
|
||||
const describedBy: string[] = [];
|
||||
if (description) describedBy.push("description");
|
||||
if (error) describedBy.push("error");
|
||||
|
||||
if (describedBy.length > 0) {
|
||||
props["aria-describedby"] = describedBy.join(" ");
|
||||
}
|
||||
|
||||
return props;
|
||||
}
|
||||
90
packages/hooks/src/use-api.ts
Normal file
90
packages/hooks/src/use-api.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
/**
|
||||
* useApi - ApiClient 实例管理
|
||||
*
|
||||
* 用途:注入 token + 401 处理 + trace_id 透传。
|
||||
* P2 阶段:GraphQL client(urql/apollo)注入;本 hook 提供统一 fetcher 封装。
|
||||
*
|
||||
* 注意:本 hook 不直接创建 GraphQL client(由 Shell 的 GraphQLProvider 注入)。
|
||||
* 本 hook 提供"API 调用层"封装,用于非 GraphQL 场景(如文件上传 / SSE 流)。
|
||||
*
|
||||
* @example
|
||||
* const { fetchJson, fetchWithAuth } = useApi({ token, traceId });
|
||||
* const data = await fetchJson("/api/v1/upload", { method: "POST", body: formData });
|
||||
*/
|
||||
|
||||
export interface UseApiProps {
|
||||
/** 认证 token(从 useAuth 获取) */
|
||||
token: string | null;
|
||||
/** trace_id(从 useTraceId 获取) */
|
||||
traceId?: string | null;
|
||||
/** 401 回调(通常跳转登录页) */
|
||||
onUnauthorized?: () => void;
|
||||
}
|
||||
|
||||
export interface UseApiReturn {
|
||||
/** 带 auth + trace_id 的 fetch 封装 */
|
||||
fetchWithAuth: (url: string, init?: RequestInit) => Promise<Response>;
|
||||
/** fetchJson - 解析 JSON 响应 */
|
||||
fetchJson: <T>(url: string, init?: RequestInit) => Promise<T>;
|
||||
/** 构造带 auth 的 headers */
|
||||
authHeaders: Record<string, string>;
|
||||
}
|
||||
|
||||
export function useApi({
|
||||
token,
|
||||
traceId,
|
||||
onUnauthorized,
|
||||
}: UseApiProps): UseApiReturn {
|
||||
const authHeaders = useMemo(() => {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
if (traceId) {
|
||||
headers["X-Trace-Id"] = traceId;
|
||||
}
|
||||
return headers;
|
||||
}, [token, traceId]);
|
||||
|
||||
const fetchWithAuth = useCallback(
|
||||
async (url: string, init?: RequestInit): Promise<Response> => {
|
||||
const mergedInit: RequestInit = {
|
||||
...init,
|
||||
headers: {
|
||||
...authHeaders,
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
const response = await fetch(url, mergedInit);
|
||||
|
||||
if (response.status === 401) {
|
||||
onUnauthorized?.();
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
[authHeaders, onUnauthorized],
|
||||
);
|
||||
|
||||
const fetchJson = useCallback(
|
||||
async <T>(url: string, init?: RequestInit): Promise<T> => {
|
||||
const response = await fetchWithAuth(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
},
|
||||
[fetchWithAuth],
|
||||
);
|
||||
|
||||
return {
|
||||
fetchWithAuth,
|
||||
fetchJson,
|
||||
authHeaders,
|
||||
};
|
||||
}
|
||||
52
packages/hooks/src/use-aria-live.ts
Normal file
52
packages/hooks/src/use-aria-live.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* useAriaLive - aria-live 区域管理
|
||||
*
|
||||
* 用途:动态向 aria-live 区域推送消息,供屏幕阅读器播报。
|
||||
* 迁移自 CICD 项目 A11y 工具集。
|
||||
*
|
||||
* @example
|
||||
* const { message, announce } = useAriaLive();
|
||||
* announce("表单提交成功", "polite");
|
||||
* // 渲染:<div aria-live="polite">{message}</div>
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
export type AriaLivePoliteness = "polite" | "assertive" | "off";
|
||||
|
||||
export interface UseAriaLiveReturn {
|
||||
/** 当前消息内容 */
|
||||
message: string;
|
||||
/** 当前 politeness 级别 */
|
||||
politeness: AriaLivePoliteness;
|
||||
/** 推送消息 */
|
||||
announce: (message: string, politeness?: AriaLivePoliteness) => void;
|
||||
/** 清除消息 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function useAriaLive(): UseAriaLiveReturn {
|
||||
const [message, setMessage] = useState("");
|
||||
const [politeness, setPoliteness] = useState<AriaLivePoliteness>("polite");
|
||||
|
||||
const announce = useCallback(
|
||||
(msg: string, level: AriaLivePoliteness = "polite"): void => {
|
||||
setPoliteness(level);
|
||||
// 先清空再设置,确保相同消息也能触发播报
|
||||
setMessage("");
|
||||
requestAnimationFrame(() => setMessage(msg));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
setMessage("");
|
||||
}, []);
|
||||
|
||||
return {
|
||||
message,
|
||||
politeness,
|
||||
announce,
|
||||
clear,
|
||||
};
|
||||
}
|
||||
93
packages/hooks/src/use-auth.ts
Normal file
93
packages/hooks/src/use-auth.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AuthState, UserSession } from "./types.js";
|
||||
|
||||
/**
|
||||
* useAuth - 会话状态管理
|
||||
*
|
||||
* 用途:管理用户登录状态、token、用户信息。
|
||||
* P2 阶段:token 存储在 localStorage(F12 裁决),P6 评估迁移 httpOnly Cookie。
|
||||
*
|
||||
* 注意:本 hook 提供"状态管理"骨架,具体的登录/登出 API 调用
|
||||
* 由 apps/* 注入(通过 AuthProvider Context 或 props 传入 fetcher)。
|
||||
*
|
||||
* @example
|
||||
* const { user, isAuthenticated, login, logout } = useAuth();
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = "edu_auth_token";
|
||||
const USER_KEY = "edu_auth_user";
|
||||
|
||||
export interface UseAuthReturn extends AuthState {
|
||||
/** 登录(由调用方注入 fetcher) */
|
||||
login: (token: string, user: UserSession) => void;
|
||||
/** 登出 */
|
||||
logout: () => void;
|
||||
/** 刷新用户信息 */
|
||||
refreshUser: (user: UserSession) => void;
|
||||
}
|
||||
|
||||
export function useAuth(): UseAuthReturn {
|
||||
const [state, setState] = useState<AuthState>({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
// 初始化:从 localStorage 读取 token + user(F12 裁决 P2 用 localStorage)
|
||||
useEffect(() => {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const userJson = localStorage.getItem(USER_KEY);
|
||||
const user = userJson ? (JSON.parse(userJson) as UserSession) : null;
|
||||
|
||||
setState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: Boolean(token && user),
|
||||
isLoading: false,
|
||||
});
|
||||
} catch {
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const login = useCallback((token: string, user: UserSession): void => {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
setState({
|
||||
user,
|
||||
token,
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const logout = useCallback((): void => {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setState({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback((user: UserSession): void => {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
setState((prev) => ({ ...prev, user }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
}
|
||||
76
packages/hooks/src/use-permission.ts
Normal file
76
packages/hooks/src/use-permission.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { PermissionContext } from "./types.js";
|
||||
|
||||
/**
|
||||
* usePermission - 权限查询 Hook
|
||||
*
|
||||
* 用途:L2 路由级 + L3 组件级视口控制。
|
||||
* 权限点命名遵循 F7 裁决:`<RESOURCE>_<ACTION>`(如 `EXAM_READ`)。
|
||||
* 数据范围用后缀 `_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`)。
|
||||
*
|
||||
* 注意:权限数据由调用方注入(apps/* 从 iam /iam/permissions/effective 获取后传入)。
|
||||
* 本 hook 不直接调 API,保持纯客户端逻辑。
|
||||
*
|
||||
* @example
|
||||
* const { hasPermission, hasAny, hasAll } = usePermission({ permissions, dataScope, roles });
|
||||
*
|
||||
* if (hasPermission("EXAM_CREATE")) { ... }
|
||||
* if (hasPermission("GRADE_READ_CHILD")) { ... } // 数据范围后缀
|
||||
*/
|
||||
|
||||
export interface UsePermissionProps {
|
||||
/** 权限上下文(从 iam /iam/permissions/effective 获取) */
|
||||
context: PermissionContext | null;
|
||||
}
|
||||
|
||||
export interface UsePermissionReturn {
|
||||
/** 检查是否拥有指定权限点 */
|
||||
hasPermission: (perm: string) => boolean;
|
||||
/** 检查是否拥有任一权限点 */
|
||||
hasAny: (...perms: string[]) => boolean;
|
||||
/** 检查是否拥有全部权限点 */
|
||||
hasAll: (...perms: string[]) => boolean;
|
||||
/** 检查是否拥有指定角色 */
|
||||
hasRole: (role: string) => boolean;
|
||||
/** 当前数据范围 */
|
||||
dataScope: PermissionContext["dataScope"] | null;
|
||||
}
|
||||
|
||||
export function usePermission({
|
||||
context,
|
||||
}: UsePermissionProps): UsePermissionReturn {
|
||||
const permissions = context?.permissions ?? [];
|
||||
const roles = context?.roles ?? [];
|
||||
const dataScope = context?.dataScope ?? null;
|
||||
|
||||
const permissionSet = useMemo(() => new Set(permissions), [permissions]);
|
||||
const roleSet = useMemo(() => new Set(roles), [roles]);
|
||||
|
||||
const hasPermission = useCallback(
|
||||
(perm: string): boolean => permissionSet.has(perm),
|
||||
[permissionSet],
|
||||
);
|
||||
|
||||
const hasAny = useCallback(
|
||||
(...perms: string[]): boolean => perms.some((p) => permissionSet.has(p)),
|
||||
[permissionSet],
|
||||
);
|
||||
|
||||
const hasAll = useCallback(
|
||||
(...perms: string[]): boolean => perms.every((p) => permissionSet.has(p)),
|
||||
[permissionSet],
|
||||
);
|
||||
|
||||
const hasRole = useCallback(
|
||||
(role: string): boolean => roleSet.has(role),
|
||||
[roleSet],
|
||||
);
|
||||
|
||||
return {
|
||||
hasPermission,
|
||||
hasAny,
|
||||
hasAll,
|
||||
hasRole,
|
||||
dataScope,
|
||||
};
|
||||
}
|
||||
66
packages/hooks/src/use-toast.ts
Normal file
66
packages/hooks/src/use-toast.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ToastMessage } from "./types.js";
|
||||
|
||||
/**
|
||||
* useToast - 全局 Toast 通知管理
|
||||
*
|
||||
* 用途:成功/错误/警告/信息提示,自动关闭。
|
||||
* 与 Zustand ui-store 解耦,提供 hook 层 API。
|
||||
*
|
||||
* @example
|
||||
* const { toasts, showToast, dismissToast } = useToast();
|
||||
* showToast({ type: "success", title: "保存成功" });
|
||||
*/
|
||||
|
||||
export interface UseToastReturn {
|
||||
/** 当前活跃的 toast 列表 */
|
||||
toasts: ToastMessage[];
|
||||
/** 展示 toast */
|
||||
showToast: (toast: Omit<ToastMessage, "id">) => string;
|
||||
/** 关闭指定 toast */
|
||||
dismissToast: (id: string) => void;
|
||||
/** 关闭全部 toast */
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
export function useToast(): UseToastReturn {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([]);
|
||||
|
||||
const dismissToast = useCallback((id: string): void => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
const showToast = useCallback(
|
||||
(toast: Omit<ToastMessage, "id">): string => {
|
||||
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const fullToast: ToastMessage = {
|
||||
id,
|
||||
duration: 4000,
|
||||
...toast,
|
||||
};
|
||||
|
||||
setToasts((prev) => [...prev, fullToast]);
|
||||
|
||||
// 自动关闭
|
||||
if (fullToast.duration && fullToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
dismissToast(id);
|
||||
}, fullToast.duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
[dismissToast],
|
||||
);
|
||||
|
||||
const dismissAll = useCallback((): void => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
dismissToast,
|
||||
dismissAll,
|
||||
};
|
||||
}
|
||||
61
packages/hooks/src/use-trace-id.ts
Normal file
61
packages/hooks/src/use-trace-id.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
/**
|
||||
* useTraceId - 当前会话 trace_id 管理
|
||||
*
|
||||
* 用途:OpenTelemetry traceparent 透传,用于日志关联 + 链路追踪。
|
||||
* ai13 新增(对齐 project_rules §12 可观测性规范)。
|
||||
*
|
||||
* @example
|
||||
* const { traceId, setTraceId, withTraceId } = useTraceId();
|
||||
* setTraceId("abc123");
|
||||
* withTraceId(() => fetch("/api/v1/exams", { headers: { "X-Trace-Id": traceId } }));
|
||||
*/
|
||||
|
||||
export interface UseTraceIdReturn {
|
||||
/** 当前 trace_id */
|
||||
traceId: string | null;
|
||||
/** 设置 trace_id(从响应头 X-Trace-Id 获取) */
|
||||
setTraceId: (id: string) => void;
|
||||
/** 清除 trace_id */
|
||||
clear: () => void;
|
||||
/**
|
||||
* 在 trace_id 上下文中执行回调(自动注入 header)
|
||||
* 用于 fetch 包装
|
||||
*/
|
||||
withTraceId: <T>(
|
||||
fn: (headers: Record<string, string>) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
}
|
||||
|
||||
export function useTraceId(): UseTraceIdReturn {
|
||||
const [traceId, setTraceIdState] = useState<string | null>(null);
|
||||
|
||||
const setTraceId = useCallback((id: string): void => {
|
||||
setTraceIdState(id);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback((): void => {
|
||||
setTraceIdState(null);
|
||||
}, []);
|
||||
|
||||
const withTraceId = useCallback(
|
||||
async <T>(
|
||||
fn: (headers: Record<string, string>) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const headers: Record<string, string> = {};
|
||||
if (traceId) {
|
||||
headers["X-Trace-Id"] = traceId;
|
||||
}
|
||||
return fn(headers);
|
||||
},
|
||||
[traceId],
|
||||
);
|
||||
|
||||
return {
|
||||
traceId,
|
||||
setTraceId,
|
||||
clear,
|
||||
withTraceId,
|
||||
};
|
||||
}
|
||||
65
packages/hooks/src/use-viewports.ts
Normal file
65
packages/hooks/src/use-viewports.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Viewport } from "./types.js";
|
||||
|
||||
/**
|
||||
* useViewports - 视口列表查询
|
||||
*
|
||||
* 用途:按 scope 过滤当前用户的可用视口(iam 4 层视口模型)。
|
||||
* 视口数据由调用方注入(apps/* 从 iam /iam/viewports 获取后传入)。
|
||||
*
|
||||
* iam 视口 4 层:admin / teacher / student / parent
|
||||
*
|
||||
* @example
|
||||
* const { viewports, getByType, hasViewport } = useViewports({ viewports });
|
||||
* const teacherViewports = getByType("teacher");
|
||||
*/
|
||||
|
||||
export interface UseViewportsProps {
|
||||
/** 当前用户的视口列表(从 iam 获取) */
|
||||
viewports: Viewport[] | null;
|
||||
}
|
||||
|
||||
export interface UseViewportsReturn {
|
||||
/** 全部视口 */
|
||||
viewports: Viewport[];
|
||||
/** 按类型过滤 */
|
||||
getByType: (type: Viewport["type"]) => Viewport[];
|
||||
/** 检查是否拥有指定类型视口 */
|
||||
hasViewport: (type: Viewport["type"]) => boolean;
|
||||
/** 按 ID 查找 */
|
||||
getById: (id: string) => Viewport | undefined;
|
||||
}
|
||||
|
||||
export function useViewports({
|
||||
viewports: input,
|
||||
}: UseViewportsProps): UseViewportsReturn {
|
||||
const viewports = useMemo(() => input ?? [], [input]);
|
||||
|
||||
const getByType = useMemo(
|
||||
() =>
|
||||
(type: Viewport["type"]): Viewport[] =>
|
||||
viewports.filter((v) => v.type === type),
|
||||
[viewports],
|
||||
);
|
||||
|
||||
const hasViewport = useMemo(
|
||||
() =>
|
||||
(type: Viewport["type"]): boolean =>
|
||||
viewports.some((v) => v.type === type),
|
||||
[viewports],
|
||||
);
|
||||
|
||||
const getById = useMemo(
|
||||
() =>
|
||||
(id: string): Viewport | undefined =>
|
||||
viewports.find((v) => v.id === id),
|
||||
[viewports],
|
||||
);
|
||||
|
||||
return {
|
||||
viewports,
|
||||
getByType,
|
||||
hasViewport,
|
||||
getById,
|
||||
};
|
||||
}
|
||||
20
packages/hooks/tsconfig.json
Normal file
20
packages/hooks/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
76
packages/shared-go/env/env.go
vendored
Normal file
76
packages/shared-go/env/env.go
vendored
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package env provides typed helpers for reading environment variables.
|
||||
//
|
||||
// All functions treat an unset variable and an empty string as distinct only
|
||||
// when explicitly documented: Must/Get distinguish "not set" from "set to empty"
|
||||
// via os.LookupEnv, while GetInt/GetBool/GetDuration fall back to the default
|
||||
// value when parsing fails.
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Must returns the value of the environment variable named by key.
|
||||
// It panics if the variable is not set, which is intended for values that the
|
||||
// service cannot start without (DB URLs, JWT secrets, ...).
|
||||
func Must(key string) string {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("env: required environment variable %q is not set", key))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Get returns the value of the environment variable named by key, or
|
||||
// defaultValue when the variable is not set.
|
||||
func Get(key, defaultValue string) string {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
return v
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetInt returns the integer value of the environment variable named by key,
|
||||
// or defaultValue when the variable is not set or cannot be parsed as an int.
|
||||
func GetInt(key string, defaultValue int) int {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return n
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetBool returns the boolean value of the environment variable named by key,
|
||||
// or defaultValue when the variable is not set or cannot be parsed as a bool
|
||||
// (accepted values are those understood by strconv.ParseBool: 1, t, T, TRUE,
|
||||
// true, True, 0, f, F, FALSE, false, False).
|
||||
func GetBool(key string, defaultValue bool) bool {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return b
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// GetDuration returns the duration value of the environment variable named by
|
||||
// key, or defaultValue when the variable is not set or cannot be parsed by
|
||||
// time.ParseDuration (e.g. "30s", "5m", "2h").
|
||||
func GetDuration(key string, defaultValue time.Duration) time.Duration {
|
||||
if v, ok := os.LookupEnv(key); ok {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return d
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
2
packages/shared-go/gen/proto/.gitkeep
Normal file
2
packages/shared-go/gen/proto/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# buf generate 产物输出到此目录(见 packages/shared-proto/buf.gen.yaml)。
|
||||
# 保留此文件以便 gen/proto 目录在产物生成前被 git 跟踪。
|
||||
8
packages/shared-go/go.mod
Normal file
8
packages/shared-go/go.mod
Normal file
@@ -0,0 +1,8 @@
|
||||
module github.com/edu-cloud/shared-go
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
go.uber.org/zap v1.27.0
|
||||
github.com/go-chi/chi/v5 v5.0.12
|
||||
)
|
||||
183
packages/shared-go/jwks/jwks.go
Normal file
183
packages/shared-go/jwks/jwks.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// Package jwks fetches JSON Web Key Sets and validates RS256 JWTs against them.
|
||||
//
|
||||
// A Fetcher caches parsed RSA public keys in memory for 5 minutes per kid so
|
||||
// repeated token validation does not re-hit the issuer's JWKS endpoint. It is
|
||||
// safe for concurrent use.
|
||||
package jwks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rsa"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// cacheTTL is how long a cached public key is considered fresh before the
|
||||
// Fetcher re-fetches the JWKS document.
|
||||
const cacheTTL = 5 * time.Minute
|
||||
|
||||
// Claims is the set of assertions carried by an Edu access token. The
|
||||
// embedded RegisteredClaims provides exp, iss, aud and the other standard
|
||||
// JWT claims.
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
DataScope string `json:"data_scope"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// cachedKey holds a parsed public key together with the moment it expires.
|
||||
type cachedKey struct {
|
||||
key *rsa.PublicKey
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
// Fetcher downloads and caches a JWKS document and validates tokens against
|
||||
// the cached keys.
|
||||
type Fetcher struct {
|
||||
jwksURL string
|
||||
httpClient *http.Client
|
||||
cache sync.Map
|
||||
}
|
||||
|
||||
// NewFetcher returns a Fetcher that retrieves keys from jwksURL.
|
||||
func NewFetcher(jwksURL string) *Fetcher {
|
||||
return &Fetcher{
|
||||
jwksURL: jwksURL,
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// GetPublicKey returns the RSA public key identified by kid. The key is served
|
||||
// from the in-memory cache when fresh; otherwise the JWKS document is
|
||||
// re-fetched and the cache repopulated.
|
||||
func (f *Fetcher) GetPublicKey(kid string) (*rsa.PublicKey, error) {
|
||||
if v, ok := f.cache.Load(kid); ok {
|
||||
if ck, ok := v.(*cachedKey); ok && time.Now().Before(ck.expiresAt) {
|
||||
return ck.key, nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.refreshCache(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
v, ok := f.cache.Load(kid)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("jwks: kid %q not found in JWKS", kid)
|
||||
}
|
||||
ck, ok := v.(*cachedKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("jwks: cached entry for kid %q has unexpected type", kid)
|
||||
}
|
||||
return ck.key, nil
|
||||
}
|
||||
|
||||
// refreshCache downloads the JWKS document and replaces every cached key.
|
||||
func (f *Fetcher) refreshCache() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.jwksURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jwks: building request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := f.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jwks: fetching JWKS: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("jwks: unexpected status %d from %s", resp.StatusCode, f.jwksURL)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Keys []struct {
|
||||
Kid string `json:"kid"`
|
||||
Kty string `json:"kty"`
|
||||
N string `json:"n"`
|
||||
E string `json:"e"`
|
||||
} `json:"keys"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
return fmt.Errorf("jwks: decoding response: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(cacheTTL)
|
||||
for _, k := range body.Keys {
|
||||
if k.Kty != "RSA" {
|
||||
continue
|
||||
}
|
||||
pk, err := buildRSAPublicKey(k.N, k.E)
|
||||
if err != nil {
|
||||
return fmt.Errorf("jwks: parsing key %q: %w", k.Kid, err)
|
||||
}
|
||||
f.cache.Store(k.Kid, &cachedKey{key: pk, expiresAt: expiresAt})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildRSAPublicKey reconstructs an rsa.PublicKey from the base64url-encoded
|
||||
// modulus (n) and exponent (e) fields of a JWK.
|
||||
func buildRSAPublicKey(nStr, eStr string) (*rsa.PublicKey, error) {
|
||||
nBytes, err := base64.RawURLEncoding.DecodeString(nStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding modulus: %w", err)
|
||||
}
|
||||
eBytes, err := base64.RawURLEncoding.DecodeString(eStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding exponent: %w", err)
|
||||
}
|
||||
|
||||
var e int
|
||||
for _, b := range eBytes {
|
||||
e = e<<8 + int(b)
|
||||
}
|
||||
if e == 0 {
|
||||
return nil, fmt.Errorf("empty exponent")
|
||||
}
|
||||
|
||||
return &rsa.PublicKey{
|
||||
N: new(big.Int).SetBytes(nBytes),
|
||||
E: e,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateToken parses and validates an RS256 JWT using the cached JWKS keys.
|
||||
// The kid in the token header selects the verification key. On success the
|
||||
// populated Claims (including user_id, role, data_scope and the standard
|
||||
// registered claims) are returned.
|
||||
func (f *Fetcher) ValidateToken(token string) (*Claims, error) {
|
||||
parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (any, error) {
|
||||
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
|
||||
return nil, fmt.Errorf("jwks: unexpected signing method %v", t.Header["alg"])
|
||||
}
|
||||
kid, ok := t.Header["kid"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("jwks: token header missing kid")
|
||||
}
|
||||
pk, err := f.GetPublicKey(kid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return pk, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwks: parsing token: %w", err)
|
||||
}
|
||||
|
||||
claims, ok := parsed.Claims.(*Claims)
|
||||
if !ok || !parsed.Valid {
|
||||
return nil, fmt.Errorf("jwks: invalid token claims")
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
65
packages/shared-go/logger/logger.go
Normal file
65
packages/shared-go/logger/logger.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package logger provides a structured logger built on zap with OpenTelemetry
|
||||
// trace correlation.
|
||||
//
|
||||
// In production (the default) logs are emitted as JSON at Info level. When the
|
||||
// ENV environment variable is set to "development" the logger switches to a
|
||||
// console encoder at Debug level for human-readable output.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// ctxKey is an unexported type so context values cannot collide with callers.
|
||||
type ctxKey struct{}
|
||||
|
||||
// fallback is returned by FromContext when no logger has been injected into
|
||||
// the context; it discards all output so callers never receive a nil logger.
|
||||
var fallback = zap.NewNop()
|
||||
|
||||
// New creates a structured logger tagged with serviceName. The encoder and
|
||||
// level are selected from the ENV environment variable as described in the
|
||||
// package documentation.
|
||||
func New(serviceName string) *zap.Logger {
|
||||
var cfg zap.Config
|
||||
if os.Getenv("ENV") == "development" {
|
||||
cfg = zap.NewDevelopmentConfig()
|
||||
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
|
||||
} else {
|
||||
cfg = zap.NewProductionConfig()
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
}
|
||||
|
||||
l, err := cfg.Build(zap.Fields(zap.String("service", serviceName)))
|
||||
if err != nil {
|
||||
// A logger that cannot be constructed is a fatal misconfiguration;
|
||||
// there is no safe way to continue.
|
||||
panic(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// WithContext returns a copy of ctx that carries l so it can later be
|
||||
// retrieved via FromContext.
|
||||
func WithContext(ctx context.Context, l *zap.Logger) context.Context {
|
||||
return context.WithValue(ctx, ctxKey{}, l)
|
||||
}
|
||||
|
||||
// FromContext returns the logger stored in ctx. When an active OpenTelemetry
|
||||
// span is present, the returned logger is decorated with a trace_id field so
|
||||
// log lines can be correlated to traces. If no logger was stored in ctx a
|
||||
// no-op logger is returned.
|
||||
func FromContext(ctx context.Context) *zap.Logger {
|
||||
if v, ok := ctx.Value(ctxKey{}).(*zap.Logger); ok && v != nil {
|
||||
if sc := trace.SpanContextFromContext(ctx); sc.HasTraceID() {
|
||||
return v.With(zap.String("trace_id", sc.TraceID().String()))
|
||||
}
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
92
packages/shared-go/tracer/tracer.go
Normal file
92
packages/shared-go/tracer/tracer.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// Package tracer bootstraps the OpenTelemetry SDK for a service and exports
|
||||
// traces over OTLP/HTTP (default endpoint localhost:4318, overridable via the
|
||||
// OTEL_EXPORTER_OTLP_ENDPOINT environment variable).
|
||||
//
|
||||
// Typical usage:
|
||||
//
|
||||
// if err := tracer.Init("api-gateway"); err != nil { log.Fatal(err) }
|
||||
// defer tracer.Shutdown(context.Background())
|
||||
package tracer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.24.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// tp is the process-wide TracerProvider created by Init and torn down by
|
||||
// Shutdown.
|
||||
var tp *sdktrace.TracerProvider
|
||||
|
||||
// Init initializes the OpenTelemetry SDK with an OTLP HTTP exporter and
|
||||
// registers it as the global TracerProvider. The service is identified by
|
||||
// serviceName on all exported spans.
|
||||
func Init(serviceName string) error {
|
||||
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = "localhost:4318"
|
||||
}
|
||||
|
||||
exp, err := otlptracehttp.New(
|
||||
context.Background(),
|
||||
otlptracehttp.WithEndpoint(endpoint),
|
||||
otlptracehttp.WithInsecure(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tracer: creating OTLP exporter: %w", err)
|
||||
}
|
||||
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(serviceName),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tracer: building resource: %w", err)
|
||||
}
|
||||
|
||||
tp = sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exp),
|
||||
sdktrace.WithResource(res),
|
||||
)
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown flushes pending spans and releases resources held by the
|
||||
// TracerProvider. It is a no-op when Init was never called. The provided
|
||||
// context bounds the flush to 5 seconds.
|
||||
func Shutdown(ctx context.Context) error {
|
||||
if tp == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := tp.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("tracer: shutting down provider: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FromContext returns the active span from ctx. When no span is active the
|
||||
// returned span is a no-op, so callers may safely record attributes or events
|
||||
// without nil checks.
|
||||
func FromContext(ctx context.Context) trace.Span {
|
||||
return trace.SpanFromContext(ctx)
|
||||
}
|
||||
30
packages/shared-ts/package.json
Normal file
30
packages/shared-ts/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@edu/shared-ts",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./outbox": {
|
||||
"types": "./src/outbox/index.ts",
|
||||
"default": "./src/outbox/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.0",
|
||||
"@nestjs/core": "^10.4.0",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"drizzle-orm": "^0.31.0",
|
||||
"kafkajs": "^2.2.0",
|
||||
"pino": "^9.4.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
22
packages/shared-ts/src/outbox/index.ts
Normal file
22
packages/shared-ts/src/outbox/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export { OutboxModule } from "./outbox.module.js";
|
||||
export type { OutboxRootOptions } from "./outbox.module.js";
|
||||
export { OutboxService } from "./outbox.service.js";
|
||||
export { OutboxPublisher } from "./publisher.js";
|
||||
export { createOutboxTable } from "./schema.js";
|
||||
export type { OutboxTable, OutboxRow, NewOutboxRow } from "./schema.js";
|
||||
export {
|
||||
OUTBOX_CONFIG,
|
||||
OUTBOX_DB,
|
||||
OUTBOX_KAFKA_PRODUCER,
|
||||
OUTBOX_LOGGER,
|
||||
OUTBOX_TABLE,
|
||||
} from "./types.js";
|
||||
export type {
|
||||
OutboxConfig,
|
||||
OutboxDbClient,
|
||||
OutboxKafkaProducer,
|
||||
OutboxLogger,
|
||||
OutboxRecord,
|
||||
OutboxStatus,
|
||||
PublishOptions,
|
||||
} from "./types.js";
|
||||
75
packages/shared-ts/src/outbox/outbox.module.ts
Normal file
75
packages/shared-ts/src/outbox/outbox.module.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Module, type DynamicModule } from "@nestjs/common";
|
||||
import pino from "pino";
|
||||
import { OutboxService } from "./outbox.service.js";
|
||||
import { OutboxPublisher } from "./publisher.js";
|
||||
import { createOutboxTable } from "./schema.js";
|
||||
import {
|
||||
OUTBOX_CONFIG,
|
||||
OUTBOX_DB,
|
||||
OUTBOX_KAFKA_PRODUCER,
|
||||
OUTBOX_LOGGER,
|
||||
OUTBOX_TABLE,
|
||||
type OutboxConfig,
|
||||
type OutboxDbClient,
|
||||
type OutboxKafkaProducer,
|
||||
type OutboxLogger,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* forRoot 选项。
|
||||
*
|
||||
* `config` 即 OutboxConfig(表名 / topic / 轮询参数)。
|
||||
* `db` / `kafkaProducer` / `logger` 为基础设施实例:NestJS DynamicModule
|
||||
* 无法跨模块边界注入宿主已存在的具体单例,故统一通过 forRoot 注入。
|
||||
*
|
||||
* `kafkaProducer` 应由调用方以 idempotent 方式创建:
|
||||
* `kafka.producer({ idempotent: true, transactionalId: '<service>-tx' })`。
|
||||
*/
|
||||
export interface OutboxRootOptions {
|
||||
config: OutboxConfig;
|
||||
db: OutboxDbClient;
|
||||
kafkaProducer: OutboxKafkaProducer;
|
||||
/** 可选;省略时使用默认 pino logger(name=outbox) */
|
||||
logger?: OutboxLogger;
|
||||
}
|
||||
|
||||
function resolveLogger(logger: OutboxLogger | undefined): OutboxLogger {
|
||||
return logger ?? pino({ name: "outbox", level: "info" });
|
||||
}
|
||||
|
||||
/**
|
||||
* OutboxModule —— 事务性 Outbox 模式的 NestJS DynamicModule。
|
||||
*
|
||||
* 注册并启动:
|
||||
* - OutboxService:业务代码注入后调用 `publish` 写入 outbox 记录
|
||||
* - OutboxPublisher:onModuleInit 启动轮询,onModuleDestroy 停止轮询
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* @Module({
|
||||
* imports: [OutboxModule.forRoot({ config, db, kafkaProducer })],
|
||||
* })
|
||||
* export class AppModule {}
|
||||
* ```
|
||||
*/
|
||||
@Module({})
|
||||
export class OutboxModule {
|
||||
static forRoot(options: OutboxRootOptions): DynamicModule {
|
||||
const table = createOutboxTable(options.config.tableName);
|
||||
const logger = resolveLogger(options.logger);
|
||||
|
||||
return {
|
||||
module: OutboxModule,
|
||||
providers: [
|
||||
{ provide: OUTBOX_CONFIG, useValue: options.config },
|
||||
{ provide: OUTBOX_DB, useValue: options.db },
|
||||
{ provide: OUTBOX_TABLE, useValue: table },
|
||||
{ provide: OUTBOX_KAFKA_PRODUCER, useValue: options.kafkaProducer },
|
||||
{ provide: OUTBOX_LOGGER, useValue: logger },
|
||||
OutboxService,
|
||||
OutboxPublisher,
|
||||
],
|
||||
exports: [OutboxService],
|
||||
};
|
||||
}
|
||||
}
|
||||
74
packages/shared-ts/src/outbox/outbox.service.ts
Normal file
74
packages/shared-ts/src/outbox/outbox.service.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import type { NewOutboxRow, OutboxTable } from "./schema.js";
|
||||
import {
|
||||
OUTBOX_CONFIG,
|
||||
OUTBOX_DB,
|
||||
OUTBOX_LOGGER,
|
||||
OUTBOX_TABLE,
|
||||
type OutboxConfig,
|
||||
type OutboxDbClient,
|
||||
type OutboxLogger,
|
||||
type PublishOptions,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* OutboxService —— 事务性 Outbox 写入服务。
|
||||
*
|
||||
* 调用方在业务事务内调用 `publish`,将事件记录写入 outbox 表,
|
||||
* 与业务写在同一事务中原子提交,保证“业务变更”与“事件发布”的一致性。
|
||||
*
|
||||
* 由独立的 OutboxPublisher 负责异步轮询 pending 记录并投递到 Kafka,
|
||||
* 实现 at-least-once 投递语义。
|
||||
*/
|
||||
@Injectable()
|
||||
export class OutboxService {
|
||||
constructor(
|
||||
@Inject(OUTBOX_DB) private readonly db: OutboxDbClient,
|
||||
@Inject(OUTBOX_TABLE) private readonly table: OutboxTable,
|
||||
@Inject(OUTBOX_CONFIG) private readonly config: OutboxConfig,
|
||||
@Inject(OUTBOX_LOGGER) private readonly logger: OutboxLogger,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 写入一条 outbox 记录。
|
||||
*
|
||||
* @param eventType 事件类型(如 `ExamCreated`),用于 Kafka topic 路由
|
||||
* @param payload 事件负载,将被 JSON 序列化存入 outbox 行
|
||||
* @param options 可选:aggregateId / metadata / delayMs / tx
|
||||
* @returns event id(cuid2),供调用方追踪;同时作为 Kafka 消息 key 实现幂等去重
|
||||
*
|
||||
* 事务管理:传入 `options.tx` 则在调用方事务内写入(推荐,保证与业务表原子提交);
|
||||
* 省略时使用模块注入的 db 执行单条插入(仅保证 outbox 行自身的原子性)。
|
||||
*/
|
||||
async publish(
|
||||
eventType: string,
|
||||
payload: unknown,
|
||||
options?: PublishOptions,
|
||||
): Promise<string> {
|
||||
const id = createId();
|
||||
const now = new Date();
|
||||
const nextRetryAt =
|
||||
options?.delayMs !== undefined && options.delayMs > 0
|
||||
? new Date(now.getTime() + options.delayMs)
|
||||
: null;
|
||||
|
||||
const record: NewOutboxRow = {
|
||||
id,
|
||||
eventType,
|
||||
payload: JSON.stringify(payload),
|
||||
aggregateId: options?.aggregateId ?? id,
|
||||
maxRetryCount: this.config.maxRetryCount,
|
||||
nextRetryAt,
|
||||
metadata: options?.metadata ?? null,
|
||||
};
|
||||
|
||||
await (options?.tx ?? this.db).insert(this.table).values(record);
|
||||
|
||||
this.logger.debug(
|
||||
{ id, eventType, aggregateId: record.aggregateId },
|
||||
"Outbox record enqueued",
|
||||
);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
205
packages/shared-ts/src/outbox/publisher.ts
Normal file
205
packages/shared-ts/src/outbox/publisher.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
type OnModuleDestroy,
|
||||
type OnModuleInit,
|
||||
} from "@nestjs/common";
|
||||
import { and, eq, isNull, lte, or, type SQL } from "drizzle-orm";
|
||||
import type { OutboxRow, OutboxTable } from "./schema.js";
|
||||
import {
|
||||
OUTBOX_CONFIG,
|
||||
OUTBOX_DB,
|
||||
OUTBOX_KAFKA_PRODUCER,
|
||||
OUTBOX_LOGGER,
|
||||
OUTBOX_TABLE,
|
||||
type OutboxConfig,
|
||||
type OutboxDbClient,
|
||||
type OutboxKafkaProducer,
|
||||
type OutboxLogger,
|
||||
} from "./types.js";
|
||||
|
||||
/**
|
||||
* OutboxPublisher —— 轮询 pending 记录并投递到 Kafka。
|
||||
*
|
||||
* 实现 at-least-once 投递语义:
|
||||
* 1. 周期性(pollIntervalMs)批量拉取 status=pending 且到期的记录
|
||||
* 2. 逐条投递到 Kafka(message key = event id,保证同聚合有序)
|
||||
* 3. 投递成功 → 标记 published
|
||||
* 4. 投递失败 → 指数退避更新 nextRetryAt;重试耗尽 → 标记 failed
|
||||
*
|
||||
* 幂等性:
|
||||
* - producer 应由调用方配置为 idempotent(`kafka.producer({ idempotent: true, transactionalId })`),
|
||||
* 避免生产端重试产生重复消息
|
||||
* - message key = event id(cuid2),消费端可基于 event_id 去重(Redis SETNX 或 DB 唯一索引)
|
||||
*/
|
||||
@Injectable()
|
||||
export class OutboxPublisher implements OnModuleInit, OnModuleDestroy {
|
||||
private intervalId: ReturnType<typeof setInterval> | null = null;
|
||||
private isPolling = false;
|
||||
|
||||
constructor(
|
||||
@Inject(OUTBOX_DB) private readonly db: OutboxDbClient,
|
||||
@Inject(OUTBOX_TABLE) private readonly table: OutboxTable,
|
||||
@Inject(OUTBOX_KAFKA_PRODUCER)
|
||||
private readonly producer: OutboxKafkaProducer,
|
||||
@Inject(OUTBOX_CONFIG) private readonly config: OutboxConfig,
|
||||
@Inject(OUTBOX_LOGGER) private readonly logger: OutboxLogger,
|
||||
) {}
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
await this.start();
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.stop();
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.intervalId) return;
|
||||
this.logger.info(
|
||||
{
|
||||
topic: this.config.kafkaTopic,
|
||||
pollIntervalMs: this.config.pollIntervalMs,
|
||||
batchSize: this.config.batchSize,
|
||||
},
|
||||
"OutboxPublisher started",
|
||||
);
|
||||
this.intervalId = setInterval(() => {
|
||||
void this.poll();
|
||||
}, this.config.pollIntervalMs);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
this.logger.info("OutboxPublisher stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取一批到期 pending 记录并逐条投递。
|
||||
* 通过 isPolling 标志保证不会并发轮询。
|
||||
*/
|
||||
private async poll(): Promise<void> {
|
||||
if (this.isPolling) return;
|
||||
this.isPolling = true;
|
||||
try {
|
||||
const now = new Date();
|
||||
const where: SQL | undefined = and(
|
||||
eq(this.table.status, "pending"),
|
||||
or(isNull(this.table.nextRetryAt), lte(this.table.nextRetryAt, now)),
|
||||
);
|
||||
const messages: OutboxRow[] = await this.db
|
||||
.select()
|
||||
.from(this.table)
|
||||
.where(where)
|
||||
.limit(this.config.batchSize);
|
||||
|
||||
for (const message of messages) {
|
||||
await this.dispatch(message);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error({ error }, "Outbox poll failed");
|
||||
} finally {
|
||||
this.isPolling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 投递单条记录到 Kafka 并更新状态。
|
||||
*/
|
||||
private async dispatch(message: OutboxRow): Promise<void> {
|
||||
try {
|
||||
await this.producer.send({
|
||||
topic: this.config.kafkaTopic,
|
||||
messages: [
|
||||
{
|
||||
key: message.id,
|
||||
value: message.payload,
|
||||
headers: this.buildHeaders(message),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await this.db
|
||||
.update(this.table)
|
||||
.set({ status: "published", publishedAt: new Date(), lastError: null })
|
||||
.where(eq(this.table.id, message.id));
|
||||
|
||||
this.logger.info(
|
||||
{
|
||||
id: message.id,
|
||||
eventType: message.eventType,
|
||||
topic: this.config.kafkaTopic,
|
||||
},
|
||||
"Outbox message published",
|
||||
);
|
||||
} catch (error) {
|
||||
await this.handleFailure(message, this.toErrorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 失败处理:指数退避更新 nextRetryAt,重试耗尽则标记 failed。
|
||||
*/
|
||||
private async handleFailure(
|
||||
message: OutboxRow,
|
||||
errorMessage: string,
|
||||
): Promise<void> {
|
||||
const newRetryCount = message.retryCount + 1;
|
||||
this.logger.error(
|
||||
{
|
||||
id: message.id,
|
||||
eventType: message.eventType,
|
||||
retryCount: newRetryCount,
|
||||
error: errorMessage,
|
||||
},
|
||||
"Outbox publish failed",
|
||||
);
|
||||
|
||||
if (newRetryCount >= message.maxRetryCount) {
|
||||
await this.db
|
||||
.update(this.table)
|
||||
.set({
|
||||
status: "failed",
|
||||
retryCount: newRetryCount,
|
||||
lastError: errorMessage,
|
||||
})
|
||||
.where(eq(this.table.id, message.id));
|
||||
return;
|
||||
}
|
||||
|
||||
const backoffMs = this.config.retryBackoffMs * 2 ** newRetryCount;
|
||||
const nextRetryAt = new Date(Date.now() + backoffMs);
|
||||
await this.db
|
||||
.update(this.table)
|
||||
.set({
|
||||
retryCount: newRetryCount,
|
||||
nextRetryAt,
|
||||
lastError: errorMessage,
|
||||
})
|
||||
.where(eq(this.table.id, message.id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 Kafka 消息头:eventId / eventType / aggregateId + 调用方 metadata。
|
||||
*/
|
||||
private buildHeaders(message: OutboxRow): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
eventId: message.id,
|
||||
eventType: message.eventType,
|
||||
aggregateId: message.aggregateId,
|
||||
};
|
||||
if (message.metadata) {
|
||||
for (const [key, value] of Object.entries(message.metadata)) {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
private toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
}
|
||||
56
packages/shared-ts/src/outbox/schema.ts
Normal file
56
packages/shared-ts/src/outbox/schema.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
int,
|
||||
json,
|
||||
mysqlTable,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/mysql-core";
|
||||
import type { OutboxRecord, OutboxStatus } from "./types.js";
|
||||
|
||||
/**
|
||||
* 创建 outbox 表的 Drizzle schema。
|
||||
*
|
||||
* 表名由调用方通过 OutboxConfig.tableName 提供(每服务独立 outbox 表),
|
||||
* 因此以工厂函数形式返回,避免多服务共用同一表名常量。
|
||||
*
|
||||
* 字段与 OutboxRecord 接口一一对齐:
|
||||
* - id:cuid2(varchar(32)),作为 event id 与 Kafka 消息 key
|
||||
* - status:通过 $type<OutboxStatus> 收窄为联合类型
|
||||
* - metadata:通过 $type<Record<string,string> | null> 收窄
|
||||
*/
|
||||
export function createOutboxTable(tableName: string) {
|
||||
return mysqlTable(tableName, {
|
||||
id: varchar("id", { length: 32 }).notNull().primaryKey(),
|
||||
eventType: varchar("event_type", { length: 100 }).notNull(),
|
||||
payload: text("payload").notNull(),
|
||||
aggregateId: varchar("aggregate_id", { length: 32 }).notNull(),
|
||||
status: varchar("status", { length: 20 })
|
||||
.notNull()
|
||||
.default("pending")
|
||||
.$type<OutboxStatus>(),
|
||||
retryCount: int("retry_count").notNull().default(0),
|
||||
maxRetryCount: int("max_retry_count").notNull().default(5),
|
||||
nextRetryAt: timestamp("next_retry_at"),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
publishedAt: timestamp("published_at"),
|
||||
lastError: text("last_error"),
|
||||
metadata: json("metadata").$type<Record<string, string> | null>(),
|
||||
});
|
||||
}
|
||||
|
||||
/** outbox 表类型 */
|
||||
export type OutboxTable = ReturnType<typeof createOutboxTable>;
|
||||
|
||||
/** select 推断行类型(应结构兼容 OutboxRecord) */
|
||||
export type OutboxRow = OutboxTable["$inferSelect"];
|
||||
|
||||
/** insert 推断类型 */
|
||||
export type NewOutboxRow = OutboxTable["$inferInsert"];
|
||||
|
||||
/**
|
||||
* 编译期断言:OutboxRow 与 OutboxRecord 结构兼容。
|
||||
* 若 schema 与接口偏离,此处会在严格模式下编译失败。
|
||||
*/
|
||||
const _assertOutboxRow: OutboxRow extends OutboxRecord ? true : never = true;
|
||||
void _assertOutboxRow;
|
||||
87
packages/shared-ts/src/outbox/types.ts
Normal file
87
packages/shared-ts/src/outbox/types.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { MySql2Database } from "drizzle-orm/mysql2";
|
||||
import type { Producer } from "kafkajs";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
/**
|
||||
* Outbox 记录状态机:
|
||||
* - pending:已写入 outbox 表,等待 publisher 投递
|
||||
* - published:已成功投递到 Kafka
|
||||
* - failed:重试次数耗尽,需人工介入
|
||||
*/
|
||||
export type OutboxStatus = "pending" | "published" | "failed";
|
||||
|
||||
/**
|
||||
* Outbox 模块配置(由各服务在 forRoot 时提供)。
|
||||
*/
|
||||
export interface OutboxConfig {
|
||||
/** outbox 表名(每服务独立,如 core_edu_outbox) */
|
||||
tableName: string;
|
||||
/** Kafka 投递目标 topic */
|
||||
kafkaTopic: string;
|
||||
/** 轮询 pending 记录的间隔(毫秒) */
|
||||
pollIntervalMs: number;
|
||||
/** 每次轮询批量处理的记录数 */
|
||||
batchSize: number;
|
||||
/** 单条记录最大重试次数 */
|
||||
maxRetryCount: number;
|
||||
/** 重试退避基数(毫秒),实际退避 = retryBackoffMs * 2^retryCount */
|
||||
retryBackoffMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* publish 选项。
|
||||
*
|
||||
* `tx` 用于由调用方管理事务:调用方在 `db.transaction(async (tx) => { ... })`
|
||||
* 中同时写入业务表与 outbox 记录,保证两者原子提交(事务性 Outbox 模式)。
|
||||
*/
|
||||
export interface PublishOptions {
|
||||
/** 聚合根 ID,作为 Kafka 消息 key 用于分区与顺序保证 */
|
||||
aggregateId?: string;
|
||||
/** 附加元数据,写入 outbox 行并随消息头投递 */
|
||||
metadata?: Record<string, string>;
|
||||
/** 延迟投递毫秒数;设置后 nextRetryAt 推迟,到期前不会被轮询 */
|
||||
delayMs?: number;
|
||||
/** 调用方管理的事务客户端;省略时使用模块注入的 db(非事务单条插入) */
|
||||
tx?: OutboxDbClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Outbox 行结构(与 schema.ts 的 $inferSelect 对齐)。
|
||||
*/
|
||||
export interface OutboxRecord {
|
||||
id: string;
|
||||
eventType: string;
|
||||
/** JSON 序列化后的 payload */
|
||||
payload: string;
|
||||
aggregateId: string;
|
||||
status: OutboxStatus;
|
||||
retryCount: number;
|
||||
maxRetryCount: number;
|
||||
nextRetryAt: Date | null;
|
||||
createdAt: Date;
|
||||
publishedAt: Date | null;
|
||||
lastError: string | null;
|
||||
metadata: Record<string, string> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drizzle 数据库客户端类型。
|
||||
*
|
||||
* 服务以无 schema 绑定方式创建 db(`drizzle(pool)`),其类型为
|
||||
* `MySql2Database<Record<string, never>>`。由于 `MySql2Transaction extends
|
||||
* MySqlDatabase`,事务客户端同样可赋值给本类型,故 publish 可接受调用方传入的 tx。
|
||||
*/
|
||||
export type OutboxDbClient = MySql2Database<Record<string, never>>;
|
||||
|
||||
/** Kafka producer 类型别名 */
|
||||
export type OutboxKafkaProducer = Producer;
|
||||
|
||||
/** pino Logger 类型别名 */
|
||||
export type OutboxLogger = Logger;
|
||||
|
||||
// —— NestJS 注入 Token —— //
|
||||
export const OUTBOX_CONFIG = Symbol("OUTBOX_CONFIG");
|
||||
export const OUTBOX_DB = Symbol("OUTBOX_DB");
|
||||
export const OUTBOX_TABLE = Symbol("OUTBOX_TABLE");
|
||||
export const OUTBOX_KAFKA_PRODUCER = Symbol("OUTBOX_KAFKA_PRODUCER");
|
||||
export const OUTBOX_LOGGER = Symbol("OUTBOX_LOGGER");
|
||||
16
packages/shared-ts/tsconfig.json
Normal file
16
packages/shared-ts/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"incremental": false,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
24
packages/ui-components/package.json
Normal file
24
packages/ui-components/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@edu/ui-components",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edu/ui-tokens": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
72
packages/ui-components/src/empty.tsx
Normal file
72
packages/ui-components/src/empty.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
|
||||
/**
|
||||
* Empty - 空态展示(插画占位 + 文案 + CTA)
|
||||
*
|
||||
* 用途:数据列表为空时展示友好提示,引导用户操作。
|
||||
*
|
||||
* @example
|
||||
* <Empty title="暂无班级" description="点击下方按钮创建第一个班级" action={<Button>新建班级</Button>} />
|
||||
*/
|
||||
|
||||
export interface EmptyProps {
|
||||
/** 标题(必填) */
|
||||
title: string;
|
||||
/** 描述文案 */
|
||||
description?: string;
|
||||
/** 行动按钮(CTA) */
|
||||
action?: ReactNode;
|
||||
/** 自定义插画;未提供时使用默认占位 */
|
||||
illustration?: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Empty({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
illustration,
|
||||
className,
|
||||
}: EmptyProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-4 p-8 text-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{illustration ?? <DefaultIllustration />}
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-base font-medium">{title}</h3>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action ?? null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultIllustration(): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className="flex h-16 w-16 items-center justify-center rounded-full bg-muted"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="h-8 w-8 text-muted-foreground"
|
||||
>
|
||||
<path d="M9 17a2 2 0 002 2h2a2 2 0 002-2M5 9h14l-1 8H6L5 9z" />
|
||||
<path d="M9 9V5a3 3 0 016 0v4" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
packages/ui-components/src/error-boundary.tsx
Normal file
88
packages/ui-components/src/error-boundary.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* ErrorBoundary - React 渲染异常兜底(fallback UI)
|
||||
*
|
||||
* 用途:捕获子组件树渲染异常,展示降级 UI,避免整页白屏。
|
||||
*
|
||||
* @example
|
||||
* <ErrorBoundary fallback={<ErrorFallback />}>
|
||||
* <Dashboard />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
|
||||
export interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
/** 自定义降级 UI;未提供时使用默认 fallback */
|
||||
fallback?: ReactNode | ((error: Error, reset: () => void) => ReactNode);
|
||||
/** 错误回调(上报 / 日志) */
|
||||
onError?: (error: Error, info: ErrorInfo) => void;
|
||||
/** 重置回调(用于外部触发重试) */
|
||||
onReset?: () => void;
|
||||
}
|
||||
|
||||
export interface ErrorBoundaryState {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState
|
||||
> {
|
||||
state: ErrorBoundaryState = { error: null };
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
this.props.onError?.(error, info);
|
||||
}
|
||||
|
||||
reset = (): void => {
|
||||
this.props.onReset?.();
|
||||
this.setState({ error: null });
|
||||
};
|
||||
|
||||
render(): ReactNode {
|
||||
const { error } = this.state;
|
||||
const { children, fallback } = this.props;
|
||||
|
||||
if (error) {
|
||||
if (typeof fallback === "function") {
|
||||
return fallback(error, this.reset);
|
||||
}
|
||||
if (fallback !== undefined) {
|
||||
return fallback;
|
||||
}
|
||||
return <DefaultErrorFallback error={error} onReset={this.reset} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
function DefaultErrorFallback({
|
||||
error,
|
||||
onReset,
|
||||
}: {
|
||||
error: Error;
|
||||
onReset: () => void;
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex min-h-[200px] flex-col items-center justify-center gap-4 p-8"
|
||||
>
|
||||
<h2 className="text-lg font-semibold">出错了</h2>
|
||||
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
packages/ui-components/src/index.ts
Normal file
38
packages/ui-components/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @edu/ui-components - 共享组件库
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联文档:teacher-portal 02-architecture-design.md §7
|
||||
*
|
||||
* 包含:
|
||||
* - ErrorBoundary:React 渲染异常兜底
|
||||
* - Loading:骨架屏 / 加载占位
|
||||
* - Empty:空态展示
|
||||
* - RequirePermission:L3 组件级视口控制
|
||||
* - cn:类名合并工具(project_rules §3.9)
|
||||
*
|
||||
* 后续扩展(P2+):
|
||||
* - AppShell(MF Shell 布局)
|
||||
* - shadcn/ui 基础组件(Button/Input/Select/Modal/Toast/DataTable)
|
||||
* - A11y 工具集(useA11yId/mergeA11yProps/describeInput/focus-trap)
|
||||
* - Form(react-hook-form + zodResolver)
|
||||
* - RichTextEditor(Tiptap 封装)
|
||||
* - Chart(recharts 封装)
|
||||
*/
|
||||
|
||||
export { ErrorBoundary } from "./error-boundary.js";
|
||||
export type {
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState,
|
||||
} from "./error-boundary.js";
|
||||
|
||||
export { Loading } from "./loading.js";
|
||||
export type { LoadingProps } from "./loading.js";
|
||||
|
||||
export { Empty } from "./empty.js";
|
||||
export type { EmptyProps } from "./empty.js";
|
||||
|
||||
export { RequirePermission } from "./require-permission.js";
|
||||
export type { RequirePermissionProps } from "./require-permission.js";
|
||||
|
||||
export { cn } from "./utils/cn.js";
|
||||
65
packages/ui-components/src/loading.tsx
Normal file
65
packages/ui-components/src/loading.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
|
||||
/**
|
||||
* Loading - 骨架屏 / 加载占位
|
||||
*
|
||||
* 用途:数据加载期间展示骨架屏,避免内容跳动。
|
||||
*
|
||||
* @example
|
||||
* <Loading /> // 默认 3 行骨架
|
||||
* <Loading lines={5} /> // 5 行骨架
|
||||
* <Loading variant="spinner" /> // 旋转图标
|
||||
*/
|
||||
|
||||
export interface LoadingProps {
|
||||
/** 骨架行数(variant="skeleton" 时生效) */
|
||||
lines?: number;
|
||||
/** 展示形态 */
|
||||
variant?: "skeleton" | "spinner";
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** aria-label(无障碍) */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function Loading({
|
||||
lines = 3,
|
||||
variant = "skeleton",
|
||||
className,
|
||||
ariaLabel = "加载中",
|
||||
}: LoadingProps): ReactNode {
|
||||
if (variant === "spinner") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("flex items-center justify-center p-8", className)}
|
||||
>
|
||||
<span
|
||||
className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-muted border-t-primary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="sr-only">{ariaLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("flex flex-col gap-2 p-4", className)}
|
||||
>
|
||||
{Array.from({ length: lines }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 animate-pulse rounded bg-muted"
|
||||
style={{ width: `${100 - i * 10}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">{ariaLabel}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
48
packages/ui-components/src/require-permission.tsx
Normal file
48
packages/ui-components/src/require-permission.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* RequirePermission - L3 组件级视口控制
|
||||
*
|
||||
* 用途:根据当前用户权限点决定是否渲染 children。
|
||||
* 无权限时不渲染 children(返回 null 或自定义 fallback)。
|
||||
*
|
||||
* 权限点命名遵循 F7 裁决:`<RESOURCE>_<ACTION>`(如 `EXAM_READ`、`GRADE_READ_CHILD`)。
|
||||
* 数据范围用后缀 `_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`)。
|
||||
*
|
||||
* 注意:本组件依赖 `@edu/hooks` 的 `usePermission()`。为避免循环依赖,
|
||||
* 权限检查函数通过 props 注入,由调用方(apps/*)从 usePermission() 获取后传入。
|
||||
*
|
||||
* @example
|
||||
* import { usePermission } from "@edu/hooks";
|
||||
* const { hasPermission } = usePermission();
|
||||
*
|
||||
* <RequirePermission perm="EXAM_CREATE" hasPermission={hasPermission}>
|
||||
* <Button>新建考试</Button>
|
||||
* </RequirePermission>
|
||||
*/
|
||||
|
||||
export interface RequirePermissionProps {
|
||||
/** 权限点(F7 规范:<RESOURCE>_<ACTION>) */
|
||||
perm: string;
|
||||
/**
|
||||
* 权限检查函数(由 @edu/hooks 的 usePermission().hasPermission 注入)
|
||||
* 返回 true 渲染 children,false 渲染 fallback。
|
||||
*/
|
||||
hasPermission: (perm: string) => boolean;
|
||||
/** 子节点(有权限时渲染) */
|
||||
children: ReactNode;
|
||||
/** 无权限时的降级 UI;未提供时返回 null */
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
export function RequirePermission({
|
||||
perm,
|
||||
hasPermission,
|
||||
children,
|
||||
fallback = null,
|
||||
}: RequirePermissionProps): ReactNode {
|
||||
if (!hasPermission(perm)) {
|
||||
return fallback;
|
||||
}
|
||||
return children;
|
||||
}
|
||||
83
packages/ui-components/src/utils/cn.ts
Normal file
83
packages/ui-components/src/utils/cn.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 类名合并工具(project_rules §3.9 强制使用)
|
||||
*
|
||||
* 用于管理条件类名,禁止字符串拼接动态类名(如 `bg-${color}-500`)。
|
||||
* 基于 clsx + tailwind-merge 的轻量实现,后续接入 shadcn/ui 时替换为官方 cn()。
|
||||
*/
|
||||
|
||||
type ClassValue =
|
||||
| string
|
||||
| number
|
||||
| null
|
||||
| false
|
||||
| undefined
|
||||
| ClassValue[]
|
||||
| { [key: string]: unknown };
|
||||
|
||||
/**
|
||||
* 合并类名,过滤 falsy 值。
|
||||
*
|
||||
* @example
|
||||
* cn("px-2 py-1", isActive && "bg-primary", { "text-muted": isDisabled })
|
||||
*/
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
const classes: string[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (!input) continue;
|
||||
|
||||
if (typeof input === "string" || typeof input === "number") {
|
||||
classes.push(String(input));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
const nested = cn(...input);
|
||||
if (nested) classes.push(nested);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof input === "object") {
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (value) classes.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 去重 + 合并 Tailwind 冲突类(基础实现,后续替换为 tailwind-merge)
|
||||
return dedupeTailwindClasses(classes.join(" "));
|
||||
}
|
||||
|
||||
/**
|
||||
* 基础 Tailwind 类去重(同一前缀后者覆盖前者)。
|
||||
* 完整实现待引入 tailwind-merge。
|
||||
*/
|
||||
function dedupeTailwindClasses(className: string): string {
|
||||
const seen = new Set<string>();
|
||||
const tokens = className.split(/\s+/).filter(Boolean);
|
||||
|
||||
// 反向遍历,保留后出现的同类令牌
|
||||
for (let i = tokens.length - 1; i >= 0; i--) {
|
||||
const token = tokens[i];
|
||||
if (!token) continue;
|
||||
|
||||
const prefix = getTailwindPrefix(token);
|
||||
const key = prefix ? `__${prefix}` : token;
|
||||
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
}
|
||||
|
||||
// 恢复原始顺序
|
||||
return tokens
|
||||
.filter((t) => t && (seen.has(t) || seen.has(`__${getTailwindPrefix(t)}`)))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function getTailwindPrefix(token: string): string | null {
|
||||
// 匹配 Tailwind 前缀:bg- text- p- m- w- h- border- 等
|
||||
const match = token.match(
|
||||
/^(bg|text|p|m|px|py|mx|my|w|h|min-h|min-w|border|rounded|shadow|font|leading|tracking|gap|space|flex|grid|col|row|inset|top|right|bottom|left|z|opacity|transition|duration|delay|animate|hover|focus|sm|md|lg|xl|2xl)-/,
|
||||
);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
20
packages/ui-components/tsconfig.json
Normal file
20
packages/ui-components/tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
8
packages/ui-tokens/package.json
Normal file
8
packages/ui-tokens/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "@edu/ui-tokens",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts"
|
||||
}
|
||||
39
packages/ui-tokens/src/colors.ts
Normal file
39
packages/ui-tokens/src/colors.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type ColorScheme = {
|
||||
background: string;
|
||||
foreground: string;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
muted: string;
|
||||
accent: string;
|
||||
destructive: string;
|
||||
border: string;
|
||||
input: string;
|
||||
ring: string;
|
||||
};
|
||||
|
||||
export const colors = {
|
||||
light: {
|
||||
background: "210 40% 98%",
|
||||
foreground: "222 47% 11%",
|
||||
primary: "221 83% 53%",
|
||||
secondary: "210 40% 96%",
|
||||
muted: "210 40% 96%",
|
||||
accent: "210 40% 94%",
|
||||
destructive: "0 84% 60%",
|
||||
border: "214 32% 91%",
|
||||
input: "214 32% 91%",
|
||||
ring: "221 83% 53%",
|
||||
},
|
||||
dark: {
|
||||
background: "222 47% 11%",
|
||||
foreground: "210 40% 98%",
|
||||
primary: "217 91% 60%",
|
||||
secondary: "217 33% 17%",
|
||||
muted: "217 33% 17%",
|
||||
accent: "217 33% 20%",
|
||||
destructive: "0 63% 31%",
|
||||
border: "217 33% 20%",
|
||||
input: "217 33% 20%",
|
||||
ring: "217 91% 60%",
|
||||
},
|
||||
} as const satisfies Record<"light" | "dark", ColorScheme>;
|
||||
4
packages/ui-tokens/src/index.ts
Normal file
4
packages/ui-tokens/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./colors.js";
|
||||
export * from "./typography.js";
|
||||
export * from "./spacing.js";
|
||||
export * from "./shadows.js";
|
||||
6
packages/ui-tokens/src/shadows.ts
Normal file
6
packages/ui-tokens/src/shadows.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const shadows = {
|
||||
sm: "0 1px 2px 0 hsl(220 23% 15% / 0.05)",
|
||||
md: "0 4px 6px -1px hsl(220 23% 15% / 0.1), 0 2px 4px -2px hsl(220 23% 15% / 0.1)",
|
||||
lg: "0 10px 15px -3px hsl(220 23% 15% / 0.1), 0 4px 6px -4px hsl(220 23% 15% / 0.1)",
|
||||
xl: "0 20px 25px -5px hsl(220 23% 15% / 0.1), 0 8px 10px -6px hsl(220 23% 15% / 0.1)",
|
||||
} as const;
|
||||
23
packages/ui-tokens/src/spacing.ts
Normal file
23
packages/ui-tokens/src/spacing.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export const spacing = {
|
||||
space0: "0rem",
|
||||
space1: "0.25rem",
|
||||
space2: "0.5rem",
|
||||
space3: "0.75rem",
|
||||
space4: "1rem",
|
||||
space5: "1.25rem",
|
||||
space6: "1.5rem",
|
||||
space7: "1.75rem",
|
||||
space8: "2rem",
|
||||
space9: "2.25rem",
|
||||
space10: "2.5rem",
|
||||
space11: "3rem",
|
||||
space12: "3.5rem",
|
||||
space13: "4rem",
|
||||
space14: "4.5rem",
|
||||
space15: "5rem",
|
||||
space16: "6rem",
|
||||
space17: "7rem",
|
||||
space18: "8rem",
|
||||
space19: "9rem",
|
||||
space20: "10rem",
|
||||
} as const;
|
||||
17
packages/ui-tokens/src/typography.ts
Normal file
17
packages/ui-tokens/src/typography.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const fontSizes = {
|
||||
fontSize1: "0.75rem",
|
||||
fontSize2: "0.875rem",
|
||||
fontSize3: "1rem",
|
||||
fontSize4: "1.125rem",
|
||||
fontSize5: "1.25rem",
|
||||
fontSize6: "1.5rem",
|
||||
fontSize7: "1.875rem",
|
||||
fontSize8: "2.25rem",
|
||||
fontSize9: "3rem",
|
||||
} as const;
|
||||
|
||||
export const fontFamilies = {
|
||||
sans: "var(--font-family-sans)",
|
||||
serif: "var(--font-family-serif)",
|
||||
mono: "var(--font-family-mono)",
|
||||
} as const;
|
||||
19
packages/ui-tokens/tsconfig.json
Normal file
19
packages/ui-tokens/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user