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:
SpecialX
2026-07-17 16:10:05 +08:00
parent f7e52b5b7f
commit 9cedf0c437
140 changed files with 10872 additions and 3192 deletions

View File

@@ -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"
}
}

View File

@@ -20,42 +20,39 @@
* - token 存储使用 localStorageF12 裁决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 插件系统 Hooksv2.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";

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import type { AuthState, UserSession } from "./types.js";
import type { AuthState, UserSession } from "./types";
/**
* useAuth - 会话状态管理

View File

@@ -0,0 +1,190 @@
"use client";
import { useCallback } from "react";
/**
* useErrorReport - 客户端错误上报 Hook对齐 CICD use-error-report.ts
*
* 通过 navigator.sendBeacon 上报到 /api/logfallback 到 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.digestNext.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;
}

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo } from "react";
import type { PermissionContext } from "./types.js";
import type { PermissionContext } from "./types";
/**
* usePermission - 权限查询 Hook

View File

@@ -0,0 +1,133 @@
import { useEffect, useState } from "react";
import type { PluginConfigResponse } from "@edu/shared-ts/contracts";
/**
* usePluginConfig - 插件配置静默刷新 Hookportal-shell spec §6.4
*
* 通用封装,不直接依赖 SWR 或 Apollo Client。
* fetcher 由消费者注入,保持 @edu/hooks "hooks 不直接调 API" 的设计原则。
*
* 特性:
* - 支持 fallbackDataRSC 预取的 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]);
}

View File

@@ -0,0 +1,80 @@
import { useSyncExternalStore } from "react";
import type {
PluginStoreState,
ThemeMode,
Locale,
} from "@edu/shared-ts/contracts";
/**
* usePluginStore - Zustand 全局状态 Hookportal-shell spec §5.2.2
*
* 封装 Zustand store 的订阅,提供 theme/locale/sidebarCollapsed 状态管理。
* 此 hook 是通用封装,实际 store 实例由 portal-shell 创建并注入。
*
* 设计原则(@edu/hookshooks 不直接依赖特定 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 未注入时的空操作
},
};

View File

@@ -1,5 +1,5 @@
import { useCallback, useState } from "react";
import type { ToastMessage } from "./types.js";
import type { ToastMessage } from "./types";
/**
* useToast - 全局 Toast 通知管理

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react";
import type { Viewport } from "./types.js";
import type { Viewport } from "./types";
/**
* useViewports - 视口列表查询

View File

@@ -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": {

View 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";

View 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[];
}

View File

@@ -0,0 +1,105 @@
/**
* URL Search Params 上下文 schemaportal-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;
/** 当前选中的考试 IDexams-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);
}
}

View File

@@ -0,0 +1,36 @@
/**
* Zustand 全局状态 schemaportal-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;
}

View 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;
/** 数据范围DataScopeIAM 计算后的可见范围 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 schemaJSON Schemaadmin 配置面板自动渲染表单) */
propsSchema?: JsonSchema;
/** 系统默认 props与 propsSchema 配合) */
defaultProps?: Record<string, unknown>;
};
}
/** 插件清单元数据(不含 Component用于 plugin.manifest.ts 声明) */
export type PluginManifestMeta = Omit<PluginManifest, "Component">;

View 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 G4pino 结构化日志)
* - 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();

View 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 → 遍历 ORDERbit 为 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);
}

View File

@@ -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"
}
}

View File

@@ -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",
}}
>

View File

@@ -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"
/>
))}

View File

@@ -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,
)}

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
import { cn } from "./utils/cn";
/**
* Empty - 空态展示(插画占位 + 文案 + CTA

View File

@@ -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>

View File

@@ -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,
)}
>

View File

@@ -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";

View File

@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn.js";
import { cn } from "./utils/cn";
/**
* Loading - 骨架屏 / 加载占位

View File

@@ -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}

View File

@@ -0,0 +1,75 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn";
/**
* PluginCard - 插件卡片容器portal-shell spec §7.3
*
* 所有插件内容应使用 PluginCard 包裹,强制设计令牌一致性:
* - 卡片背景 bg-cardv2.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-cardvariant 仅作语义标识(向后兼容)
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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,182 @@
import type { ReactNode } from "react";
import { cn } from "./utils/cn";
/**
* PropsConfigForm - 基于 JSON Schema 的插件配置表单portal-shell spec §7.3
*
* 根据插件的 propsSchemaJSON 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>
);
}

View File

@@ -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}

View 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>
);
}

View File

@@ -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,
)}

View File

@@ -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));
}

View File

@@ -1,112 +1,116 @@
/**
* Layer 1: Primitive Tokens原始色板/字号/间距/阴影)
* Layer 1: Primitive Tokens原始色板/字号/间距/阴影/字体家族
*
* 业务代码不直接引用本层令牌,仅 Layer 2 Semantic 引用。
* 维护者ai13teacher-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-91 最小 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;
}

View File

@@ -1,66 +1,73 @@
/**
* Layer 2: Semantic Tokens - Dark Theme暗色语义令牌
*
* 维护者ai13teacher-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%;
}

View File

@@ -1,82 +1,76 @@
/**
* Layer 2: Semantic Tokens - Light Theme亮色语义令牌
*
* 业务代码唯一引用入口:业务 TSX/CSS 引用 var(--color-*) / var(--font-*) 等
* 维护者ai13teacher-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%;
}

View File

@@ -1,29 +1,129 @@
/**
* Layer 3: Tailwind Theme MappingTailwind 主题映射
* Layer 3: Tailwind Theme MappingTailwind v4 @theme inline
*
* 将 Layer 2 Semantic 令牌映射为 Tailwind 类bg-* / text-* / font-* 等)。
* 维护者ai13teacher-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; }
}
}