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,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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user