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