feat(portal-shell): v2.0 P0 shadcn standardization + security + streaming + error handling
- shadcn/ui 标准化:废弃纸感令牌,统一 bg-background/text-foreground 等 - Tailwind v4 + @theme inline,移除 tailwind.config.js - React 19 use() + Suspense 流式渲染,首屏骨架秒出 - 三级错误边界:Route → Section → Widget 层层兜底 - 错误上报:useErrorReport → sendBeacon → /api/log mock 端点 - 三层安全边界:L1 角色门禁 / L2 权限点门禁 / L3 数据范围 - 权限位图 base36 压缩:67 权限点 → ~14 字符,JWT 体积减少 ≥ 99% - notify 统一 Toast 封装,禁止业务直接 import sonner - PluginBoundary 替代 PluginLoader(错误边界 + Suspense + Skeleton 三件套) 验证:typecheck 0 错误 / lint 0 错误 / build 6 路由生成成功
This commit is contained in:
@@ -10,16 +10,17 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edu/shared-ts": "workspace:*",
|
||||
"@edu/ui-components": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0",
|
||||
"urql": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,42 +20,39 @@
|
||||
* - token 存储使用 localStorage(F12 裁决,P2 阶段)
|
||||
*/
|
||||
|
||||
export { useAuth } from "./use-auth.js";
|
||||
export type { UseAuthReturn } from "./use-auth.js";
|
||||
export { useAuth } from "./use-auth";
|
||||
export type { UseAuthReturn } from "./use-auth";
|
||||
|
||||
export { usePermission } from "./use-permission.js";
|
||||
export type {
|
||||
UsePermissionProps,
|
||||
UsePermissionReturn,
|
||||
} from "./use-permission.js";
|
||||
export { usePermission } from "./use-permission";
|
||||
export type { UsePermissionProps, UsePermissionReturn } from "./use-permission";
|
||||
|
||||
export { useViewports } from "./use-viewports.js";
|
||||
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports.js";
|
||||
export { useViewports } from "./use-viewports";
|
||||
export type { UseViewportsProps, UseViewportsReturn } from "./use-viewports";
|
||||
|
||||
export { useApi } from "./use-api.js";
|
||||
export type { UseApiProps, UseApiReturn } from "./use-api.js";
|
||||
export { useApi } from "./use-api";
|
||||
export type { UseApiProps, UseApiReturn } from "./use-api";
|
||||
|
||||
export {
|
||||
useA11yId,
|
||||
useA11yIds,
|
||||
mergeA11yProps,
|
||||
describeInput,
|
||||
} from "./use-a11y-id.js";
|
||||
} from "./use-a11y-id";
|
||||
|
||||
export { useAriaLive } from "./use-aria-live.js";
|
||||
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live.js";
|
||||
export { useAriaLive } from "./use-aria-live";
|
||||
export type { UseAriaLiveReturn, AriaLivePoliteness } from "./use-aria-live";
|
||||
|
||||
export { useToast } from "./use-toast.js";
|
||||
export type { UseToastReturn } from "./use-toast.js";
|
||||
export { useToast } from "./use-toast";
|
||||
export type { UseToastReturn } from "./use-toast";
|
||||
|
||||
export { useTraceId } from "./use-trace-id.js";
|
||||
export type { UseTraceIdReturn } from "./use-trace-id.js";
|
||||
export { useTraceId } from "./use-trace-id";
|
||||
export type { UseTraceIdReturn } from "./use-trace-id";
|
||||
|
||||
export {
|
||||
useGraphQLClient,
|
||||
GraphQLClientContext,
|
||||
} from "./use-graphql-client.js";
|
||||
export type { UseGraphQLClientReturn } from "./use-graphql-client.js";
|
||||
export { useErrorReport } from "./use-error-report";
|
||||
export type { ErrorReportPayload } from "./use-error-report";
|
||||
|
||||
export { useGraphQLClient, GraphQLClientContext } from "./use-graphql-client";
|
||||
export type { UseGraphQLClientReturn } from "./use-graphql-client";
|
||||
|
||||
// 共享类型
|
||||
export type {
|
||||
@@ -65,4 +62,14 @@ export type {
|
||||
Viewport,
|
||||
ToastMessage,
|
||||
AuthState,
|
||||
} from "./types.js";
|
||||
} from "./types";
|
||||
|
||||
// portal-shell 插件系统 Hooks(v2.1 spec §9.3)
|
||||
export { usePluginStore, injectPluginStore } from "./use-plugin-store";
|
||||
export type { PluginStoreInstance } from "./use-plugin-store";
|
||||
|
||||
export { usePluginConfig } from "./use-plugin-config";
|
||||
export type {
|
||||
UsePluginConfigOptions,
|
||||
UsePluginConfigReturn,
|
||||
} from "./use-plugin-config";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AuthState, UserSession } from "./types.js";
|
||||
import type { AuthState, UserSession } from "./types";
|
||||
|
||||
/**
|
||||
* useAuth - 会话状态管理
|
||||
|
||||
190
packages/hooks/src/use-error-report.ts
Normal file
190
packages/hooks/src/use-error-report.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
|
||||
/**
|
||||
* useErrorReport - 客户端错误上报 Hook(对齐 CICD use-error-report.ts)
|
||||
*
|
||||
* 通过 navigator.sendBeacon 上报到 /api/log(fallback 到 fetch keepalive)
|
||||
* 节流:基于 error.digest 在 sessionStorage 中记录,1 分钟内同 digest 只上报一次
|
||||
* 上报失败静默降级,不影响用户体验
|
||||
*
|
||||
* 上报 payload 结构:
|
||||
* {
|
||||
* level: "error" | "warning",
|
||||
* message: string,
|
||||
* stack?: string,
|
||||
* digest?: string, // Next.js 自动生成的错误摘要
|
||||
* path: string, // window.location.pathname
|
||||
* userAgent: string,
|
||||
* timestamp: string, // ISO 8601
|
||||
* pluginId?: string, // 插件级错误标识
|
||||
* userId?: string, // 当前用户 ID(从 localStorage 读取)
|
||||
* context?: Record<string, unknown> // 额外上下文
|
||||
* }
|
||||
*
|
||||
* 关联:portal-shell README v2.0 §5.4 三级错误处理
|
||||
*/
|
||||
|
||||
/** 错误上报 payload */
|
||||
export interface ErrorReportPayload {
|
||||
level: "error" | "warning";
|
||||
message: string;
|
||||
stack?: string;
|
||||
digest?: string;
|
||||
path: string;
|
||||
userAgent: string;
|
||||
timestamp: string;
|
||||
pluginId?: string;
|
||||
userId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 上报端点(Next.js API Route mock,未来切换到 OTel / Sentry) */
|
||||
const REPORT_ENDPOINT = "/api/log";
|
||||
|
||||
/** 节流窗口(1 分钟内同 digest 只上报一次) */
|
||||
const THROTTLE_WINDOW_MS = 60_000;
|
||||
|
||||
/** sessionStorage key 前缀 */
|
||||
const THROTTLE_KEY_PREFIX = "edu_err_reported_";
|
||||
|
||||
/**
|
||||
* 读取当前用户 ID(从 localStorage,避免引入 auth 依赖)
|
||||
*/
|
||||
function readUserId(): string | undefined {
|
||||
try {
|
||||
const raw = localStorage.getItem("edu_user_id");
|
||||
return raw ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成错误 digest(简单 hash,用于节流去重)
|
||||
*
|
||||
* 优先使用 error.digest(Next.js 自动生成),
|
||||
* 否则基于 message + stack 前 200 字符生成简单 hash
|
||||
*/
|
||||
function makeDigest(error: Error): string {
|
||||
const nextDigest = (error as Error & { digest?: string }).digest;
|
||||
if (nextDigest) return nextDigest;
|
||||
const stackSnippet = (error.stack ?? "").slice(0, 200);
|
||||
const input = `${error.message}::${stackSnippet}`;
|
||||
// 简单 FNV-1a hash
|
||||
let hash = 2166136261;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= input.charCodeAt(i);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查并更新节流记录
|
||||
*
|
||||
* @returns true 表示应该上报,false 表示已被节流
|
||||
*/
|
||||
function checkThrottle(digest: string): boolean {
|
||||
try {
|
||||
const key = `${THROTTLE_KEY_PREFIX}${digest}`;
|
||||
const now = Date.now();
|
||||
const last = sessionStorage.getItem(key);
|
||||
if (last) {
|
||||
const lastTime = parseInt(last, 10);
|
||||
if (Number.isFinite(lastTime) && now - lastTime < THROTTLE_WINDOW_MS) {
|
||||
return false; // 节流窗口内,跳过
|
||||
}
|
||||
}
|
||||
sessionStorage.setItem(key, String(now));
|
||||
return true;
|
||||
} catch {
|
||||
// sessionStorage 不可用时不过节流,直接上报
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 实际执行上报
|
||||
*/
|
||||
function sendReport(payload: ErrorReportPayload): void {
|
||||
const body = JSON.stringify(payload);
|
||||
|
||||
// 优先 sendBeacon(不阻塞页面卸载)
|
||||
if (typeof navigator !== "undefined" && navigator.sendBeacon) {
|
||||
try {
|
||||
const blob = new Blob([body], { type: "application/json" });
|
||||
if (navigator.sendBeacon(REPORT_ENDPOINT, blob)) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// sendBeacon 失败,降级到 fetch
|
||||
}
|
||||
}
|
||||
|
||||
// 降级到 fetch keepalive
|
||||
try {
|
||||
void fetch(REPORT_ENDPOINT, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
keepalive: true,
|
||||
credentials: "include",
|
||||
}).catch(() => {
|
||||
// 上报失败静默降级
|
||||
});
|
||||
} catch {
|
||||
// 完全失败,静默
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误上报 Hook
|
||||
*
|
||||
* @example
|
||||
* function MyComponent() {
|
||||
* const reportError = useErrorReport();
|
||||
* try { riskyOperation(); }
|
||||
* catch (e) { reportError(e, { pluginId: "grades-widget" }); }
|
||||
* }
|
||||
*
|
||||
* @example 与 ErrorBoundary 配合
|
||||
* <ErrorBoundary onError={(err) => reportError(err)}>
|
||||
* <Plugin />
|
||||
* </ErrorBoundary>
|
||||
*/
|
||||
export function useErrorReport() {
|
||||
const reportError = useCallback(
|
||||
(
|
||||
error: Error,
|
||||
options?: {
|
||||
pluginId?: string;
|
||||
level?: "error" | "warning";
|
||||
context?: Record<string, unknown>;
|
||||
},
|
||||
): void => {
|
||||
const digest = makeDigest(error);
|
||||
if (!checkThrottle(digest)) return;
|
||||
|
||||
const payload: ErrorReportPayload = {
|
||||
level: options?.level ?? "error",
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
digest,
|
||||
path: typeof window !== "undefined" ? window.location.pathname : "/",
|
||||
userAgent:
|
||||
typeof navigator !== "undefined" ? navigator.userAgent : "unknown",
|
||||
timestamp: new Date().toISOString(),
|
||||
pluginId: options?.pluginId,
|
||||
userId: readUserId(),
|
||||
context: options?.context,
|
||||
};
|
||||
|
||||
sendReport(payload);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return reportError;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type { PermissionContext } from "./types.js";
|
||||
import type { PermissionContext } from "./types";
|
||||
|
||||
/**
|
||||
* usePermission - 权限查询 Hook
|
||||
|
||||
133
packages/hooks/src/use-plugin-config.ts
Normal file
133
packages/hooks/src/use-plugin-config.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* usePluginConfig - 插件配置静默刷新 Hook(portal-shell spec §6.4)
|
||||
*
|
||||
* 通用封装,不直接依赖 SWR 或 Apollo Client。
|
||||
* fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 的设计原则。
|
||||
*
|
||||
* 特性:
|
||||
* - 支持 fallbackData(RSC 预取的 initialData)
|
||||
* - 支持轮询刷新(refreshInterval)
|
||||
* - 支持配置变化回调(onChanged)
|
||||
*
|
||||
* 关联:portal-shell spec §6.4、§9.3
|
||||
*/
|
||||
|
||||
export interface UsePluginConfigOptions {
|
||||
/** RSC 直出的初始配置(fallbackData) */
|
||||
initialConfig: PluginConfigResponse;
|
||||
/** 当前用户 ID */
|
||||
userId: string;
|
||||
/** 当前用户角色 */
|
||||
role: string;
|
||||
/** 配置变化回调(上层用于 Toast 提示) */
|
||||
onChanged?: () => void;
|
||||
/** 刷新间隔(ms),默认 5 分钟 */
|
||||
refreshInterval?: number;
|
||||
/** fetcher 函数(由消费者注入) */
|
||||
fetcher: (userId: string, role: string) => Promise<PluginConfigResponse>;
|
||||
}
|
||||
|
||||
export interface UsePluginConfigReturn {
|
||||
/** 当前配置 */
|
||||
config: PluginConfigResponse;
|
||||
/** 手动刷新 */
|
||||
refresh: () => Promise<void>;
|
||||
/** 是否正在刷新 */
|
||||
isValidating: boolean;
|
||||
/** 刷新错误 */
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 静默刷新插件配置。
|
||||
*
|
||||
* @example
|
||||
* const { config, refresh } = usePluginConfig({
|
||||
* initialConfig,
|
||||
* userId,
|
||||
* role,
|
||||
* fetcher: async (uid, r) => {
|
||||
* const client = getApolloClient();
|
||||
* const { data } = await client.query({ query: GET_PLUGIN_CONFIG, variables: { userId: uid, role: r } });
|
||||
* return data.pluginConfig;
|
||||
* },
|
||||
* onChanged: () => showToast("配置已更新"),
|
||||
* });
|
||||
*/
|
||||
export function usePluginConfig(
|
||||
options: UsePluginConfigOptions,
|
||||
): UsePluginConfigReturn {
|
||||
const {
|
||||
initialConfig,
|
||||
userId,
|
||||
role,
|
||||
onChanged,
|
||||
fetcher,
|
||||
refreshInterval = 300_000,
|
||||
} = options;
|
||||
const [config, setConfig] = useState<PluginConfigResponse>(initialConfig);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const doRefresh = async (): Promise<void> => {
|
||||
setIsValidating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newConfig = await fetcher(userId, role);
|
||||
if (hasConfigChanged(config, newConfig)) {
|
||||
setConfig(newConfig);
|
||||
onChanged?.();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error(String(err)));
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 轮询刷新 + 网络恢复刷新
|
||||
useEffect(() => {
|
||||
const interval = setInterval(doRefresh, refreshInterval);
|
||||
const handleOnline = (): void => {
|
||||
void doRefresh();
|
||||
};
|
||||
const handleVisibility = (): void => {
|
||||
if (document.visibilityState === "visible") {
|
||||
void doRefresh();
|
||||
}
|
||||
};
|
||||
window.addEventListener("online", handleOnline);
|
||||
document.addEventListener("visibilitychange", handleVisibility);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("online", handleOnline);
|
||||
document.removeEventListener("visibilitychange", handleVisibility);
|
||||
};
|
||||
}, [refreshInterval, userId, role]);
|
||||
|
||||
return {
|
||||
config,
|
||||
refresh: doRefresh,
|
||||
isValidating,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
/** 浅比较配置是否变化(layoutId / 插件集合 / 可见性) */
|
||||
function hasConfigChanged(
|
||||
prev: PluginConfigResponse,
|
||||
next: PluginConfigResponse,
|
||||
): boolean {
|
||||
if (prev.activeLayout?.layoutId !== next.activeLayout?.layoutId) return true;
|
||||
if (prev.plugins.length !== next.plugins.length) return true;
|
||||
const prevIds = prev.plugins
|
||||
.map((p) => `${p.pluginId}:${p.isVisible}`)
|
||||
.sort();
|
||||
const nextIds = next.plugins
|
||||
.map((p) => `${p.pluginId}:${p.isVisible}`)
|
||||
.sort();
|
||||
return prevIds.some((id, i) => id !== nextIds[i]);
|
||||
}
|
||||
80
packages/hooks/src/use-plugin-store.ts
Normal file
80
packages/hooks/src/use-plugin-store.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type {
|
||||
PluginStoreState,
|
||||
ThemeMode,
|
||||
Locale,
|
||||
} from "@edu/shared-ts/contracts";
|
||||
|
||||
/**
|
||||
* usePluginStore - Zustand 全局状态 Hook(portal-shell spec §5.2.2)
|
||||
*
|
||||
* 封装 Zustand store 的订阅,提供 theme/locale/sidebarCollapsed 状态管理。
|
||||
* 此 hook 是通用封装,实际 store 实例由 portal-shell 创建并注入。
|
||||
*
|
||||
* 设计原则(@edu/hooks):hooks 不直接依赖特定 store 实例,
|
||||
* 通过 subscribe/getSnapshot 与外部 store 交互。
|
||||
*
|
||||
* 关联:portal-shell spec §5.2.2、§9.3
|
||||
*/
|
||||
|
||||
/** Store 实例接口(与 Zustand create() 返回值兼容) */
|
||||
export interface PluginStoreInstance extends PluginStoreState {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getState: () => PluginStoreState;
|
||||
}
|
||||
|
||||
/** 全局 store 引用(由 portal-shell 注入) */
|
||||
let globalStore: PluginStoreInstance | null = null;
|
||||
|
||||
/**
|
||||
* 注入全局 PluginStore 实例(portal-shell 启动时调用)
|
||||
*
|
||||
* @example
|
||||
* import { usePluginStore as originalStore } from "@/shell/PluginStore";
|
||||
* injectPluginStore(originalStore as PluginStoreInstance);
|
||||
*/
|
||||
export function injectPluginStore(store: PluginStoreInstance): void {
|
||||
globalStore = store;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 PluginStore 全局状态(theme/locale/sidebarCollapsed)
|
||||
*
|
||||
* @example
|
||||
* const { theme, setTheme } = usePluginStore();
|
||||
*/
|
||||
export function usePluginStore(): PluginStoreState {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
if (!globalStore) return () => {};
|
||||
return globalStore.subscribe(listener);
|
||||
},
|
||||
() => {
|
||||
if (!globalStore) {
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
return globalStore.getState();
|
||||
},
|
||||
() => DEFAULT_STATE,
|
||||
);
|
||||
}
|
||||
|
||||
/** 默认状态(store 未注入时使用) */
|
||||
const DEFAULT_STATE: PluginStoreState = {
|
||||
theme: "light",
|
||||
setTheme: (_theme: ThemeMode) => {
|
||||
// store 未注入时的空操作
|
||||
},
|
||||
locale: "zh-CN",
|
||||
setLocale: (_locale: Locale) => {
|
||||
// store 未注入时的空操作
|
||||
},
|
||||
sidebarCollapsed: false,
|
||||
toggleSidebar: () => {
|
||||
// store 未注入时的空操作
|
||||
},
|
||||
unreadNotificationIds: [],
|
||||
markNotificationsRead: (_ids: string[]) => {
|
||||
// store 未注入时的空操作
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ToastMessage } from "./types.js";
|
||||
import type { ToastMessage } from "./types";
|
||||
|
||||
/**
|
||||
* useToast - 全局 Toast 通知管理
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Viewport } from "./types.js";
|
||||
import type { Viewport } from "./types";
|
||||
|
||||
/**
|
||||
* useViewports - 视口列表查询
|
||||
|
||||
@@ -15,6 +15,18 @@
|
||||
"./federation": {
|
||||
"types": "./dist/federation/index.d.ts",
|
||||
"default": "./dist/federation/index.js"
|
||||
},
|
||||
"./contracts": {
|
||||
"types": "./dist/contracts/index.d.ts",
|
||||
"default": "./dist/contracts/index.js"
|
||||
},
|
||||
"./env-loader": {
|
||||
"types": "./dist/env-loader/index.d.ts",
|
||||
"default": "./dist/env-loader/index.js"
|
||||
},
|
||||
"./permission-bitmap": {
|
||||
"types": "./dist/permission-bitmap.d.ts",
|
||||
"default": "./dist/permission-bitmap.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
41
packages/shared-ts/src/contracts/index.ts
Normal file
41
packages/shared-ts/src/contracts/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* portal-shell 契约汇总导出(portal-shell spec §9.3)
|
||||
*
|
||||
* 由 portal-shell / ui-components / hooks 共享的类型契约。
|
||||
* 后端 config-service 的 GraphQL schema 与此对齐。
|
||||
*
|
||||
* 关联:portal-shell spec §5.1 PluginProps 契约、§4 Layout 模型、§5.2 跨插件状态管理
|
||||
*/
|
||||
export type {
|
||||
Role,
|
||||
PluginSize,
|
||||
PluginCategory,
|
||||
JsonSchema,
|
||||
PluginProps,
|
||||
PluginManifest,
|
||||
PluginManifestMeta,
|
||||
} from "./plugin.js";
|
||||
|
||||
export type {
|
||||
LayoutTemplateInfo,
|
||||
SlotConfig,
|
||||
PluginPlacement,
|
||||
PluginRegistryItem,
|
||||
PluginConfigResponse,
|
||||
LayoutTemplateId,
|
||||
SlotName,
|
||||
RoleLayoutDefault,
|
||||
UserLayoutOverride,
|
||||
} from "./layout.js";
|
||||
|
||||
export { LAYOUT_TEMPLATE_IDS, SLOT_NAMES } from "./layout.js";
|
||||
|
||||
export type { ThemeMode, Locale, PluginStoreState } from "./plugin-store.js";
|
||||
|
||||
export type { UrlPluginContext, UrlContextKey } from "./plugin-context.js";
|
||||
|
||||
export {
|
||||
URL_CONTEXT_KEYS,
|
||||
parseUrlContext,
|
||||
writeUrlContext,
|
||||
} from "./plugin-context.js";
|
||||
135
packages/shared-ts/src/contracts/layout.ts
Normal file
135
packages/shared-ts/src/contracts/layout.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Layout 与 Slot 配置类型(portal-shell spec §4、§6.2)
|
||||
*
|
||||
* 对应 config-service PluginConfigResponse(三层合并后的插件配置)。
|
||||
* 类型与 services/config-service 的 GraphQL schema 对齐,
|
||||
* 通过 apollo-router GraphQL 查询 pluginConfig(userId, role) 获取。
|
||||
*
|
||||
* 关联:portal-shell spec §4.1 5 种 Layout 模板、§4.2 Slot 系统、§6.2 PluginConfigResponse
|
||||
*/
|
||||
import type { Role } from "./plugin.js";
|
||||
|
||||
/** Layout 模板信息(对应 config-service LayoutTemplateInfo) */
|
||||
export interface LayoutTemplateInfo {
|
||||
/** Layout ID:'classic' | 'focus' | 'split' | 'triple' | 'canvas' */
|
||||
layoutId: string;
|
||||
/** 显示名 */
|
||||
displayName: string;
|
||||
/** 描述 */
|
||||
description: string;
|
||||
/** 可用 slot 列表,如 ["top", "side", "main"] */
|
||||
availableSlots: string[];
|
||||
/** Layout schema JSON 字符串(grid 配置等) */
|
||||
layoutSchemaJson: string;
|
||||
}
|
||||
|
||||
/** Slot 配置(对应 config-service SlotConfig) */
|
||||
export interface SlotConfig {
|
||||
/** Slot 名称:'top' | 'side' | 'main' | 'main-left' | 'main-right' | 'right' | 'canvas-grid' */
|
||||
slotName: string;
|
||||
/** 导航项列表(side slot 的导航菜单项) */
|
||||
navItems: string[];
|
||||
}
|
||||
|
||||
/** 插件放置(三层合并后,对应 config-service PluginPlacement) */
|
||||
export interface PluginPlacement {
|
||||
/** 插件 ID */
|
||||
pluginId: string;
|
||||
/** 插入的 slot 名称 */
|
||||
slot: string;
|
||||
/** 显示顺序(升序) */
|
||||
sortOrder: number;
|
||||
/** 尺寸 JSON 字符串({colSpan, rowSpan}) */
|
||||
sizeJson: string;
|
||||
/** 三层合并后的最终 props JSON 字符串 */
|
||||
propsJson: string;
|
||||
/** 是否可见 */
|
||||
isVisible: boolean;
|
||||
}
|
||||
|
||||
/** 插件注册项(对应 config-service PluginRegistryItem) */
|
||||
export interface PluginRegistryItem {
|
||||
/** 插件 ID */
|
||||
pluginId: string;
|
||||
/** 分类:'universal' | 'sidebar' | 'topbar' | 'teacher' | 'student' | 'parent' | 'admin' */
|
||||
category: string;
|
||||
/** 版本(semver) */
|
||||
version: string;
|
||||
/** 显示名 */
|
||||
displayName: string;
|
||||
/** 描述 */
|
||||
description: string;
|
||||
/** 可访问此插件的角色列表 */
|
||||
requiredRoles: string[];
|
||||
/** 是否内置插件 */
|
||||
isBuiltin: boolean;
|
||||
/** 是否全局启用 */
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 三层合并后的插件配置响应(对应 config-service PluginConfigResponse)
|
||||
*
|
||||
* 由 config-service 合并三层配置后返回:
|
||||
* Layer 1: plugin_registry(系统默认)
|
||||
* Layer 2: role_plugin_mapping(角色模板)
|
||||
* Layer 3: user_layout_override(用户覆盖)
|
||||
*/
|
||||
export interface PluginConfigResponse {
|
||||
/** 当前启用的 Layout 模板 */
|
||||
activeLayout: LayoutTemplateInfo | null;
|
||||
/** Slot 配置列表 */
|
||||
slots: SlotConfig[];
|
||||
/** 插件放置列表(三层合并后) */
|
||||
plugins: PluginPlacement[];
|
||||
/** 插件注册表(所有可用插件) */
|
||||
registry: PluginRegistryItem[];
|
||||
}
|
||||
|
||||
/** Layout 模板 ID 枚举(portal-shell spec §4.1) */
|
||||
export const LAYOUT_TEMPLATE_IDS = [
|
||||
"classic",
|
||||
"focus",
|
||||
"split",
|
||||
"triple",
|
||||
"canvas",
|
||||
] as const;
|
||||
|
||||
/** Layout 模板 ID 类型 */
|
||||
export type LayoutTemplateId = (typeof LAYOUT_TEMPLATE_IDS)[number];
|
||||
|
||||
/** Slot 名称枚举(portal-shell spec §4.2) */
|
||||
export const SLOT_NAMES = [
|
||||
"top",
|
||||
"side",
|
||||
"main",
|
||||
"main-left",
|
||||
"main-right",
|
||||
"right",
|
||||
"canvas-grid",
|
||||
] as const;
|
||||
|
||||
/** Slot 名称类型 */
|
||||
export type SlotName = (typeof SLOT_NAMES)[number];
|
||||
|
||||
/** 角色-Layout 默认配置(admin 配置角色默认模板) */
|
||||
export interface RoleLayoutDefault {
|
||||
role: Role;
|
||||
layoutId: string;
|
||||
slotOverrides: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 用户布局覆盖(用户自定义) */
|
||||
export interface UserLayoutOverride {
|
||||
userId: string;
|
||||
activeLayout: string;
|
||||
slotOverrides: Record<string, unknown>;
|
||||
pluginPlacements: Array<{
|
||||
pluginId: string;
|
||||
slot: string;
|
||||
sortOrder: number;
|
||||
size: { colSpan: number; rowSpan: number };
|
||||
props: Record<string, unknown>;
|
||||
}>;
|
||||
hiddenPlugins: string[];
|
||||
}
|
||||
105
packages/shared-ts/src/contracts/plugin-context.ts
Normal file
105
packages/shared-ts/src/contracts/plugin-context.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* URL Search Params 上下文 schema(portal-shell spec §5.2.1)
|
||||
*
|
||||
* 适合需要 URL 分享、浏览器前进后退的全局上下文。
|
||||
* 读取:useSearchParams()(next/navigation)
|
||||
* 写入:router.push('?classId=xxx')
|
||||
* 响应:其他插件通过 useSearchParams() 自动响应,触发重新渲染。
|
||||
*
|
||||
* 关联:portal-shell spec §5.2.1 URL 驱动、§5.2.3 URL vs Zustand 选型标准、§9.3 共享包扩展
|
||||
*/
|
||||
|
||||
/**
|
||||
* URL 驱动的全局上下文(可分享、可前进后退)
|
||||
*
|
||||
* 这些 key 对应 URL Search Params 的参数名。
|
||||
* 插件通过 useSearchParams().get(key) 读取,router.push 更新。
|
||||
*/
|
||||
export interface UrlPluginContext {
|
||||
/** 当前选中的班级 ID(教师视角,class-selector 切换时更新 URL) */
|
||||
classId?: string;
|
||||
/** 当前选中的孩子 ID(家长视角,child-selector 切换时更新 URL) */
|
||||
childId?: string;
|
||||
/** 当前选中的学期(term-switcher 切换时更新 URL) */
|
||||
termId?: string;
|
||||
/** 当前视图模式(如 grades-widget 的 'list' | 'chart') */
|
||||
view?: string;
|
||||
/** 当前选中的科目(部分插件按科目过滤) */
|
||||
subjectId?: string;
|
||||
/** 当前选中的考试 ID(exams-widget 切换时更新 URL) */
|
||||
examId?: string;
|
||||
}
|
||||
|
||||
/** URL 上下文参数名常量(避免拼写错误) */
|
||||
export const URL_CONTEXT_KEYS = {
|
||||
classId: "classId",
|
||||
childId: "childId",
|
||||
termId: "termId",
|
||||
view: "view",
|
||||
subjectId: "subjectId",
|
||||
examId: "examId",
|
||||
} as const;
|
||||
|
||||
/** URL 上下文参数名类型 */
|
||||
export type UrlContextKey = keyof UrlPluginContext;
|
||||
|
||||
/**
|
||||
* 从 URLSearchParams 解析 UrlPluginContext
|
||||
*
|
||||
* @example
|
||||
* const ctx = parseUrlContext(new URLSearchParams(window.location.search));
|
||||
*/
|
||||
export function parseUrlContext(params: URLSearchParams): UrlPluginContext {
|
||||
const ctx: UrlPluginContext = {};
|
||||
const classId = params.get(URL_CONTEXT_KEYS.classId);
|
||||
if (classId) ctx.classId = classId;
|
||||
const childId = params.get(URL_CONTEXT_KEYS.childId);
|
||||
if (childId) ctx.childId = childId;
|
||||
const termId = params.get(URL_CONTEXT_KEYS.termId);
|
||||
if (termId) ctx.termId = termId;
|
||||
const view = params.get(URL_CONTEXT_KEYS.view);
|
||||
if (view) ctx.view = view;
|
||||
const subjectId = params.get(URL_CONTEXT_KEYS.subjectId);
|
||||
if (subjectId) ctx.subjectId = subjectId;
|
||||
const examId = params.get(URL_CONTEXT_KEYS.examId);
|
||||
if (examId) ctx.examId = examId;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 UrlPluginContext 写入 URLSearchParams
|
||||
*
|
||||
* @example
|
||||
* const params = new URLSearchParams();
|
||||
* writeUrlContext(params, { classId: 'cls-1' });
|
||||
* router.push(`?${params.toString()}`);
|
||||
*/
|
||||
export function writeUrlContext(
|
||||
params: URLSearchParams,
|
||||
ctx: Partial<UrlPluginContext>,
|
||||
): void {
|
||||
if (ctx.classId !== undefined) {
|
||||
if (ctx.classId) params.set(URL_CONTEXT_KEYS.classId, ctx.classId);
|
||||
else params.delete(URL_CONTEXT_KEYS.classId);
|
||||
}
|
||||
if (ctx.childId !== undefined) {
|
||||
if (ctx.childId) params.set(URL_CONTEXT_KEYS.childId, ctx.childId);
|
||||
else params.delete(URL_CONTEXT_KEYS.childId);
|
||||
}
|
||||
if (ctx.termId !== undefined) {
|
||||
if (ctx.termId) params.set(URL_CONTEXT_KEYS.termId, ctx.termId);
|
||||
else params.delete(URL_CONTEXT_KEYS.termId);
|
||||
}
|
||||
if (ctx.view !== undefined) {
|
||||
if (ctx.view) params.set(URL_CONTEXT_KEYS.view, ctx.view);
|
||||
else params.delete(URL_CONTEXT_KEYS.view);
|
||||
}
|
||||
if (ctx.subjectId !== undefined) {
|
||||
if (ctx.subjectId) params.set(URL_CONTEXT_KEYS.subjectId, ctx.subjectId);
|
||||
else params.delete(URL_CONTEXT_KEYS.subjectId);
|
||||
}
|
||||
if (ctx.examId !== undefined) {
|
||||
if (ctx.examId) params.set(URL_CONTEXT_KEYS.examId, ctx.examId);
|
||||
else params.delete(URL_CONTEXT_KEYS.examId);
|
||||
}
|
||||
}
|
||||
36
packages/shared-ts/src/contracts/plugin-store.ts
Normal file
36
packages/shared-ts/src/contracts/plugin-store.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Zustand 全局状态 schema(portal-shell spec §5.2.2)
|
||||
*
|
||||
* 管理纯 UI、不可分享的全局状态(theme/locale/sidebarCollapsed)。
|
||||
* 跨插件可分享状态走 URL Search Params(见 plugin-context.ts),不进此 Store。
|
||||
*
|
||||
* 此文件仅定义 schema 类型,实际 create() 实现在 portal-shell/src/shell/PluginStore.ts。
|
||||
* shared-ts 不依赖 zustand,仅提供类型契约供 hooks 包引用。
|
||||
*
|
||||
* 关联:portal-shell spec §5.2.2 Zustand Store、§9.3 共享包扩展
|
||||
*/
|
||||
|
||||
/** 主题模式 */
|
||||
export type ThemeMode = "light" | "dark";
|
||||
|
||||
/** i18n locale */
|
||||
export type Locale = "zh-CN" | "en";
|
||||
|
||||
/** PluginStore 状态形状(portal-shell spec §5.2.2) */
|
||||
export interface PluginStoreState {
|
||||
/** 主题模式(light/dark) */
|
||||
theme: ThemeMode;
|
||||
setTheme: (theme: ThemeMode) => void;
|
||||
|
||||
/** i18n locale */
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
|
||||
/** Sidebar 折叠状态 */
|
||||
sidebarCollapsed: boolean;
|
||||
toggleSidebar: () => void;
|
||||
|
||||
/** 通知已读标记(纯 UI 状态,不进 URL) */
|
||||
unreadNotificationIds: string[];
|
||||
markNotificationsRead: (ids: string[]) => void;
|
||||
}
|
||||
134
packages/shared-ts/src/contracts/plugin.ts
Normal file
134
packages/shared-ts/src/contracts/plugin.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* 插件契约类型定义(portal-shell spec §5.1)
|
||||
*
|
||||
* Portal Shell 插件化的核心类型契约,由 portal-shell / ui-components / hooks 共享。
|
||||
* 所有内置插件通过 PluginManifest 声明元数据,Shell 通过 PluginProps 注入运行时上下文。
|
||||
*
|
||||
* 关联:portal-shell spec §5.1 PluginProps 契约、§9.3 共享包扩展
|
||||
*/
|
||||
|
||||
/** 用户角色 */
|
||||
export type Role = "admin" | "teacher" | "student" | "parent";
|
||||
|
||||
/** 插件尺寸(colSpan / rowSpan) */
|
||||
export interface PluginSize {
|
||||
colSpan: number;
|
||||
rowSpan: number;
|
||||
}
|
||||
|
||||
/** 插件分类(portal-shell spec §3) */
|
||||
export type PluginCategory =
|
||||
| "universal"
|
||||
| "sidebar"
|
||||
| "topbar"
|
||||
| "teacher"
|
||||
| "student"
|
||||
| "parent"
|
||||
| "admin";
|
||||
|
||||
/** 简易 JSON Schema 类型(用于 propsSchema 声明) */
|
||||
export interface JsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
items?: JsonSchema;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件 Props 契约(portal-shell spec §5.1)
|
||||
*
|
||||
* Shell 通过此契约向插件注入运行时上下文,插件只通过此契约与 Shell 交互。
|
||||
* 跨插件状态不通过 props 传递,而是插件自行调用:
|
||||
* - useSearchParams() 读取 URL 上下文(classId/childId/termId/view)
|
||||
* - usePluginStore() 读取 Zustand 全局状态(theme/locale/sidebarCollapsed)
|
||||
* - useWidgetQuery() 读取 BFF 业务数据(自动按 role 路由)
|
||||
*/
|
||||
export interface PluginProps<TProps = Record<string, unknown>> {
|
||||
/** 插件实例 ID(同一插件多实例时区分) */
|
||||
instanceId: string;
|
||||
/** 当前用户角色 */
|
||||
role: Role;
|
||||
/** 当前用户信息(来自 IAM) */
|
||||
user: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
/** 数据范围(DataScope,IAM 计算后的可见范围 token) */
|
||||
dataScope: string;
|
||||
};
|
||||
/** 当前 slot 信息 */
|
||||
slot: {
|
||||
/** slot 名称:'main' | 'side' | 'top' | 'main-left' | 'main-right' | 'right' | 'canvas-grid' */
|
||||
name: string;
|
||||
/** Layout 模板 ID:'classic' | 'focus' | 'split' | 'triple' | 'canvas' */
|
||||
layoutId: string;
|
||||
/** 插件尺寸(colSpan / rowSpan) */
|
||||
size?: PluginSize;
|
||||
};
|
||||
/** 插件自定义 props(三层合并后的最终值) */
|
||||
props: TProps;
|
||||
/** 服务端预取的初始数据(RSC 直出,避免客户端瀑布流) */
|
||||
initialData?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件清单(portal-shell spec §5.1 PluginManifest)
|
||||
*
|
||||
* 每个内置插件通过 plugin.manifest.ts 声明此清单,
|
||||
* Registry 编译时登记,运行时由 SlotRenderer 查表渲染。
|
||||
*
|
||||
* 三层安全边界(portal-shell README v2.0 §3.3):
|
||||
* - L1 角色门禁(requiredRoles):粗粒度,4 角色之一即可访问
|
||||
* - L2 权限点门禁(requiredPermissions):细粒度,基于 PERMISSION_BITMAP_ORDER
|
||||
* - L3 数据范围(user.dataScope):运行时由插件内部 usePermission 校验
|
||||
*
|
||||
* 注意:shared-ts 是后端共享包,不依赖 React。
|
||||
* Component 字段在此为 unknown,前端包(portal-shell)引用时
|
||||
* 通过类型断言转换为 React.ComponentType<PluginProps>。
|
||||
*/
|
||||
export interface PluginManifest {
|
||||
/** 插件 ID(唯一,kebab-case,如 'grades-widget') */
|
||||
pluginId: string;
|
||||
/** 插件版本(semver) */
|
||||
version: string;
|
||||
/** 兼容的 Shell 版本范围(semver range,如 "^1.0.0") */
|
||||
requiredShellVersion: string;
|
||||
/** React 组件(前端包引用时断言为 React.ComponentType<PluginProps>) */
|
||||
Component: unknown;
|
||||
/** 插件元数据 */
|
||||
metadata: {
|
||||
displayName: string;
|
||||
description: string;
|
||||
category: PluginCategory;
|
||||
/** L1 角色门禁:可访问此插件的角色列表(粗粒度) */
|
||||
requiredRoles: Role[];
|
||||
/**
|
||||
* L2 权限点门禁:访问此插件所需的权限点列表(细粒度)
|
||||
*
|
||||
* - 空数组或 undefined:仅 L1 角色门禁生效
|
||||
* - 非空数组:用户必须同时拥有所有权限点(AND 语义)
|
||||
* - 权限点必须来自 PERMISSION_BITMAP_ORDER(运行时由 isValidPermission 校验)
|
||||
*
|
||||
* @example
|
||||
* // 仅 USER_MANAGE 权限可访问
|
||||
* requiredPermissions: ["USER_MANAGE"]
|
||||
* // 需同时拥有 EXAM_CREATE 和 EXAM_GRADE
|
||||
* requiredPermissions: ["EXAM_CREATE", "EXAM_GRADE"]
|
||||
*/
|
||||
requiredPermissions?: string[];
|
||||
/** 默认插入的 slot 名称 */
|
||||
defaultSlot: string;
|
||||
/** 默认尺寸 */
|
||||
defaultSize: PluginSize;
|
||||
/** 插件可配置的 props schema(JSON Schema,admin 配置面板自动渲染表单) */
|
||||
propsSchema?: JsonSchema;
|
||||
/** 系统默认 props(与 propsSchema 配合) */
|
||||
defaultProps?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/** 插件清单元数据(不含 Component,用于 plugin.manifest.ts 声明) */
|
||||
export type PluginManifestMeta = Omit<PluginManifest, "Component">;
|
||||
102
packages/shared-ts/src/env-loader/index.ts
Normal file
102
packages/shared-ts/src/env-loader/index.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* .env 文件加载器(dev 模式专用).
|
||||
*
|
||||
* 背景:
|
||||
* - NestJS 服务的 main.ts 直接 `process.env` 读取环境变量,无 dotenv 自动加载。
|
||||
* - 在 PowerShell + pnpm dev 链中,部分变量可能丢失(Start-Process 子进程继承问题)。
|
||||
* - 本模块在每个 NestJS 服务的 main.ts 顶部最先调用,从 monorepo 根 .env 加载。
|
||||
*
|
||||
* 行为:
|
||||
* - 从调用方 cwd 向上查找 .env(最多 5 层),加载第一个找到的文件。
|
||||
* - 仅在 `process.env[KEY]` 为空/未定义时设置,不覆盖真实环境变量。
|
||||
* - 支持 `KEY=VALUE`、`KEY="VALUE"`、`KEY='VALUE'`,忽略注释与空行。
|
||||
*
|
||||
* 用法:
|
||||
* ```ts
|
||||
* import "@edu/shared-ts/env-loader"; // 副作用导入,main.ts 第一行
|
||||
* ```
|
||||
*
|
||||
* 仲裁依据:
|
||||
* - coord-final-decisions §1 G4(pino 结构化日志)
|
||||
* - DEV_MODE=true 旁路鉴权(ADR-019)
|
||||
*/
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* 解析单个 .env 文件内容为 [key, value] 数组。
|
||||
* @internal
|
||||
*/
|
||||
function parseEnvContent(content: string): Array<[string, string]> {
|
||||
const entries: Array<[string, string]> = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
const eqIdx = line.indexOf("=");
|
||||
if (eqIdx === -1) continue;
|
||||
const key = line.substring(0, eqIdx).trim();
|
||||
if (!key) continue;
|
||||
let val = line.substring(eqIdx + 1).trim();
|
||||
// 移除引号
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.substring(1, val.length - 1);
|
||||
}
|
||||
entries.push([key, val]);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 startDir 向上查找 .env 文件,最多向上 maxDepth 层。
|
||||
* @internal
|
||||
*/
|
||||
function findEnvFile(startDir: string, maxDepth = 5): string | null {
|
||||
let current = startDir;
|
||||
for (let i = 0; i < maxDepth; i++) {
|
||||
const candidate = join(current, ".env");
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) break; // 到达根目录
|
||||
current = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 .env 文件到 process.env(仅 dev 模式)。
|
||||
*
|
||||
* - 当 NODE_ENV === "production" 时跳过(生产环境必须用真实环境变量)。
|
||||
* - 当 DEV_MODE === "false" 时不跳过(DEV_MODE 仅控制鉴权旁路,不影响 .env 加载)。
|
||||
*
|
||||
* @returns 加载的变量数量(已存在的不计)
|
||||
*/
|
||||
export function loadEnvFile(): number {
|
||||
if (process.env.NODE_ENV === "production") return 0;
|
||||
|
||||
const envPath = findEnvFile(process.cwd());
|
||||
if (!envPath) return 0;
|
||||
|
||||
let loaded = 0;
|
||||
try {
|
||||
const content = readFileSync(envPath, "utf-8");
|
||||
const entries = parseEnvContent(content);
|
||||
for (const [key, val] of entries) {
|
||||
// 仅在未设置或空时填充,不覆盖真实环境变量
|
||||
const existing = process.env[key];
|
||||
if (existing === undefined || existing === "") {
|
||||
process.env[key] = val;
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 静默失败,env.ts 的 zod 校验会给出明确错误
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
// 模块导入时自动加载一次(副作用导入模式)
|
||||
loadEnvFile();
|
||||
273
packages/shared-ts/src/permission-bitmap.ts
Normal file
273
packages/shared-ts/src/permission-bitmap.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 权限位图编解码(对齐 CICD 项目 permission-bitmap.ts)
|
||||
*
|
||||
* 核心价值:将 N 个权限点压缩为 base36 字符串,JWT cookie 体积减少 ~99%。
|
||||
* - 67 权限点数组(JSON ~1.1KB)→ base36 字符串(~14 字符)
|
||||
* - Edge Runtime 单点检查 hasPermissionInBitmap 无需完整解码
|
||||
*
|
||||
* 关键约束(不可破坏):
|
||||
* - PERMISSION_BITMAP_ORDER 顺序一经确定不可变,新增权限只能追加末尾
|
||||
* - 因 Number 仅支持 53 bit 精度,使用 BigInt 实现
|
||||
* - 未知权限静默忽略,无效字符返回空数组
|
||||
*
|
||||
* 编码原理:
|
||||
* - 每个权限点对应一个 bit 位(按 ORDER 数组下标)
|
||||
* - permissions 数组 → BigInt(每个有权限的 bit 置 1)→ base36 字符串
|
||||
* - 解码:base36 → BigInt → 遍历 ORDER,bit 为 1 的权限加入结果
|
||||
*
|
||||
* 关联:project_rules §3.1(前端禁止 role === "xxx" 硬编码)、
|
||||
* portal-shell README v2.0 §3.3 三层安全边界
|
||||
*/
|
||||
|
||||
/**
|
||||
* 权限点位图顺序(一经确定不可变,新增只能追加末尾)
|
||||
*
|
||||
* 命名规范:`<RESOURCE>_<ACTION>`(F7 裁决)
|
||||
* 数据范围后缀:`_OWN`/`_CHILD`(如 `GRADE_READ_CHILD`)
|
||||
*
|
||||
* 注意:顺序变更会破坏所有已签发的 JWT cookie,只能在版本升级时追加。
|
||||
*/
|
||||
export const PERMISSION_BITMAP_ORDER = [
|
||||
// ── 仪表盘(5)─────────────────────────────────────────
|
||||
"DASHBOARD_ADMIN_READ",
|
||||
"DASHBOARD_TEACHER_READ",
|
||||
"DASHBOARD_STUDENT_READ",
|
||||
"DASHBOARD_PARENT_READ",
|
||||
"DASHBOARD_READ",
|
||||
|
||||
// ── 用户管理(4)──────────────────────────────────────
|
||||
"USER_MANAGE",
|
||||
"USER_CREATE",
|
||||
"USER_UPDATE",
|
||||
"USER_DELETE",
|
||||
|
||||
// ── 角色权限(4)──────────────────────────────────────
|
||||
"ROLE_READ",
|
||||
"ROLE_MANAGE",
|
||||
"PERMISSION_READ",
|
||||
"PERMISSION_MANAGE",
|
||||
|
||||
// ── 审计与邀请(3)────────────────────────────────────
|
||||
"AUDIT_LOG_READ",
|
||||
"INVITATION_CODE_MANAGE",
|
||||
"INVITATION_CODE_CREATE",
|
||||
|
||||
// ── 学校设置(2)──────────────────────────────────────
|
||||
"SCHOOL_READ",
|
||||
"SCHOOL_MANAGE",
|
||||
|
||||
// ── 班级与年级(4)────────────────────────────────────
|
||||
"CLASS_READ",
|
||||
"CLASS_MANAGE",
|
||||
"GRADE_READ",
|
||||
"GRADE_MANAGE",
|
||||
|
||||
// ── 考试(5)──────────────────────────────────────────
|
||||
"EXAM_READ",
|
||||
"EXAM_CREATE",
|
||||
"EXAM_UPDATE",
|
||||
"EXAM_DELETE",
|
||||
"EXAM_GRADE",
|
||||
|
||||
// ── 作业(4)──────────────────────────────────────────
|
||||
"HOMEWORK_READ",
|
||||
"HOMEWORK_CREATE",
|
||||
"HOMEWORK_SUBMIT",
|
||||
"HOMEWORK_GRADE",
|
||||
|
||||
// ── 成绩(5)──────────────────────────────────────────
|
||||
"GRADE_READ",
|
||||
"GRADE_READ_OWN",
|
||||
"GRADE_READ_CHILD",
|
||||
"GRADE_RECORD_MANAGE",
|
||||
"GRADE_RECORD_READ",
|
||||
|
||||
// ── 考勤(3)──────────────────────────────────────────
|
||||
"ATTENDANCE_READ",
|
||||
"ATTENDANCE_MANAGE",
|
||||
"ATTENDANCE_RECORD",
|
||||
|
||||
// ── 课表与排课(4)────────────────────────────────────
|
||||
"SCHEDULE_READ",
|
||||
"SCHEDULE_AUTO",
|
||||
"SCHEDULE_ADJUST",
|
||||
"SCHEDULE_MANAGE",
|
||||
|
||||
// ── 备课与教材(5)────────────────────────────────────
|
||||
"LESSON_PLAN_READ",
|
||||
"LESSON_PLAN_CREATE",
|
||||
"LESSON_PLAN_UPDATE",
|
||||
"LESSON_PLAN_DELETE",
|
||||
"TEXTBOOK_READ",
|
||||
|
||||
// ── 题库(4)──────────────────────────────────────────
|
||||
"QUESTION_READ",
|
||||
"QUESTION_CREATE",
|
||||
"QUESTION_UPDATE",
|
||||
"QUESTION_DELETE",
|
||||
|
||||
// ── 学情诊断(2)──────────────────────────────────────
|
||||
"DIAGNOSTIC_READ",
|
||||
"DIAGNOSTIC_MANAGE",
|
||||
|
||||
// ── 选修课(3)────────────────────────────────────────
|
||||
"ELECTIVE_READ",
|
||||
"ELECTIVE_MANAGE",
|
||||
"ELECTIVE_SELECT",
|
||||
|
||||
// ── 错题本与学习路径(2)──────────────────────────────
|
||||
"ERROR_BOOK_READ",
|
||||
"LEARNING_PATH_READ",
|
||||
|
||||
// ── AI 辅导(2)───────────────────────────────────────
|
||||
"AI_CHAT",
|
||||
"AI_TUTOR_USE",
|
||||
|
||||
// ── 公告与消息(4)────────────────────────────────────
|
||||
"ANNOUNCEMENT_READ",
|
||||
"ANNOUNCEMENT_MANAGE",
|
||||
"MESSAGE_READ",
|
||||
"MESSAGE_SEND",
|
||||
|
||||
// ── 请假(2)──────────────────────────────────────────
|
||||
"LEAVE_REQUEST_CREATE",
|
||||
"LEAVE_APPROVAL_MANAGE",
|
||||
|
||||
// ── 插件与布局(4)────────────────────────────────────
|
||||
"PLUGIN_REGISTRY_READ",
|
||||
"PLUGIN_REGISTRY_MANAGE",
|
||||
"LAYOUT_TEMPLATE_MANAGE",
|
||||
"ROLE_LAYOUT_MANAGE",
|
||||
] as const;
|
||||
|
||||
/** 权限点类型(从 ORDER 数组推导) */
|
||||
export type Permission = (typeof PERMISSION_BITMAP_ORDER)[number];
|
||||
|
||||
/** 权限点 → bit 位映射表(启动时构建一次) */
|
||||
const PERMISSION_BIT_INDEX: ReadonlyMap<string, bigint> = new Map(
|
||||
PERMISSION_BITMAP_ORDER.map((perm, idx) => [perm, 1n << BigInt(idx)]),
|
||||
);
|
||||
|
||||
/**
|
||||
* 将权限点数组编码为 base36 字符串
|
||||
*
|
||||
* @example
|
||||
* encodePermissionsBitmap(["USER_MANAGE", "ROLE_READ"])
|
||||
* // => "j" (前 14 个权限点中 USER_MANAGE=bit5, ROLE_READ=bit9 → 0b10100100000 → base36="j")
|
||||
*/
|
||||
export function encodePermissionsBitmap(
|
||||
permissions: readonly string[],
|
||||
): string {
|
||||
let bits = 0n;
|
||||
for (const perm of permissions) {
|
||||
const bit = PERMISSION_BIT_INDEX.get(perm);
|
||||
if (bit !== undefined) {
|
||||
bits |= bit;
|
||||
}
|
||||
// 未知权限静默忽略
|
||||
}
|
||||
return bits.toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 base36 字符串解码为权限点数组
|
||||
*
|
||||
* 容错:无效字符返回空数组,未知 bit 位静默忽略
|
||||
*/
|
||||
export function decodePermissionsBitmap(bitmap: string): Permission[] {
|
||||
if (!bitmap || !/^[0-9a-z]+$/.test(bitmap)) {
|
||||
return [];
|
||||
}
|
||||
const bits = parseBase36BigInt(bitmap);
|
||||
if (bits === null) {
|
||||
return [];
|
||||
}
|
||||
const result: Permission[] = [];
|
||||
for (let i = 0; i < PERMISSION_BITMAP_ORDER.length; i++) {
|
||||
const bit = 1n << BigInt(i);
|
||||
if ((bits & bit) !== 0n) {
|
||||
result.push(PERMISSION_BITMAP_ORDER[i]!);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单点权限检查(不解码整个位图,性能优)
|
||||
*
|
||||
* 适用场景:Edge Runtime / middleware / proxy 等高频检查
|
||||
*
|
||||
* @example
|
||||
* hasPermissionInBitmap("j", "USER_MANAGE") // true
|
||||
* hasPermissionInBitmap("j", "EXAM_CREATE") // false
|
||||
*/
|
||||
export function hasPermissionInBitmap(
|
||||
bitmap: string,
|
||||
permission: string,
|
||||
): boolean {
|
||||
const bit = PERMISSION_BIT_INDEX.get(permission);
|
||||
if (bit === undefined) {
|
||||
return false; // 未知权限点
|
||||
}
|
||||
const bits = parseBase36BigInt(bitmap);
|
||||
if (bits === null) {
|
||||
return false;
|
||||
}
|
||||
return (bits & bit) !== 0n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量权限检查(任一满足即 true)
|
||||
*/
|
||||
export function hasAnyPermissionInBitmap(
|
||||
bitmap: string,
|
||||
permissions: readonly string[],
|
||||
): boolean {
|
||||
return permissions.some((p) => hasPermissionInBitmap(bitmap, p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量权限检查(全部满足才 true)
|
||||
*/
|
||||
export function hasAllPermissionsInBitmap(
|
||||
bitmap: string,
|
||||
permissions: readonly string[],
|
||||
): boolean {
|
||||
return permissions.every((p) => hasPermissionInBitmap(bitmap, p));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 base36 字符串为 BigInt
|
||||
*
|
||||
* base36 字符集:0-9, a-z(小写)
|
||||
* 实现原理:从高位到低位逐字符累加
|
||||
*/
|
||||
function parseBase36BigInt(str: string): bigint | null {
|
||||
if (!str) return 0n;
|
||||
let result = 0n;
|
||||
const base = 36n;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str[i]!;
|
||||
let digit: number;
|
||||
if (ch >= "0" && ch <= "9") {
|
||||
digit = ch.charCodeAt(0) - 48; // '0' = 48
|
||||
} else if (ch >= "a" && ch <= "z") {
|
||||
digit = ch.charCodeAt(0) - 87; // 'a' = 97, 97-10=87
|
||||
} else if (ch >= "A" && ch <= "Z") {
|
||||
digit = ch.charCodeAt(0) - 55; // 'A' = 65, 65-10=55
|
||||
} else {
|
||||
return null; // 非法字符
|
||||
}
|
||||
result = result * base + BigInt(digit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验权限点是否在位图顺序表中
|
||||
*
|
||||
* 用于开发时校验 manifest 声明的权限点是否合法
|
||||
*/
|
||||
export function isValidPermission(permission: string): boolean {
|
||||
return PERMISSION_BIT_INDEX.has(permission);
|
||||
}
|
||||
@@ -10,15 +10,17 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edu/ui-tokens": "workspace:*"
|
||||
"@edu/ui-tokens": "workspace:*",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,14 +123,7 @@ export function Calendar({
|
||||
const today = formatDate(new Date());
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full"
|
||||
style={{
|
||||
background: "var(--bg-surface)",
|
||||
borderRadius: "var(--radius-card)",
|
||||
padding: "var(--space-md)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full rounded-xl border bg-card p-4">
|
||||
{/* 月份导航 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<button
|
||||
@@ -197,14 +190,14 @@ export function Calendar({
|
||||
type="button"
|
||||
key={`day-${dateStr}`}
|
||||
onClick={() => onDateClick?.(cell.date!)}
|
||||
className="min-h-[60px] p-1 text-left transition-colors hover:bg-[var(--bg-subtle)]"
|
||||
className="min-h-[60px] p-1 text-left transition-colors hover:bg-muted"
|
||||
style={{
|
||||
background: isToday
|
||||
? "var(--color-accent-subtle)"
|
||||
? "hsl(var(--primary) / 0.1)"
|
||||
: "transparent",
|
||||
borderRadius: "var(--radius-default)",
|
||||
borderRadius: "var(--radius)",
|
||||
border: isToday
|
||||
? "1px solid var(--color-accent)"
|
||||
? "1px solid hsl(var(--primary))"
|
||||
: "1px solid transparent",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
@@ -213,8 +206,8 @@ export function Calendar({
|
||||
className="text-xs mb-1"
|
||||
style={{
|
||||
color: isToday
|
||||
? "var(--color-accent)"
|
||||
: "var(--color-ink-muted)",
|
||||
? "hsl(var(--primary))"
|
||||
: "hsl(var(--muted-foreground))",
|
||||
fontWeight: isToday ? "600" : "400",
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -386,7 +386,7 @@ function PieChart({
|
||||
key={`slice-${i}`}
|
||||
d={s.d}
|
||||
fill={s.color}
|
||||
stroke="var(--bg-paper)"
|
||||
stroke="hsl(var(--background))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { Loading } from "./loading.js";
|
||||
import { Empty } from "./empty.js";
|
||||
import { cn } from "./utils/cn";
|
||||
import { Loading } from "./loading";
|
||||
import { Empty } from "./empty";
|
||||
|
||||
/**
|
||||
* DataTable - 通用数据表格
|
||||
@@ -77,21 +77,16 @@ export function DataTable<T>({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto border border-rule rounded-card",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("overflow-x-auto rounded-xl border", className)}>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-subtle">
|
||||
<tr className="border-b border-rule">
|
||||
<thead className="bg-muted">
|
||||
<tr className="border-b">
|
||||
{columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={cn(
|
||||
"py-2 px-3 text-tiny uppercase tracking-wide text-ink-muted font-medium",
|
||||
"py-2 px-3 text-xs uppercase tracking-wide text-muted-foreground font-medium",
|
||||
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
||||
)}
|
||||
>
|
||||
@@ -108,15 +103,15 @@ export function DataTable<T>({
|
||||
key={key}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={cn(
|
||||
"border-b border-rule",
|
||||
onRowClick && "cursor-pointer hover:bg-subtle",
|
||||
"border-b",
|
||||
onRowClick && "cursor-pointer hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"py-2 px-3 text-ink",
|
||||
"py-2 px-3 text-foreground",
|
||||
col.align ? ALIGN_CLASS[col.align] : "text-left",
|
||||
col.className,
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* Empty - 空态展示(插画占位 + 文案 + CTA)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* FilterBar - 通用筛选栏
|
||||
@@ -42,7 +42,7 @@ export function FilterBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="text-tiny uppercase tracking-wide text-ink-muted hover:opacity-70"
|
||||
className="text-xs uppercase tracking-wide text-muted-foreground hover:opacity-70"
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
@@ -51,7 +51,7 @@ export function FilterBar({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
className="px-4 py-1.5 text-sm text-ink-on-accent bg-accent rounded-button hover:bg-accent-hover"
|
||||
className="rounded-md bg-primary px-4 py-1.5 text-sm text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
应用
|
||||
</button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, type FormEvent, type ChangeEvent } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* Form - 轻量级表单(不依赖 react-hook-form)
|
||||
@@ -68,13 +68,13 @@ export function FormField({
|
||||
<div className={className}>
|
||||
<label
|
||||
htmlFor={name}
|
||||
className="mb-1 block text-tiny uppercase tracking-wide text-ink-muted"
|
||||
className="mb-1 block text-xs uppercase tracking-wide text-muted-foreground"
|
||||
>
|
||||
{label}
|
||||
{required && <span className="text-danger"> *</span>}
|
||||
{required && <span className="text-destructive"> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
{error && <p className="mt-1 text-tiny text-danger">{error}</p>}
|
||||
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export function FormInput({
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
||||
"w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground focus:outline-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
@@ -157,7 +157,7 @@ export function FormSelect({
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
||||
"w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground focus:outline-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -205,7 +205,7 @@ export function FormTextarea({
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"w-full rounded-button border border-rule bg-paper px-3 py-2 text-sm text-ink focus:outline-none disabled:opacity-50",
|
||||
"w-full rounded-md border bg-background px-3 py-2 text-sm text-foreground focus:outline-none disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
@@ -234,7 +234,7 @@ export function FormCheckbox({
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm text-ink",
|
||||
"flex items-center gap-2 text-sm text-foreground",
|
||||
disabled && "opacity-50",
|
||||
className,
|
||||
)}
|
||||
@@ -249,7 +249,7 @@ export function FormCheckbox({
|
||||
? (e: ChangeEvent<HTMLInputElement>) => onChange(e.target.checked)
|
||||
: undefined
|
||||
}
|
||||
className="rounded border-rule"
|
||||
className="rounded border"
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
@@ -276,7 +276,7 @@ export function SubmitButton({
|
||||
type="submit"
|
||||
disabled={disabled || loading}
|
||||
className={cn(
|
||||
"rounded-button bg-accent px-4 py-2 text-sm text-ink-on-accent hover:bg-accent-hover disabled:opacity-50",
|
||||
"rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -26,32 +26,29 @@
|
||||
* - RichTextEditor 升级为 Tiptap 封装
|
||||
*/
|
||||
|
||||
export { ErrorBoundary } from "./error-boundary.js";
|
||||
export type {
|
||||
ErrorBoundaryProps,
|
||||
ErrorBoundaryState,
|
||||
} from "./error-boundary.js";
|
||||
export { ErrorBoundary } from "./error-boundary";
|
||||
export type { ErrorBoundaryProps, ErrorBoundaryState } from "./error-boundary";
|
||||
|
||||
export { Loading } from "./loading.js";
|
||||
export type { LoadingProps } from "./loading.js";
|
||||
export { Loading } from "./loading";
|
||||
export type { LoadingProps } from "./loading";
|
||||
|
||||
export { Empty } from "./empty.js";
|
||||
export type { EmptyProps } from "./empty.js";
|
||||
export { Empty } from "./empty";
|
||||
export type { EmptyProps } from "./empty";
|
||||
|
||||
export { RequirePermission } from "./require-permission.js";
|
||||
export type { RequirePermissionProps } from "./require-permission.js";
|
||||
export { RequirePermission } from "./require-permission";
|
||||
export type { RequirePermissionProps } from "./require-permission";
|
||||
|
||||
export { DataTable } from "./data-table.js";
|
||||
export type { Column, ColumnAlign, DataTableProps } from "./data-table.js";
|
||||
export { DataTable } from "./data-table";
|
||||
export type { Column, ColumnAlign, DataTableProps } from "./data-table";
|
||||
|
||||
export { FilterBar } from "./filter-bar.js";
|
||||
export type { FilterBarProps } from "./filter-bar.js";
|
||||
export { FilterBar } from "./filter-bar";
|
||||
export type { FilterBarProps } from "./filter-bar";
|
||||
|
||||
export { StatusBadge } from "./status-badge.js";
|
||||
export type { StatusBadgeProps, StatusVariant } from "./status-badge.js";
|
||||
export { StatusBadge } from "./status-badge";
|
||||
export type { StatusBadgeProps, StatusVariant } from "./status-badge";
|
||||
|
||||
export { Modal } from "./modal.js";
|
||||
export type { ModalProps, ModalSize } from "./modal.js";
|
||||
export { Modal } from "./modal";
|
||||
export type { ModalProps, ModalSize } from "./modal";
|
||||
|
||||
export {
|
||||
Form,
|
||||
@@ -61,7 +58,7 @@ export {
|
||||
FormTextarea,
|
||||
FormCheckbox,
|
||||
SubmitButton,
|
||||
} from "./form.js";
|
||||
} from "./form";
|
||||
export type {
|
||||
FormProps,
|
||||
FormFieldProps,
|
||||
@@ -71,15 +68,34 @@ export type {
|
||||
FormCheckboxProps,
|
||||
SubmitButtonProps,
|
||||
SelectOption,
|
||||
} from "./form.js";
|
||||
} from "./form";
|
||||
|
||||
export { cn } from "./utils/cn.js";
|
||||
export { cn } from "./utils/cn";
|
||||
|
||||
export { Chart } from "./chart.js";
|
||||
export type { ChartProps, ChartType, ChartDataPoint } from "./chart.js";
|
||||
export { Chart } from "./chart";
|
||||
export type { ChartProps, ChartType, ChartDataPoint } from "./chart";
|
||||
|
||||
export { Calendar } from "./calendar.js";
|
||||
export type { CalendarProps, CalendarEvent } from "./calendar.js";
|
||||
export { Calendar } from "./calendar";
|
||||
export type { CalendarProps, CalendarEvent } from "./calendar";
|
||||
|
||||
export { RichTextEditor } from "./rich-text-editor.js";
|
||||
export type { RichTextEditorProps } from "./rich-text-editor.js";
|
||||
export { RichTextEditor } from "./rich-text-editor";
|
||||
export type { RichTextEditorProps } from "./rich-text-editor";
|
||||
|
||||
// portal-shell 插件系统组件(v2.1 spec §7.3)
|
||||
export { PluginCard } from "./plugin-card";
|
||||
export type { PluginCardProps } from "./plugin-card";
|
||||
|
||||
export { PluginSkeleton } from "./plugin-skeleton";
|
||||
export type { PluginSkeletonProps, SkeletonVariant } from "./plugin-skeleton";
|
||||
|
||||
export { PluginErrorFallback } from "./plugin-error-fallback";
|
||||
export type { PluginErrorFallbackProps } from "./plugin-error-fallback";
|
||||
|
||||
export { SlotPlaceholder } from "./slot-placeholder";
|
||||
export type { SlotPlaceholderProps } from "./slot-placeholder";
|
||||
|
||||
export { PropsConfigForm } from "./props-config-form";
|
||||
export type {
|
||||
PropsConfigFormProps,
|
||||
PropsJsonSchema,
|
||||
} from "./props-config-form";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* Loading - 骨架屏 / 加载占位
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* Modal - 模态对话框
|
||||
@@ -72,23 +72,23 @@ export function Modal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={onClose}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/50 backdrop-blur-sm"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-foreground/50 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
"max-h-[90vh] w-full overflow-y-auto rounded-card border border-rule bg-paper p-6 shadow-xl",
|
||||
"max-h-[90vh] w-full overflow-y-auto rounded-xl border bg-background p-6 shadow-xl",
|
||||
SIZE_CLASS[size],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title && (
|
||||
<>
|
||||
<h2 className="text-lg font-serif text-ink">{title}</h2>
|
||||
<div className="rule-thin mb-4 mt-2" />
|
||||
<h2 className="text-lg font-serif text-foreground">{title}</h2>
|
||||
<div className="mb-4 mt-2 border-t" />
|
||||
</>
|
||||
)}
|
||||
<div className="text-ink">{children}</div>
|
||||
<div className="text-foreground">{children}</div>
|
||||
{footer && (
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
{footer}
|
||||
|
||||
75
packages/ui-components/src/plugin-card.tsx
Normal file
75
packages/ui-components/src/plugin-card.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* PluginCard - 插件卡片容器(portal-shell spec §7.3)
|
||||
*
|
||||
* 所有插件内容应使用 PluginCard 包裹,强制设计令牌一致性:
|
||||
* - 卡片背景 bg-card(v2.0 之前 paper/surface 双变体,v2.0 统一为 bg-card)
|
||||
* - 圆角 rounded-xl
|
||||
* - 内边距 p-4(可通过 className 覆盖)
|
||||
* - 边框 border(默认 border 颜色)
|
||||
*
|
||||
* v2.0 令牌迁移:shadcn 标准令牌
|
||||
* - rounded-card → rounded-xl
|
||||
* - border-rule → border
|
||||
* - bg-paper/bg-surface → bg-card(统一)
|
||||
* - p-md → p-4
|
||||
* - mb-md → mb-4
|
||||
* - text-heading-3 → text-lg
|
||||
* - text-ink → text-foreground
|
||||
* - gap-sm → gap-2
|
||||
* - variant 属性保留但仅作语义标识,样式统一为 bg-card
|
||||
*
|
||||
* @example
|
||||
* <PluginCard title="成绩">
|
||||
* <GradesTable />
|
||||
* </PluginCard>
|
||||
*/
|
||||
export interface PluginCardProps {
|
||||
/** 卡片标题(显示在顶部) */
|
||||
title?: string;
|
||||
/** 标题右侧的操作区(如刷新按钮、筛选按钮) */
|
||||
actions?: ReactNode;
|
||||
/** 卡片内容 */
|
||||
children: ReactNode;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** 内容区类名 */
|
||||
contentClassName?: string;
|
||||
/**
|
||||
* 卡片变体(v2.0 仅作语义标识,样式统一为 bg-card)
|
||||
* - surface:用于 side/top 区(保持兼容)
|
||||
* - paper:用于 main 区(保持兼容)
|
||||
*/
|
||||
variant?: "surface" | "paper";
|
||||
}
|
||||
|
||||
export function PluginCard({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
variant = "surface",
|
||||
}: PluginCardProps): ReactNode {
|
||||
// v2.0:统一为 bg-card,variant 仅作语义标识(向后兼容)
|
||||
void variant;
|
||||
return (
|
||||
<section className={cn("rounded-xl border bg-card p-4", className)}>
|
||||
{title || actions ? (
|
||||
<header className="mb-4 flex items-center justify-between">
|
||||
{title ? (
|
||||
<h3 className="text-lg text-foreground">{title}</h3>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{actions ? (
|
||||
<div className="flex items-center gap-2">{actions}</div>
|
||||
) : null}
|
||||
</header>
|
||||
) : null}
|
||||
<div className={cn(contentClassName)}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
62
packages/ui-components/src/plugin-error-fallback.tsx
Normal file
62
packages/ui-components/src/plugin-error-fallback.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* PluginErrorFallback - 插件错误兜底组件(portal-shell spec §5.3、§7.3)
|
||||
*
|
||||
* 插件加载失败或渲染异常时显示此组件,居中显示错误信息 + 重试按钮。
|
||||
* ErrorBoundary 隔离单个插件错误,不影响其他插件。
|
||||
*
|
||||
* v2.0 令牌迁移:shadcn 标准令牌
|
||||
* - rounded-card → rounded-xl
|
||||
* - border-rule → border(默认 border 颜色)
|
||||
* - bg-surface → bg-card
|
||||
* - text-ink-muted → text-muted-foreground
|
||||
* - bg-accent → bg-primary
|
||||
* - text-ink-onAccent → text-primary-foreground
|
||||
* - rounded-button → rounded-md
|
||||
* - p-md → p-4, px-md → px-4, py-xs → py-1
|
||||
* - text-small → text-sm
|
||||
* - mt-sm → mt-2
|
||||
*
|
||||
* @example
|
||||
* <PluginErrorFallback instanceId="grades-widget-main-1" onRetry={() => refetch()} />
|
||||
*/
|
||||
export interface PluginErrorFallbackProps {
|
||||
/** 插件实例 ID */
|
||||
instanceId: string;
|
||||
/** 错误信息(可选,默认显示通用提示) */
|
||||
message?: string;
|
||||
/** 重试回调(不提供则不显示重试按钮) */
|
||||
onRetry?: () => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PluginErrorFallback({
|
||||
instanceId,
|
||||
message,
|
||||
onRetry,
|
||||
className,
|
||||
}: PluginErrorFallbackProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className={cn(
|
||||
"rounded-xl border bg-card p-4 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<p className="text-sm">{message ?? `插件加载失败(${instanceId})`}</p>
|
||||
{onRetry ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-2 rounded-md bg-primary px-4 py-1 text-sm text-primary-foreground"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
packages/ui-components/src/plugin-skeleton.tsx
Normal file
138
packages/ui-components/src/plugin-skeleton.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* PluginSkeleton - 插件骨架屏(portal-shell spec §7.3)
|
||||
*
|
||||
* 5 种 skeleton 变体,对应不同插件类型:
|
||||
* - card:通用卡片骨架(标题 + 内容块)
|
||||
* - list:列表骨架(多行)
|
||||
* - chart:图表骨架(坐标轴 + 柱状)
|
||||
* - stats:统计数据骨架(大数字 + 标签)
|
||||
* - table:表格骨架(表头 + 多行)
|
||||
*
|
||||
* v2.0 令牌迁移:shadcn 标准令牌
|
||||
* - rounded-card → rounded-xl
|
||||
* - bg-surface → bg-card
|
||||
* - bg-subtle → bg-muted
|
||||
* - p-md → p-4
|
||||
* - mb-md → mb-4
|
||||
* - h-heading-3 → h-6
|
||||
* - h-body → h-4
|
||||
* - h-large-number → h-8
|
||||
* - h-tiny → h-3
|
||||
* - rounded-button → rounded-md
|
||||
* - rounded-t-button → rounded-t-md
|
||||
* - gap-sm → gap-2
|
||||
* - gap-md → gap-4
|
||||
* - space-y-sm → space-y-2
|
||||
* - mt-xs → mt-1
|
||||
*
|
||||
* @example
|
||||
* <PluginSkeleton variant="table" />
|
||||
*/
|
||||
export type SkeletonVariant = "card" | "list" | "chart" | "stats" | "table";
|
||||
|
||||
export interface PluginSkeletonProps {
|
||||
/** 骨架变体 */
|
||||
variant?: SkeletonVariant;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
/** aria-label(无障碍) */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function PluginSkeleton({
|
||||
variant = "card",
|
||||
className,
|
||||
ariaLabel = "加载中",
|
||||
}: PluginSkeletonProps): ReactNode {
|
||||
const baseClass = "rounded-xl bg-card p-4 animate-pulse";
|
||||
|
||||
if (variant === "table") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(baseClass, className)}
|
||||
>
|
||||
<div className="mb-4 h-6 w-1/4 rounded-md bg-muted" />
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-4 w-full rounded-md bg-muted" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
className={cn("space-y-2", className)}
|
||||
>
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-4 w-full animate-pulse rounded-md bg-muted"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "chart") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(baseClass, className)}
|
||||
>
|
||||
<div className="mb-4 h-6 w-1/3 rounded-md bg-muted" />
|
||||
<div className="flex h-32 items-end gap-2">
|
||||
{[60, 80, 45, 90, 70, 55, 85].map((h, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t-md bg-muted"
|
||||
style={{ height: `${h}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === "stats") {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(baseClass, className)}
|
||||
>
|
||||
<div className="mb-4 h-6 w-1/3 rounded-md bg-muted" />
|
||||
<div className="flex gap-4">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="flex-1">
|
||||
<div className="h-8 w-1/2 rounded-md bg-muted" />
|
||||
<div className="mt-1 h-3 w-1/3 rounded-md bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// card(默认)
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={ariaLabel}
|
||||
className={cn(baseClass, className)}
|
||||
>
|
||||
<div className="mb-4 h-6 w-1/3 rounded-md bg-muted" />
|
||||
<div className="h-8 w-1/2 rounded-md bg-muted" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
packages/ui-components/src/props-config-form.tsx
Normal file
182
packages/ui-components/src/props-config-form.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* PropsConfigForm - 基于 JSON Schema 的插件配置表单(portal-shell spec §7.3)
|
||||
*
|
||||
* 根据插件的 propsSchema(JSON Schema)自动渲染配置表单,
|
||||
* admin 通过此表单配置插件的默认 props。
|
||||
*
|
||||
* 支持的字段类型:
|
||||
* - string:文本输入
|
||||
* - number:数字输入
|
||||
* - integer:整数输入
|
||||
* - boolean:复选框
|
||||
* - enum:下拉选择
|
||||
* - object:嵌套对象(递归渲染)
|
||||
*
|
||||
* v2.0 令牌迁移:shadcn 标准令牌
|
||||
*
|
||||
* @example
|
||||
* <PropsConfigForm
|
||||
* schema={{ type: "object", properties: { limit: { type: "number", default: 20 } } }}
|
||||
* value={{ limit: 20 }}
|
||||
* onChange={(v) => console.log(v)}
|
||||
* />
|
||||
*/
|
||||
|
||||
/** JSON Schema 类型定义(与 portal-shell spec §5.1 对齐) */
|
||||
export interface PropsJsonSchema {
|
||||
type?: string;
|
||||
properties?: Record<string, PropsJsonSchema>;
|
||||
items?: PropsJsonSchema;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
enum?: unknown[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PropsConfigFormProps {
|
||||
/** JSON Schema */
|
||||
schema: PropsJsonSchema;
|
||||
/** 当前值 */
|
||||
value: Record<string, unknown>;
|
||||
/** 值变更回调 */
|
||||
onChange: (value: Record<string, unknown>) => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PropsConfigForm({
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
}: PropsConfigFormProps): ReactNode {
|
||||
const properties = schema.properties;
|
||||
if (!properties) {
|
||||
return <p className="text-sm text-muted-foreground">此插件无可配置项</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-4", className)}>
|
||||
{Object.entries(properties).map(([key, fieldSchema]) => (
|
||||
<FieldRenderer
|
||||
key={key}
|
||||
name={key}
|
||||
schema={fieldSchema}
|
||||
value={value[key]}
|
||||
onChange={(fieldValue) => {
|
||||
onChange({ ...value, [key]: fieldValue });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FieldRendererProps {
|
||||
name: string;
|
||||
schema: PropsJsonSchema;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
}
|
||||
|
||||
function FieldRenderer({
|
||||
name,
|
||||
schema,
|
||||
value,
|
||||
onChange,
|
||||
}: FieldRendererProps): ReactNode {
|
||||
const fieldType = schema.type ?? "string";
|
||||
const label = schema.description ?? name;
|
||||
|
||||
// enum 下拉
|
||||
if (schema.enum && schema.enum.length > 0) {
|
||||
return (
|
||||
<label className="flex flex-col space-y-1">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<select
|
||||
value={String(value ?? schema.default ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
|
||||
>
|
||||
{schema.enum.map((opt) => (
|
||||
<option key={String(opt)} value={String(opt)}>
|
||||
{String(opt)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// boolean 复选框
|
||||
if (fieldType === "boolean") {
|
||||
return (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(value ?? schema.default ?? false)}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="rounded-md border"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{label}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// number / integer 数字输入
|
||||
if (fieldType === "number" || fieldType === "integer") {
|
||||
return (
|
||||
<label className="flex flex-col space-y-1">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<input
|
||||
type="number"
|
||||
value={Number(value ?? schema.default ?? 0)}
|
||||
onChange={(e) => {
|
||||
const num = Number(e.target.value);
|
||||
onChange(fieldType === "integer" ? Math.floor(num) : num);
|
||||
}}
|
||||
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// object 嵌套递归
|
||||
if (fieldType === "object" && schema.properties) {
|
||||
const objValue = (value as Record<string, unknown>) ?? {};
|
||||
return (
|
||||
<fieldset className="rounded-xl border p-2">
|
||||
<legend className="px-2 text-sm text-foreground">{label}</legend>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(schema.properties).map(([childKey, childSchema]) => (
|
||||
<FieldRenderer
|
||||
key={childKey}
|
||||
name={childKey}
|
||||
schema={childSchema}
|
||||
value={objValue[childKey]}
|
||||
onChange={(childValue) => {
|
||||
onChange({ ...objValue, [childKey]: childValue });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
// string 默认文本输入
|
||||
return (
|
||||
<label className="flex flex-col space-y-1">
|
||||
<span className="text-sm text-muted-foreground">{label}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={String(value ?? schema.default ?? "")}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="rounded-md border bg-card px-2 py-1 text-sm text-foreground"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -78,11 +78,10 @@ export function RichTextEditor({
|
||||
|
||||
const toolbarButtons = readOnly ? null : (
|
||||
<div
|
||||
className="flex items-center gap-1 p-2 border-b flex-wrap"
|
||||
className="flex items-center gap-1 p-2 border-b flex-wrap bg-muted"
|
||||
style={{
|
||||
borderColor: "var(--color-rule)",
|
||||
background: "var(--bg-subtle)",
|
||||
borderRadius: "var(--radius-default) var(--radius-default) 0 0",
|
||||
borderColor: "hsl(var(--border))",
|
||||
borderRadius: "var(--radius) var(--radius) 0 0",
|
||||
}}
|
||||
>
|
||||
<ToolbarButton label="加粗" onClick={() => exec("bold")} icon="B" bold />
|
||||
@@ -131,14 +130,7 @@ export function RichTextEditor({
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full overflow-hidden"
|
||||
style={{
|
||||
border: "1px solid var(--color-rule)",
|
||||
borderRadius: "var(--radius-card)",
|
||||
background: "var(--bg-paper)",
|
||||
}}
|
||||
>
|
||||
<div className="w-full overflow-hidden rounded-xl border bg-background">
|
||||
{toolbarButtons}
|
||||
<div
|
||||
ref={editorRef}
|
||||
|
||||
58
packages/ui-components/src/slot-placeholder.tsx
Normal file
58
packages/ui-components/src/slot-placeholder.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* SlotPlaceholder - 空 slot 占位组件(portal-shell spec §7.3)
|
||||
*
|
||||
* 当 slot 中没有可见插件时显示此占位。
|
||||
* admin 模式下显示"添加插件"按钮,普通模式下显示空态提示。
|
||||
*
|
||||
* v2.0 令牌迁移:shadcn 标准令牌
|
||||
*
|
||||
* @example
|
||||
* <SlotPlaceholder slotName="main" isAdmin={false} />
|
||||
* <SlotPlaceholder slotName="side" isAdmin={true} onAddPlugin={() => openDialog()} />
|
||||
*/
|
||||
export interface SlotPlaceholderProps {
|
||||
/** slot 名称 */
|
||||
slotName: string;
|
||||
/** 是否为 admin 模式(admin 模式显示添加按钮) */
|
||||
isAdmin?: boolean;
|
||||
/** 添加插件回调(admin 模式下点击触发) */
|
||||
onAddPlugin?: () => void;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SlotPlaceholder({
|
||||
slotName,
|
||||
isAdmin = false,
|
||||
onAddPlugin,
|
||||
className,
|
||||
}: SlotPlaceholderProps): ReactNode {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-xl border bg-card p-4 text-sm text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isAdmin ? (
|
||||
<div className="flex items-center justify-between">
|
||||
<span>slot「{slotName}」暂无插件</span>
|
||||
{onAddPlugin ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddPlugin}
|
||||
className="rounded-md bg-primary px-2 py-1 text-xs text-primary-foreground"
|
||||
>
|
||||
添加插件
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span>暂无可见插件</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "./utils/cn.js";
|
||||
import { cn } from "./utils/cn";
|
||||
|
||||
/**
|
||||
* StatusBadge - 状态徽章
|
||||
@@ -56,13 +56,15 @@ const STATUS_VARIANT_MAP: Record<string, StatusVariant> = {
|
||||
loading: "info",
|
||||
};
|
||||
|
||||
/** variant → Tailwind 类名映射 */
|
||||
/** variant → Tailwind 类名映射(v2.0 shadcn 标准令牌) */
|
||||
const VARIANT_CLASS: Record<StatusVariant, string> = {
|
||||
success: "border-success text-success bg-success/10",
|
||||
warning: "border-warning text-warning bg-warning/10",
|
||||
danger: "border-danger text-danger bg-danger/10",
|
||||
info: "border-info text-info bg-info/10",
|
||||
neutral: "border-rule text-ink-muted bg-subtle",
|
||||
success:
|
||||
"border-emerald-500 text-emerald-700 bg-emerald-500/10 dark:text-emerald-400",
|
||||
warning:
|
||||
"border-amber-500 text-amber-700 bg-amber-500/10 dark:text-amber-400",
|
||||
danger: "border-destructive text-destructive bg-destructive/10",
|
||||
info: "border-sky-500 text-sky-700 bg-sky-500/10 dark:text-sky-400",
|
||||
neutral: "border text-muted-foreground bg-muted",
|
||||
};
|
||||
|
||||
function inferVariant(status: string): StatusVariant {
|
||||
@@ -81,7 +83,7 @@ export function StatusBadge({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-button border px-2 py-0.5 text-tiny font-medium",
|
||||
"inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium",
|
||||
VARIANT_CLASS[resolvedVariant],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -2,82 +2,14 @@
|
||||
* 类名合并工具(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 值。
|
||||
* 基于 clsx + tailwind-merge 的标准实现(对齐 shadcn/ui 官方 cn())。
|
||||
*
|
||||
* @example
|
||||
* cn("px-2 py-1", isActive && "bg-primary", { "text-muted": isDisabled })
|
||||
*/
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
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?.[1] ?? null;
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
@@ -1,112 +1,116 @@
|
||||
/**
|
||||
* Layer 1: Primitive Tokens(原始色板/字号/间距/阴影)
|
||||
* Layer 1: Primitive Tokens(原始色板/字号/间距/阴影/字体家族)
|
||||
*
|
||||
* 业务代码不直接引用本层令牌,仅 Layer 2 Semantic 引用。
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 仅被 Layer 2 Semantic 引用,业务代码不直接使用。
|
||||
* 色板层不区分明暗,主题差异在 Semantic 层体现。
|
||||
*
|
||||
* 对齐:CICD 项目 src/app/styles/tokens/primitive.css
|
||||
* 关联:project_rules §3.10 设计令牌规范
|
||||
*
|
||||
* 命名规范:
|
||||
* - 颜色:--color-<hue>-<level>(HSL 分量,供 Layer 2 组合)
|
||||
* - 字号:--font-size-<n>(1-9 阶梯)
|
||||
* - 间距:--space-<n>(4px 基准阶梯)
|
||||
* - 阴影:--shadow-<n>
|
||||
* - 字重:--font-weight-<name>
|
||||
* - 圆角:--radius-<name>
|
||||
*/
|
||||
|
||||
:root {
|
||||
/* ============ 颜色原始色板(HSL 分量,非完整颜色) ============ */
|
||||
/* 中性色(纸张/墨色) */
|
||||
--color-paper-h: 40;
|
||||
--color-paper-s: 20%;
|
||||
--color-paper-l-50: 50%;
|
||||
--color-paper-l-98: 98%;
|
||||
--color-paper-l-99: 99%;
|
||||
/* ============ 色板(HSL 分量,非完整颜色) ============ */
|
||||
|
||||
--color-ink-h: 25;
|
||||
--color-ink-s: 3%;
|
||||
--color-ink-l-15: 15%;
|
||||
--color-ink-l-45: 45%;
|
||||
--color-ink-l-60: 60%;
|
||||
/* Zinc 中性色板(shadcn 默认) */
|
||||
--color-zinc-50: 0 0% 99%;
|
||||
--color-zinc-100: 240 4.8% 95.9%;
|
||||
--color-zinc-200: 240 5.9% 90%;
|
||||
--color-zinc-300: 240 4.8% 83.9%;
|
||||
--color-zinc-400: 240 5% 64.9%;
|
||||
--color-zinc-500: 240 3.8% 46.1%;
|
||||
--color-zinc-600: 240 5.2% 33.9%;
|
||||
--color-zinc-700: 240 5.3% 26.1%;
|
||||
--color-zinc-800: 240 5.9% 10%;
|
||||
--color-zinc-900: 240 5.9% 3.9%;
|
||||
--color-zinc-950: 240 10% 3.9%;
|
||||
|
||||
/* 强调色(深蓝) */
|
||||
--color-accent-h: 220;
|
||||
--color-accent-s: 60%;
|
||||
--color-accent-l-35: 35%;
|
||||
--color-accent-l-50: 50%;
|
||||
/* Stone 暖灰(用于纸感业务扩展,如备课编辑器) */
|
||||
--color-stone-50: 60 4.8% 95.9%;
|
||||
--color-stone-100: 60 5.1% 90%;
|
||||
--color-stone-200: 20 5.9% 90%;
|
||||
--color-stone-300: 24 5.7% 82.9%;
|
||||
--color-stone-400: 24 5.4% 63.9%;
|
||||
--color-stone-500: 25 5.1% 44.7%;
|
||||
--color-stone-600: 33 5% 39.8%;
|
||||
--color-stone-700: 30 5.2% 32.7%;
|
||||
--color-stone-800: 12 6.5% 31.4%;
|
||||
--color-stone-900: 24 10% 10%;
|
||||
--color-stone-950: 20 14.3% 4.1%;
|
||||
|
||||
/* 分隔线(暖灰) */
|
||||
--color-rule-h: 30;
|
||||
--color-rule-s: 10%;
|
||||
--color-rule-l-85: 85%;
|
||||
--color-rule-l-90: 90%;
|
||||
/* Indigo 强调色(业务交互强调) */
|
||||
--color-indigo-500: 238.7 83.5% 66.7%;
|
||||
--color-indigo-600: 238.6 84.5% 59.8%;
|
||||
|
||||
/* 语义原始色 */
|
||||
--color-success-h: 142;
|
||||
--color-success-s: 71%;
|
||||
--color-success-l-45: 45%;
|
||||
/* ============ 字号阶梯(10 级,0 最小 9 最大) ============ */
|
||||
--font-size-0: 9px; /* 角色标签微字号 */
|
||||
--font-size-1: 12px; /* 元信息/角色标签 */
|
||||
--font-size-2: 13px; /* inline-node body */
|
||||
--font-size-3: 13.5px; /* inline-node 主文(纸感) */
|
||||
--font-size-4: 14px; /* 标题/按钮 */
|
||||
--font-size-5: 16px; /* 正文(Fraunces 16px) */
|
||||
--font-size-6: 18px; /* H2 */
|
||||
--font-size-7: 20px; /* H1 */
|
||||
--font-size-8: 24px; /* 区块标题 */
|
||||
--font-size-9: 32px; /* 页面标题 */
|
||||
|
||||
--color-warning-h: 38;
|
||||
--color-warning-s: 92%;
|
||||
--color-warning-l-50: 50%;
|
||||
/* ============ 间距阶梯 ============ */
|
||||
--space-0: 0;
|
||||
--space-0_5: 0.125rem; /* 2px */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-1_5: 0.375rem; /* 6px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-2_5: 0.625rem; /* 10px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-3_5: 0.875rem; /* 14px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-7: 1.75rem; /* 28px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-18: 4.5rem; /* 72px */
|
||||
|
||||
--color-danger-h: 0;
|
||||
--color-danger-s: 84%;
|
||||
--color-danger-l-60: 60%;
|
||||
/* ============ 阴影阶梯 ============ */
|
||||
--shadow-1: 0 1px 2px rgba(15, 15, 15, 0.04);
|
||||
--shadow-2: 0 1px 3px rgba(15, 15, 15, 0.06), 0 1px 2px rgba(15, 15, 15, 0.04);
|
||||
--shadow-3: 0 4px 6px rgba(15, 15, 15, 0.05), 0 2px 4px rgba(15, 15, 15, 0.04);
|
||||
--shadow-4: 0 1px 2px rgba(15, 15, 15, 0.04), 0 8px 24px rgba(15, 15, 15, 0.04);
|
||||
--shadow-5: 0 1px 2px rgba(15, 15, 15, 0.06), 0 12px 36px rgba(15, 15, 15, 0.08);
|
||||
--shadow-6: 0 10px 15px rgba(15, 15, 15, 0.1), 0 4px 6px rgba(15, 15, 15, 0.05);
|
||||
|
||||
--color-info-h: 199;
|
||||
--color-info-s: 89%;
|
||||
--color-info-l-48: 48%;
|
||||
/* ============ 字体家族 ============ */
|
||||
/* 引用 next/font 在 <html> 上注入的 CSS 变量(fallback 保证 SSR/无字体时降级) */
|
||||
--font-family-sans: var(--font-inter, 'Inter'), system-ui, sans-serif;
|
||||
--font-family-serif: var(--font-fraunces, 'Fraunces'), Georgia, serif;
|
||||
--font-family-mono: var(--font-jetbrains-mono, 'JetBrains Mono'), ui-monospace, monospace;
|
||||
|
||||
/* ============ 字号阶梯(1-9,1 最小 9 最大) ============ */
|
||||
--font-size-1: 0.75rem; /* 12px - 辅助说明 */
|
||||
--font-size-2: 0.875rem; /* 14px - 次要正文 */
|
||||
--font-size-3: 1rem; /* 16px - 正文 body */
|
||||
--font-size-4: 1.125rem; /* 18px - 强调正文 */
|
||||
--font-size-5: 1.25rem; /* 20px - 小标题 */
|
||||
--font-size-6: 1.5rem; /* 24px - 区块标题 */
|
||||
--font-size-7: 1.875rem; /* 30px - 页面标题 */
|
||||
--font-size-8: 2.25rem; /* 36px - Hero */
|
||||
--font-size-9: 3rem; /* 48px - 大数字 */
|
||||
/* ============ 行高阶梯 ============ */
|
||||
--leading-tight: 1.2;
|
||||
--leading-snug: 1.35;
|
||||
--leading-normal: 1.5;
|
||||
--leading-relaxed: 1.65;
|
||||
--leading-loose: 1.8;
|
||||
|
||||
/* ============ 间距阶梯(4px 基准) ============ */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-5: 1.25rem; /* 20px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-10: 2.5rem; /* 40px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-20: 5rem; /* 80px */
|
||||
/* ============ 字重阶梯 ============ */
|
||||
--weight-regular: 400;
|
||||
--weight-medium: 500;
|
||||
--weight-semibold: 600;
|
||||
--weight-bold: 700;
|
||||
|
||||
/* ============ 阴影 ============ */
|
||||
--shadow-1: 0 1px 2px 0 hsl(25 3% 15% / 0.05);
|
||||
--shadow-2: 0 1px 3px 0 hsl(25 3% 15% / 0.1), 0 1px 2px -1px hsl(25 3% 15% / 0.1);
|
||||
--shadow-3: 0 4px 6px -1px hsl(25 3% 15% / 0.1), 0 2px 4px -2px hsl(25 3% 15% / 0.1);
|
||||
--shadow-4: 0 10px 15px -3px hsl(25 3% 15% / 0.1), 0 4px 6px -4px hsl(25 3% 15% / 0.1);
|
||||
/* ============ 动效 ============ */
|
||||
--duration-fast: 150ms;
|
||||
--duration-normal: 200ms;
|
||||
--duration-slow: 300ms;
|
||||
--ease-in: cubic-bezier(0.4, 0, 1, 1);
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
/* ============ 字重 ============ */
|
||||
--font-weight-regular: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* ============ 圆角 ============ */
|
||||
--radius-sm: 0.25rem; /* 4px */
|
||||
--radius-md: 0.375rem; /* 6px */
|
||||
--radius-lg: 0.5rem; /* 8px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ============ 行高 ============ */
|
||||
--line-height-tight: 1.25;
|
||||
--line-height-normal: 1.5;
|
||||
--line-height-relaxed: 1.75;
|
||||
|
||||
/* ============ 字间距 ============ */
|
||||
--letter-spacing-tight: -0.01em;
|
||||
--letter-spacing-normal: 0;
|
||||
--letter-spacing-wide: 0.025em;
|
||||
/* ============ z-index ============ */
|
||||
--z-dropdown: 1000;
|
||||
--z-sticky: 1100;
|
||||
--z-modal: 1300;
|
||||
--z-popover: 1400;
|
||||
--z-toast: 1500;
|
||||
}
|
||||
|
||||
@@ -1,66 +1,73 @@
|
||||
/**
|
||||
* Layer 2: Semantic Tokens - Dark Theme(暗色语义令牌)
|
||||
*
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:project_rules §3.10
|
||||
* 通过 .dark class 激活(由 next-themes 切换)。
|
||||
* 所有令牌都有 :root (light) 对应定义。
|
||||
*
|
||||
* 暗色主题覆盖,通过 [data-theme="dark"] 或 prefers-color-scheme: dark 激活。
|
||||
* 对齐:CICD 项目 src/app/styles/tokens/semantic-dark.css
|
||||
*/
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg-paper: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-15));
|
||||
--bg-surface: hsl(var(--color-ink-h) var(--color-ink-s) 20%);
|
||||
--bg-subtle: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
||||
|
||||
--color-ink: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-98));
|
||||
--color-ink-muted: hsl(var(--color-paper-h) var(--color-paper-s) 70%);
|
||||
--color-ink-subtle: hsl(var(--color-paper-h) var(--color-paper-s) 60%);
|
||||
--color-ink-on-accent: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
||||
|
||||
--color-accent: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-50));
|
||||
--color-accent-hover: hsl(var(--color-accent-h) var(--color-accent-s) 60%);
|
||||
--color-accent-subtle: hsl(var(--color-accent-h) var(--color-accent-s) 25%);
|
||||
|
||||
--color-rule: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
||||
--color-rule-strong: hsl(var(--color-ink-h) var(--color-ink-s) 30%);
|
||||
|
||||
--color-border: var(--color-rule);
|
||||
--color-input-bg: var(--bg-surface);
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.3);
|
||||
--shadow-md: 0 1px 3px 0 hsl(0 0% 0% / 0.4), 0 1px 2px -1px hsl(0 0% 0% / 0.4);
|
||||
--shadow-lg: 0 4px 6px -1px hsl(0 0% 0% / 0.4), 0 2px 4px -2px hsl(0 0% 0% / 0.4);
|
||||
--shadow-xl: 0 10px 15px -3px hsl(0 0% 0% / 0.5), 0 4px 6px -4px hsl(0 0% 0% / 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
|
||||
--bg-paper: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-15));
|
||||
--bg-surface: hsl(var(--color-ink-h) var(--color-ink-s) 20%);
|
||||
--bg-subtle: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
||||
/* ============ shadcn 标准令牌 ============ */
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
--color-ink: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-98));
|
||||
--color-ink-muted: hsl(var(--color-paper-h) var(--color-paper-s) 70%);
|
||||
--color-ink-subtle: hsl(var(--color-paper-h) var(--color-paper-s) 60%);
|
||||
--color-ink-on-accent: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
||||
/* ============ chart 令牌 ============ */
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
|
||||
--color-accent: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-50));
|
||||
--color-accent-hover: hsl(var(--color-accent-h) var(--color-accent-s) 60%);
|
||||
--color-accent-subtle: hsl(var(--color-accent-h) var(--color-accent-s) 25%);
|
||||
/* ============ sidebar 令牌 ============ */
|
||||
--sidebar-background: 240 5.9% 10%;
|
||||
--sidebar-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-primary: 224.3 76.3% 48%;
|
||||
--sidebar-primary-foreground: 0 0% 100%;
|
||||
--sidebar-accent: 240 3.7% 15.9%;
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
--color-rule: hsl(var(--color-ink-h) var(--color-ink-s) 25%);
|
||||
--color-rule-strong: hsl(var(--color-ink-h) var(--color-ink-s) 30%);
|
||||
/* ============ 语义层扩展 ============ */
|
||||
--background-elevated: 240 6% 10%;
|
||||
--background-sunken: 240 6% 8%;
|
||||
--text-primary: 0 0% 98%; /* = --foreground */
|
||||
--text-secondary: 240 5% 64.9%; /* = --muted-foreground */
|
||||
--text-tertiary: 240 5% 50%;
|
||||
--border-strong: 240 5% 40%;
|
||||
--border-subtle: 240 5% 18%;
|
||||
|
||||
--color-border: var(--color-rule);
|
||||
--color-input-bg: var(--bg-surface);
|
||||
/* ============ 业务语义令牌 ============ */
|
||||
--diff-add: 142 71% 55%;
|
||||
--diff-add-bg: 142 71% 55% / 0.15;
|
||||
--diff-remove: 0 84% 70%;
|
||||
--diff-remove-bg: 0 84% 70% / 0.15;
|
||||
|
||||
--shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.3);
|
||||
--shadow-md: 0 1px 3px 0 hsl(0 0% 0% / 0.4), 0 1px 2px -1px hsl(0 0% 0% / 0.4);
|
||||
--shadow-lg: 0 4px 6px -1px hsl(0 0% 0% / 0.4), 0 2px 4px -2px hsl(0 0% 0% / 0.4);
|
||||
--shadow-xl: 0 10px 15px -3px hsl(0 0% 0% / 0.5), 0 4px 6px -4px hsl(0 0% 0% / 0.4);
|
||||
--graph-node-1: 220 70% 50%;
|
||||
--graph-node-2: 160 60% 45%;
|
||||
--graph-node-3: 30 80% 55%;
|
||||
--graph-node-4: 280 65% 60%;
|
||||
--graph-node-5: 340 75% 55%;
|
||||
--graph-node-6: 200 80% 60%;
|
||||
}
|
||||
|
||||
@@ -1,82 +1,76 @@
|
||||
/**
|
||||
* Layer 2: Semantic Tokens - Light Theme(亮色语义令牌)
|
||||
*
|
||||
* 业务代码唯一引用入口:业务 TSX/CSS 引用 var(--color-*) / var(--font-*) 等。
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:project_rules §3.10、03-long-term-architecture.md §1.5
|
||||
* 业务代码唯一引用入口:业务 TSX/CSS 引用 hsl(var(--*)) 或 Tailwind bg-* 类。
|
||||
* 所有令牌都有 .dark 对应定义。
|
||||
*
|
||||
* 引用 Layer 1 Primitive 组合成完整语义值。
|
||||
* 对齐:CICD 项目 src/app/styles/tokens/semantic-light.css
|
||||
* 关联:project_rules §3.10
|
||||
*/
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
|
||||
/* ============ 背景与表面 ============ */
|
||||
--bg-paper: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-98));
|
||||
--bg-surface: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
||||
--bg-subtle: hsl(var(--color-rule-h) var(--color-rule-s) var(--color-rule-l-90));
|
||||
/* ============ shadcn 标准令牌 ============ */
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* ============ 文字 ============ */
|
||||
--color-ink: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-15));
|
||||
--color-ink-muted: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-45));
|
||||
--color-ink-subtle: hsl(var(--color-ink-h) var(--color-ink-s) var(--color-ink-l-60));
|
||||
--color-ink-on-accent: hsl(var(--color-paper-h) var(--color-paper-s) var(--color-paper-l-99));
|
||||
/* ============ chart 令牌 ============ */
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
|
||||
/* ============ 强调色 ============ */
|
||||
--color-accent: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-35));
|
||||
--color-accent-hover: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-accent-l-50));
|
||||
--color-accent-subtle: hsl(var(--color-accent-h) var(--color-accent-s) var(--color-rule-l-90));
|
||||
/* ============ sidebar 令牌 ============ */
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
/* ============ 分隔线 ============ */
|
||||
--color-rule: hsl(var(--color-rule-h) var(--color-rule-s) var(--color-rule-l-90));
|
||||
--color-rule-strong: hsl(var(--color-rule-h) var(--color-rule-s) var(--color-rule-l-85));
|
||||
/* ============ 语义层扩展(新增层级) ============ */
|
||||
--background-elevated: 0 0% 100%;
|
||||
--background-sunken: 240 4.8% 95.9%;
|
||||
--text-primary: 240 10% 3.9%; /* = --foreground */
|
||||
--text-secondary: 240 3.8% 46.1%; /* = --muted-foreground */
|
||||
--text-tertiary: 240 4% 65%;
|
||||
--border-strong: 240 5.9% 70%;
|
||||
--border-subtle: 240 5.9% 95%;
|
||||
|
||||
/* ============ 语义色 ============ */
|
||||
--color-success: hsl(var(--color-success-h) var(--color-success-s) var(--color-success-l-45));
|
||||
--color-warning: hsl(var(--color-warning-h) var(--color-warning-s) var(--color-warning-l-50));
|
||||
--color-danger: hsl(var(--color-danger-h) var(--color-danger-s) var(--color-danger-l-60));
|
||||
--color-info: hsl(var(--color-info-h) var(--color-info-s) var(--color-info-l-48));
|
||||
/* ============ 业务语义令牌 ============ */
|
||||
/* 版本对比 */
|
||||
--diff-add: 142 71% 45%;
|
||||
--diff-add-bg: 142 71% 45% / 0.1;
|
||||
--diff-remove: 0 84% 60%;
|
||||
--diff-remove-bg: 0 84% 60% / 0.1;
|
||||
|
||||
/* ============ 边框/输入 ============ */
|
||||
--color-border: var(--color-rule);
|
||||
--color-border-focus: var(--color-accent);
|
||||
--color-input-bg: var(--bg-surface);
|
||||
|
||||
/* ============ 字体族(通过 var 引用,禁止字面量) ============ */
|
||||
--font-family-sans: var(--font-inter, system-ui), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-family-serif: var(--font-fraunces, Georgia), "Times New Roman", serif;
|
||||
--font-family-mono: var(--font-jetbrains-mono, "SF Mono"), Monaco, Consolas, monospace;
|
||||
|
||||
/* ============ 语义字号 ============ */
|
||||
--font-size-body: var(--font-size-3);
|
||||
--font-size-small: var(--font-size-2);
|
||||
--font-size-tiny: var(--font-size-1);
|
||||
--font-size-heading-1: var(--font-size-7);
|
||||
--font-size-heading-2: var(--font-size-6);
|
||||
--font-size-heading-3: var(--font-size-5);
|
||||
--font-size-display: var(--font-size-9);
|
||||
--font-size-large-number: var(--font-size-8);
|
||||
|
||||
/* ============ 语义间距 ============ */
|
||||
--space-xs: var(--space-1);
|
||||
--space-sm: var(--space-2);
|
||||
--space-md: var(--space-4);
|
||||
--space-lg: var(--space-6);
|
||||
--space-xl: var(--space-8);
|
||||
--space-2xl: var(--space-12);
|
||||
|
||||
/* ============ 语义阴影 ============ */
|
||||
--shadow-sm: var(--shadow-1);
|
||||
--shadow-md: var(--shadow-2);
|
||||
--shadow-lg: var(--shadow-3);
|
||||
--shadow-xl: var(--shadow-4);
|
||||
|
||||
/* ============ 语义圆角 ============ */
|
||||
--radius-default: var(--radius-md);
|
||||
--radius-card: var(--radius-lg);
|
||||
--radius-button: var(--radius-md);
|
||||
|
||||
/* ============ 过渡 ============ */
|
||||
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
/* 知识图谱节点色 */
|
||||
--graph-node-1: 12 76% 61%; /* = --chart-1 */
|
||||
--graph-node-2: 173 58% 39%; /* = --chart-2 */
|
||||
--graph-node-3: 197 37% 24%; /* = --chart-3 */
|
||||
--graph-node-4: 43 74% 66%; /* = --chart-4 */
|
||||
--graph-node-5: 27 87% 67%; /* = --chart-5 */
|
||||
--graph-node-6: 280 65% 60%;
|
||||
}
|
||||
|
||||
@@ -1,29 +1,129 @@
|
||||
/**
|
||||
* Layer 3: Tailwind Theme Mapping(Tailwind 主题映射)
|
||||
* Layer 3: Tailwind Theme Mapping(Tailwind v4 @theme inline)
|
||||
*
|
||||
* 将 Layer 2 Semantic 令牌映射为 Tailwind 类名(bg-* / text-* / font-* 等)。
|
||||
* 维护者:ai13(teacher-portal)
|
||||
* 关联:project_rules §3.10、03-long-term-architecture.md §1.5
|
||||
* 将 Layer 2 Semantic 令牌暴露为 Tailwind 类(bg-background / text-foreground / ...)。
|
||||
* 业务代码使用 bg-background / text-foreground / font-sans 等。
|
||||
*
|
||||
* Tailwind 3.4 通过 tailwind.config.js 的 theme.extend 映射;
|
||||
* Tailwind 4 将改用 @theme inline 指令(未来升级时本文件可转为 @theme 块)。
|
||||
*
|
||||
* 使用方式(业务组件):
|
||||
* <div className="bg-paper text-ink font-serif">...</div>
|
||||
* <p className="text-sm text-ink-muted">...</p>
|
||||
*
|
||||
* 对应 tailwind.config.js 中:
|
||||
* colors: { paper: 'var(--bg-paper)', ink: 'var(--color-ink)', ... }
|
||||
* fontFamily: { sans: 'var(--font-family-sans)', serif: 'var(--font-family-serif)', ... }
|
||||
* 对齐:CICD 项目 src/app/styles/tokens/tailwind-theme.css
|
||||
* 关联:project_rules §3.10
|
||||
*/
|
||||
|
||||
/* 显式引用语义令牌,确保 CSS 变量在构建产物中可用 */
|
||||
:root {
|
||||
/* Tailwind 颜色类映射(与 tailwind.config.js theme.extend.colors 对齐) */
|
||||
--tw-color-paper: var(--bg-paper);
|
||||
--tw-color-surface: var(--bg-surface);
|
||||
--tw-color-ink: var(--color-ink);
|
||||
--tw-color-ink-muted: var(--color-ink-muted);
|
||||
--tw-color-accent: var(--color-accent);
|
||||
--tw-color-rule: var(--color-rule);
|
||||
@theme inline {
|
||||
/* ============ shadcn 标准颜色 ============ */
|
||||
--color-background: hsl(var(--background));
|
||||
--color-foreground: hsl(var(--foreground));
|
||||
--color-card: hsl(var(--card));
|
||||
--color-card-foreground: hsl(var(--card-foreground));
|
||||
--color-popover: hsl(var(--popover));
|
||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
||||
--color-primary: hsl(var(--primary));
|
||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
||||
--color-secondary: hsl(var(--secondary));
|
||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
||||
--color-muted: hsl(var(--muted));
|
||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
||||
--color-accent: hsl(var(--accent));
|
||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
||||
--color-destructive: hsl(var(--destructive));
|
||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
||||
--color-border: hsl(var(--border));
|
||||
--color-input: hsl(var(--input));
|
||||
--color-ring: hsl(var(--ring));
|
||||
|
||||
/* ============ chart 颜色 ============ */
|
||||
--color-chart-1: hsl(var(--chart-1));
|
||||
--color-chart-2: hsl(var(--chart-2));
|
||||
--color-chart-3: hsl(var(--chart-3));
|
||||
--color-chart-4: hsl(var(--chart-4));
|
||||
--color-chart-5: hsl(var(--chart-5));
|
||||
|
||||
/* ============ sidebar 颜色 ============ */
|
||||
--color-sidebar: hsl(var(--sidebar-background));
|
||||
--color-sidebar-foreground: hsl(var(--sidebar-foreground));
|
||||
--color-sidebar-primary: hsl(var(--sidebar-primary));
|
||||
--color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
|
||||
--color-sidebar-accent: hsl(var(--sidebar-accent));
|
||||
--color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
|
||||
--color-sidebar-border: hsl(var(--sidebar-border));
|
||||
--color-sidebar-ring: hsl(var(--sidebar-ring));
|
||||
|
||||
/* ============ 扩展语义颜色 ============ */
|
||||
--color-background-elevated: hsl(var(--background-elevated));
|
||||
--color-background-sunken: hsl(var(--background-sunken));
|
||||
--color-text-primary: hsl(var(--text-primary));
|
||||
--color-text-secondary: hsl(var(--text-secondary));
|
||||
--color-text-tertiary: hsl(var(--text-tertiary));
|
||||
--color-border-strong: hsl(var(--border-strong));
|
||||
--color-border-subtle: hsl(var(--border-subtle));
|
||||
--color-diff-add: hsl(var(--diff-add));
|
||||
--color-diff-add-bg: hsl(var(--diff-add-bg));
|
||||
--color-diff-remove: hsl(var(--diff-remove));
|
||||
--color-diff-remove-bg: hsl(var(--diff-remove-bg));
|
||||
--color-graph-node-1: hsl(var(--graph-node-1));
|
||||
--color-graph-node-2: hsl(var(--graph-node-2));
|
||||
--color-graph-node-3: hsl(var(--graph-node-3));
|
||||
--color-graph-node-4: hsl(var(--graph-node-4));
|
||||
--color-graph-node-5: hsl(var(--graph-node-5));
|
||||
--color-graph-node-6: hsl(var(--graph-node-6));
|
||||
|
||||
/* ============ 字体家族 ============ */
|
||||
--font-sans: var(--font-family-sans);
|
||||
--font-serif: var(--font-family-serif);
|
||||
--font-mono: var(--font-family-mono);
|
||||
|
||||
/* ============ 字号阶梯 ============ */
|
||||
--text-size-0: var(--font-size-0);
|
||||
--text-size-1: var(--font-size-1);
|
||||
--text-size-2: var(--font-size-2);
|
||||
--text-size-3: var(--font-size-3);
|
||||
--text-size-4: var(--font-size-4);
|
||||
--text-size-5: var(--font-size-5);
|
||||
--text-size-6: var(--font-size-6);
|
||||
--text-size-7: var(--font-size-7);
|
||||
--text-size-8: var(--font-size-8);
|
||||
--text-size-9: var(--font-size-9);
|
||||
|
||||
/* ============ 圆角阶梯 ============ */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* ============ 阴影阶梯 ============ */
|
||||
--shadow-1: var(--shadow-1);
|
||||
--shadow-2: var(--shadow-2);
|
||||
--shadow-3: var(--shadow-3);
|
||||
--shadow-4: var(--shadow-4);
|
||||
--shadow-5: var(--shadow-5);
|
||||
--shadow-6: var(--shadow-6);
|
||||
|
||||
/* ============ 动效 ============ */
|
||||
--duration-fast: var(--duration-fast);
|
||||
--duration-normal: var(--duration-normal);
|
||||
--duration-slow: var(--duration-slow);
|
||||
--ease-in: var(--ease-in);
|
||||
--ease-out: var(--ease-out);
|
||||
--ease-in-out: var(--ease-in-out);
|
||||
|
||||
/* ============ z-index ============ */
|
||||
--z-dropdown: var(--z-dropdown);
|
||||
--z-sticky: var(--z-sticky);
|
||||
--z-modal: var(--z-modal);
|
||||
--z-popover: var(--z-popover);
|
||||
--z-toast: var(--z-toast);
|
||||
|
||||
/* ============ 动画 ============ */
|
||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||
|
||||
@keyframes accordion-down {
|
||||
from { height: 0; }
|
||||
to { height: var(--radix-accordion-content-height); }
|
||||
}
|
||||
@keyframes accordion-up {
|
||||
from { height: var(--radix-accordion-content-height); }
|
||||
to { height: 0; }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user