建立设计系统并拆分应用壳层

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Ubuntu 2026-08-03 19:26:41 +00:00
parent d54841fdb4
commit 33bb93ac7b
14 changed files with 223 additions and 82 deletions

View File

@ -3,6 +3,8 @@ import { SessionQueryIntent, SessionTable } from "./SessionTable";
import { demoSessions } from "./protocol/fixtures";
import { initializeSessionTableStates, updateSessionTableState } from "./sessionTableState";
import { toSessionRow } from "./sessionPresentation";
import { WorkspaceLayout } from "./shell";
import { Button, Icon } from "./ui";
import {
createInitialWorkspaceState,
selectActiveTab,
@ -22,68 +24,11 @@ export function App() {
const tableState = { ...tableStates[server.id], filter: server.filter };
return (
<main className="shell">
<aside className="server-rail" aria-label="Teamserver 列表">
<div className="brand">G</div>
{workspace.serverOrder.map((serverId) => workspace.servers[serverId]).map((item) => (
<button
className={`server-button ${item.id === server.id ? "active" : ""}`}
key={item.id}
onClick={() => dispatch({ type: "switchServer", serverId: item.id })}
aria-label={`切换到 ${item.name}`}
title={item.name}
>
{item.shortName}
<span className={`presence ${item.connection.toLowerCase()}`} />
{item.unread > 0 && <span className="badge">{item.unread}</span>}
</button>
))}
<button className="server-button add" aria-label="添加 Teamserver">+</button>
</aside>
<aside className="project-sidebar">
<header>
<p className="eyebrow">TEAMSERVER</p>
<h1>{server.name}</h1>
<span className={`connection ${server.connection.toLowerCase()}`}>{server.connection}</span>
</header>
<label className="search">
<span></span>
<input aria-label="搜索" placeholder="搜索" />
<kbd> K</kbd>
</label>
<nav aria-label="项目导航">
<a> <strong>{server.unread}</strong></a>
<p className="nav-heading"> · {server.activeProject}</p>
{['Overview', 'Sessions', 'Tasks', 'Events', 'Artifacts', 'Audit'].map((item) => (
<a className={item === activeTab.title ? "selected" : ""} key={item}>{item}</a>
))}
</nav>
<p className="memory-note"> · 退</p>
</aside>
<section className="workspace">
<div className="tabbar" role="tablist" aria-label={`${server.name} 标签`}>
{server.tabs.map((tab) => (
<button
role="tab"
aria-selected={tab.id === activeTab.id}
className={tab.id === activeTab.id ? "active" : ""}
key={tab.id}
onClick={() => dispatch({ type: "activateTab", serverId: server.id, tabId: tab.id })}
title={`${tab.context.project} / ${tab.context.session}`}
>
{tab.running && <span className="running" />}{tab.title}<span className="close">×</span>
</button>
))}
</div>
<div className="contextbar" aria-label="锁定的操作上下文">
<span className="context-lock"> LOCKED</span>{server.name} <i>/</i> {activeTab.context.project} <i>/</i> <b>{activeTab.context.session}</b> <i>/</i> {activeTab.title}
</div>
<WorkspaceLayout servers={workspace.serverOrder.map((id) => workspace.servers[id])} server={server} activeTab={activeTab} onSwitchServer={(serverId) => dispatch({ type: "switchServer", serverId })} onActivateTab={(tabId) => dispatch({ type: "activateTab", serverId: server.id, tabId })}>
<article className="content">
<div className="content-heading">
<div><h2>{activeTab.title}</h2><p>{activeTab.context.project} 248 Session · 4 </p></div>
<div className="heading-actions"><button className="secondary"></button><button className="primary"> </button></div>
<div className="heading-actions"><Button></Button><Button intent="primary"><Icon name="add" /></Button></div>
</div>
<SessionTable rows={sessions} state={tableState} capabilities={{ filtering: true, sortableColumns: ["status", "id", "seen"], columnVisibility: true, pagination: true }} page={{ start: 1, end: 50, total: 248, hasPrevious: false, hasNext: true }} onStateChange={(state) => {
if (state.filter !== server.filter) dispatch({ type: "setFilter", serverId: server.id, filter: state.filter });
@ -91,8 +36,6 @@ export function App() {
}} onQueryIntent={(intent) => setLastQueryIntent((current) => ({ ...current, [server.id]: intent }))} />
<output className="query-intent" aria-live="polite">{lastQueryIntent[server.id] ? `查询意图:${lastQueryIntent[server.id]?.type}` : "等待服务端查询意图"}</output>
</article>
<footer className="statusbar"><span> {server.connection}</span><span> operator@example</span><span> 38ms</span><span> v1.0</span><span> </span><span className="status-spacer" /></footer>
</section>
</main>
</WorkspaceLayout>
);
}

View File

@ -2,6 +2,8 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles.css";
import "./styles/tokens.css";
import "./ui/ui.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>

3
src/shell/ContextBar.tsx Normal file
View File

@ -0,0 +1,3 @@
import { Icon } from "../ui";
import type { OperationTab } from "../workspace";
export function ContextBar({ serverName, tab }: { serverName: string; tab: OperationTab }) { return <div className="contextbar" aria-label="锁定的操作上下文"><span className="context-lock"><Icon name="lock" size={12} /> LOCKED</span>{serverName} <i>/</i> {tab.context.project} <i>/</i> <b>{tab.context.session}</b> <i>/</i> {tab.title}</div>; }

View File

@ -0,0 +1,7 @@
import { Icon } from "../ui";
import type { OperationTab } from "../workspace";
export interface OperationTabsProps { serverName: string; tabs: readonly OperationTab[]; activeTabId: string; onActivate: (tabId: string) => void; onClose?: (tabId: string) => void; }
export function OperationTabs({ serverName, tabs, activeTabId, onActivate, onClose }: OperationTabsProps) {
return <div className="tabbar" role="tablist" aria-label={`${serverName} 标签`}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeTabId} className={tab.id === activeTabId ? "active" : ""} key={tab.id} onClick={() => onActivate(tab.id)} title={`${tab.context.project} / ${tab.context.session}`}><span className="tab-label">{tab.running && <span className="running" aria-label="任务运行中" />}{tab.title}</span>{onClose && <span className="close" role="button" aria-label={`关闭 ${tab.title}`} tabIndex={0} onClick={(event) => { event.stopPropagation(); onClose(tab.id); }}><Icon name="close" size={12} /></span>}</button>)}</div>;
}

View File

@ -0,0 +1,8 @@
import { Icon, Input, StatusIndicator } from "../ui";
import type { TeamserverWorkspace } from "../workspace";
const navigation = ["Overview", "Sessions", "Tasks", "Events", "Artifacts", "Audit"];
export interface ProjectSidebarProps { server: TeamserverWorkspace; activePage: string; onNavigate?: (page: string) => void; }
export function ProjectSidebar({ server, activePage, onNavigate }: ProjectSidebarProps) {
return <aside className="project-sidebar"><header><p className="eyebrow">TEAMSERVER</p><h1>{server.name}</h1><StatusIndicator status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} /></header><Input className="sidebar-search" aria-label="搜索" placeholder="搜索" leading={<Icon name="search" />} /><nav aria-label="项目导航"><button> <strong>{server.unread}</strong></button><p className="nav-heading"> · {server.activeProject}</p>{navigation.map((item) => <button className={item === activePage ? "selected" : ""} key={item} onClick={() => onNavigate?.(item)}>{item}</button>)}</nav><p className="memory-note"> · 退</p></aside>;
}

3
src/shell/StatusBar.tsx Normal file
View File

@ -0,0 +1,3 @@
import { StatusIndicator } from "../ui";
import type { ConnectionState } from "../workspace";
export function StatusBar({ connection }: { connection: ConnectionState }) { return <footer className="statusbar"><StatusIndicator status={connection === "CONNECTED" ? "online" : "warning"} label={connection} /><span> operator@example</span><span> 38ms</span><span> v1.0</span><span></span><span className="status-spacer" /><span></span></footer>; }

View File

@ -0,0 +1,7 @@
import { Badge, Icon, IconButton, StatusIndicator, Tooltip } from "../ui";
import type { TeamserverWorkspace } from "../workspace";
export interface TeamserverRailProps { servers: TeamserverWorkspace[]; activeServerId: string; onSwitch: (serverId: string) => void; onAdd?: () => void; }
export function TeamserverRail({ servers, activeServerId, onSwitch, onAdd }: TeamserverRailProps) {
return <aside className="server-rail" aria-label="Teamserver 列表"><div className="brand">G</div>{servers.map((server) => <Tooltip content={`${server.name} · ${server.connection}`} key={server.id}><button className={`server-button ${server.id === activeServerId ? "active" : ""}`} onClick={() => onSwitch(server.id)} aria-label={`切换到 ${server.name}`} aria-current={server.id === activeServerId ? "true" : undefined}><span>{server.shortName}</span><StatusIndicator compact status={server.connection === "CONNECTED" ? "online" : "warning"} label={server.connection} />{server.unread > 0 && <Badge tone="danger">{server.unread}</Badge>}</button></Tooltip>)}<IconButton className="server-button add" label="添加 Teamserver" onClick={onAdd}><Icon name="add" /></IconButton></aside>;
}

View File

@ -0,0 +1,10 @@
import { ReactNode } from "react";
import type { OperationTab, TeamserverWorkspace } from "../workspace";
import { ContextBar } from "./ContextBar";
import { OperationTabs } from "./OperationTabs";
import { ProjectSidebar } from "./ProjectSidebar";
import { StatusBar } from "./StatusBar";
import { TeamserverRail } from "./TeamserverRail";
export interface WorkspaceLayoutProps { servers: TeamserverWorkspace[]; server: TeamserverWorkspace; activeTab: OperationTab; children: ReactNode; onSwitchServer: (id: string) => void; onActivateTab: (id: string) => void; }
export function WorkspaceLayout({ servers, server, activeTab, children, onSwitchServer, onActivateTab }: WorkspaceLayoutProps) { return <main className="shell"><TeamserverRail servers={servers} activeServerId={server.id} onSwitch={onSwitchServer} /><ProjectSidebar server={server} activePage={activeTab.title} /><section className="workspace"><OperationTabs serverName={server.name} tabs={server.tabs} activeTabId={activeTab.id} onActivate={onActivateTab} /><ContextBar serverName={server.name} tab={activeTab} />{children}<StatusBar connection={server.connection} /></section></main>; }

6
src/shell/index.ts Normal file
View File

@ -0,0 +1,6 @@
export * from "./ContextBar";
export * from "./OperationTabs";
export * from "./ProjectSidebar";
export * from "./StatusBar";
export * from "./TeamserverRail";
export * from "./WorkspaceLayout";

View File

@ -6,21 +6,20 @@
--online: #38b57a; --warning: #d9a441; --error: #e05b63; --info: #4c8dda;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 900px; min-height: 100vh; background: var(--app); }
button, input { font: inherit; color: inherit; }
body { margin: 0; min-width: 0; min-height: 100vh; overflow: auto; background: var(--app); }
button, input, select { font: inherit; color: inherit; }
button { cursor: pointer; }
button:focus-visible, input:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--info); outline-offset: -2px; }
.shell { display: grid; grid-template-columns: 52px 260px 1fr; min-height: 100vh; background: var(--app); }
button:focus-visible, input:focus-visible, select:focus-visible, [tabindex]:focus-visible { outline: none; box-shadow: var(--focus-ring); position: relative; z-index: 1; }
.shell { display: grid; grid-template-columns: 52px 260px minmax(600px, 1fr); min-width: 912px; min-height: 100vh; background: var(--app); }
.server-rail { background: #101318; border-right: 1px solid var(--border); padding: 10px 7px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.brand { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #3a414c; border-radius: 5px; color: var(--text); background: #1b2027; font: 700 14px ui-monospace, "JetBrains Mono", monospace; margin-bottom: 9px; }
.server-button { position: relative; width: 36px; height: 36px; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; }
.server-button { position: relative; width: 36px; height: 36px; display:grid; place-items:center; border: 1px solid transparent; border-radius: 5px; background: #1b2027; color: #a9b0bc; font: 600 10px ui-monospace, "JetBrains Mono", monospace; transition: background 100ms ease, border-color 100ms ease; }
.server-button:hover { background: var(--hover); border-color: #343c47; color: var(--text); }
.server-button.active { background: var(--selected); border-color: #3c4959; color: var(--text); }
.server-button.active::before { content: ""; position: absolute; width: 2px; height: 22px; background: var(--info); left: -8px; top: 6px; border-radius: 1px; }
.server-button.add { color: var(--muted); font-size: 18px; background: transparent; border: 1px dashed #3a414c; }
.presence { position: absolute; width: 8px; height: 8px; border: 2px solid #101318; border-radius: 50%; right: -3px; bottom: -3px; background: var(--online); }
.presence.degraded { background: var(--warning); }
.badge { position: absolute; min-width: 16px; height: 16px; padding: 0 4px; line-height: 16px; background: var(--error); color: white; border-radius: 8px; right: -7px; top: -7px; font: 600 9px ui-monospace, monospace; }
.server-button .ui-status { position:absolute;right:-3px;bottom:-3px;background:#101318;border-radius:50%;padding:2px; }
.server-button .ui-badge { position:absolute;right:-7px;top:-7px; }
.project-sidebar { background: var(--nav); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 18px 12px 9px; }
.project-sidebar header { padding: 0 7px 13px; }
.eyebrow { color: var(--muted); font-size: 10px; font-weight: 650; letter-spacing: .1em; margin: 0 0 6px; }
@ -29,14 +28,14 @@ h1 { font-size: 14px; line-height: 20px; margin: 0 0 8px; font-weight: 600; }
.connection::before { content: "✓"; margin-right: 4px; }
.connection.degraded { color: #e5bb67; background: #322a1d; border-color: #5a4727; }
.connection.degraded::before { content: "!"; }
.search, .table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); }
.search { margin: 8px 1px 17px; padding: 6px 8px; }
.search input, .table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; }
.table-toolbar label { display: flex; align-items: center; gap: 7px; border: 1px solid var(--border); border-radius: 4px; background: #111419; color: var(--muted); }
.sidebar-search { margin: 8px 1px 17px; }
.table-toolbar input { min-width: 0; width: 100%; border: 0; outline: 0; background: transparent; }
kbd { border: 1px solid #343b45; border-radius: 3px; padding: 1px 4px; font-size: 9px; white-space: nowrap; }
nav { display: flex; flex-direction: column; gap: 2px; }
nav a { display: flex; justify-content: space-between; padding: 6px 9px; border-radius: 4px; color: #aab1bd; font-size: 12px; transition: background 100ms ease; }
nav a:hover { background: var(--hover); }
nav a.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); }
nav button { display: flex; justify-content: space-between; padding: 6px 9px; border:0; border-radius: 4px; background:transparent; color: #aab1bd; font-size: 12px; text-align:left; transition: background 100ms ease; }
nav button:hover { background: var(--hover); }
nav button.selected { background: var(--selected); color: var(--text); font-weight: 550; box-shadow: inset 2px 0 var(--info); }
nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.nav-heading { margin: 18px 9px 5px; font-size: 10px; color: #737c89; font-weight: 650; text-transform: uppercase; letter-spacing: .08em; }
.memory-note { margin-top: auto; padding: 9px; border-top: 1px solid var(--border); font-size: 10px; color: #727b88; }
@ -45,12 +44,12 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.tabbar button { min-width: 132px; max-width: 190px; border: 0; border-right: 1px solid var(--border); background: transparent; padding: 0 11px; font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: background 100ms ease; }
.tabbar button:hover { background: var(--hover); color: #cbd0d8; }
.tabbar button.active { background: var(--content); color: var(--text); box-shadow: inset 0 2px var(--info); }
.close { color: #69717d; margin-left: 15px; }
.running { display: inline-block; width: 6px; height: 6px; border-radius: 2px; background: var(--warning); margin-right: 7px; }
.tabbar button { display:flex;align-items:center;justify-content:space-between;gap:8px; }.tab-label{overflow:hidden;text-overflow:ellipsis}.close { color: #69717d; display:inline-flex; }
.running { display: inline-block; width: 6px; height: 6px; border:1px solid currentColor; border-radius: 2px; background: var(--warning); margin-right: 7px; }
.contextbar { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--border); padding: 0 16px; font-size: 11px; color: var(--muted); background: #171b21; }
.contextbar i { color: #515967; font-style: normal; }
.contextbar b { color: #cdd2da; font: 500 11px ui-monospace, "JetBrains Mono", monospace; }
.context-lock { color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; }
.context-lock { display:inline-flex;align-items:center;gap:4px;color: #77a9e4; font: 650 9px ui-monospace, monospace; border-right: 1px solid var(--border); padding-right: 10px; margin-right: 2px; }
.content { overflow: auto; padding: 22px 24px 30px; background: var(--content); }
.content-heading { display: flex; align-items: center; justify-content: space-between; padding-bottom: 18px; }
.content-heading h2 { font-size: 20px; line-height: 27px; margin: 0; font-weight: 600; letter-spacing: -.01em; }
@ -94,7 +93,8 @@ nav strong { color: #f1888f; font: 600 10px ui-monospace, monospace; }
.query-intent { display: block; margin-top: 7px; color: #737c89; font: 9px ui-monospace, monospace; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.statusbar { display: flex; gap: 20px; align-items: center; padding: 0 10px; border-top: 1px solid #2b323c; color: #78818d; background: #111419; font-size: 9px; }
.statusbar span:first-child { color: #75c99c; }
.statusbar { overflow-x:auto;white-space:nowrap; }.statusbar .ui-status { color: #75c99c; }
input[type="checkbox"] { accent-color: var(--info); width: 12px; height: 12px; }
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 230px 1fr; } .content { padding-inline: 16px; } }
@media (prefers-reduced-motion: reduce) { * { transition-duration: 0.01ms !important; } }
@media (max-width: 1050px) { .shell { grid-template-columns: 52px 220px minmax(600px,1fr); min-width:872px; } .content { padding-inline: 16px; } }
@media (prefers-reduced-motion: reduce) { *,*::before,*::after { scroll-behavior:auto!important;transition-duration:0.01ms!important;animation-duration:0.01ms!important;animation-iteration-count:1!important; } }
@media (forced-colors: active) { button:focus-visible,input:focus-visible,select:focus-visible,[tabindex]:focus-visible { outline:2px solid Highlight;outline-offset:2px;box-shadow:none; } }

10
src/styles/tokens.css Normal file
View File

@ -0,0 +1,10 @@
:root {
--color-app: #0f1115; --color-nav: #14171c; --color-content: #181c22; --color-hover: #20252d;
--color-selected: #27313d; --color-border: #2a3039; --color-text: #e7eaf0; --color-muted: #929aa8;
--color-online: #38b57a; --color-warning: #d9a441; --color-danger: #e05b63; --color-info: #4c8dda;
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px; --space-5: 24px;
--font-xs: 9px; --font-sm: 11px; --font-md: 13px; --font-lg: 20px;
--line-sm: 1.3; --line-md: 1.5; --radius-sm: 4px; --radius-md: 6px;
--border-subtle: 1px solid var(--color-border); --z-popover: 30; --z-dialog: 50;
--duration-fast: 100ms; --duration-normal: 160ms; --focus-ring: 0 0 0 2px #0f1115, 0 0 0 4px #73b7ff;
}

121
src/ui/index.tsx Normal file
View File

@ -0,0 +1,121 @@
import {
ButtonHTMLAttributes,
HTMLAttributes,
InputHTMLAttributes,
ReactNode,
RefObject,
SelectHTMLAttributes,
useEffect,
useId,
useRef,
useState,
} from "react";
export type Intent = "neutral" | "primary" | "danger";
export type StatusTone = "online" | "warning" | "offline" | "info";
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
intent?: Intent;
loading?: boolean;
}
export function Button({ intent = "neutral", loading = false, disabled, className = "", children, ...props }: ButtonProps) {
return <button className={`ui-button ${intent} ${className}`} disabled={disabled || loading} aria-busy={loading || undefined} {...props}>{loading && <span className="ui-spinner" aria-hidden="true" />}{children}</button>;
}
export interface IconButtonProps extends ButtonProps { label: string; }
export function IconButton({ label, children, ...props }: IconButtonProps) {
return <Button className={`ui-icon-button ${props.className ?? ""}`} aria-label={label} title={props.title ?? label} {...props}>{children}</Button>;
}
export function Icon({ name, size = 14 }: { name: "add" | "search" | "close" | "lock" | "more" | "columns" | "check" | "warning"; size?: number }) {
const paths = {
add: <path d="M12 5v14M5 12h14" />,
search: <><circle cx="10.5" cy="10.5" r="5.5" /><path d="m15 15 4 4" /></>,
close: <path d="m7 7 10 10M17 7 7 17" />,
lock: <><rect x="6" y="10" width="12" height="9" rx="2" /><path d="M8.5 10V7.5a3.5 3.5 0 0 1 7 0V10" /></>,
more: <><circle cx="6" cy="12" r="1" fill="currentColor" /><circle cx="12" cy="12" r="1" fill="currentColor" /><circle cx="18" cy="12" r="1" fill="currentColor" /></>,
columns: <><rect x="4" y="5" width="16" height="14" rx="1" /><path d="M10 5v14m4-14v14" /></>,
check: <path d="m5 12 4 4L19 6" />,
warning: <><path d="M12 4 21 20H3Z" /><path d="M12 9v5m0 3v.1" /></>,
};
return <svg className="ui-icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>;
}
export function Badge({ children, tone = "neutral", className = "" }: { children: ReactNode; tone?: StatusTone | "neutral" | "danger"; className?: string }) {
return <span className={`ui-badge ${tone} ${className}`}>{children}</span>;
}
export function StatusIndicator({ status, label, compact = false }: { status: StatusTone; label: string; compact?: boolean }) {
const mark = status === "online" ? <Icon name="check" /> : status === "warning" ? <Icon name="warning" /> : <span aria-hidden="true"></span>;
return <span className={`ui-status ${status} ${compact ? "compact" : ""}`}><span className="ui-status-mark">{mark}</span>{!compact && <span>{label}</span>}<span className="sr-only">{compact ? label : ""}</span></span>;
}
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> { label?: string; leading?: ReactNode; }
export function Input({ label, leading, className = "", ...props }: InputProps) {
const id = useId();
return <label className={`ui-field ${className}`}>{label && <span className="ui-field-label">{label}</span>}<span className="ui-input-wrap">{leading}<input id={id} {...props} /></span></label>;
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { label?: string; options: { value: string; label: string }[]; }
export function Select({ label, options, className = "", ...props }: SelectProps) {
return <label className={`ui-field ${className}`}>{label && <span className="ui-field-label">{label}</span>}<select {...props}>{options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>;
}
export function Checkbox({ label, ...props }: InputHTMLAttributes<HTMLInputElement> & { label: ReactNode }) {
return <label className="ui-checkbox"><input type="checkbox" {...props} /><span>{label}</span></label>;
}
export interface TabsProps { label: string; tabs: { id: string; label: ReactNode; title?: string }[]; activeId: string; onChange: (id: string) => void; }
export function Tabs({ label, tabs, activeId, onChange }: TabsProps) {
return <div className="ui-tabs" role="tablist" aria-label={label}>{tabs.map((tab) => <button role="tab" aria-selected={tab.id === activeId} className={tab.id === activeId ? "active" : ""} key={tab.id} title={tab.title} onClick={() => onChange(tab.id)}>{tab.label}</button>)}</div>;
}
export function Tooltip({ content, children }: { content: ReactNode; children: ReactNode }) {
return <span className="ui-tooltip">{children}<span role="tooltip">{content}</span></span>;
}
export interface OverlayProps { open: boolean; onOpenChange: (open: boolean) => void; children: ReactNode; }
function useDismiss(open: boolean, onOpenChange: (open: boolean) => void, container: RefObject<HTMLElement | null>) {
const returnFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
returnFocus.current = document.activeElement as HTMLElement;
const escape = (event: KeyboardEvent) => event.key === "Escape" && onOpenChange(false);
const outside = (event: PointerEvent) => !container.current?.contains(event.target as Node) && onOpenChange(false);
document.addEventListener("keydown", escape);
document.addEventListener("pointerdown", outside);
return () => { document.removeEventListener("keydown", escape); document.removeEventListener("pointerdown", outside); returnFocus.current?.focus(); };
}, [open, onOpenChange, container]);
}
export function Popover({ open, onOpenChange, children }: OverlayProps) {
const ref = useRef<HTMLDivElement>(null);
useDismiss(open, onOpenChange, ref);
return open ? <div className="ui-popover" ref={ref} role="dialog">{children}</div> : null;
}
export function Dialog({ open, onOpenChange, title, children }: OverlayProps & { title: string }) {
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
useDismiss(open, onOpenChange, ref);
useEffect(() => { if (open) ref.current?.querySelector<HTMLElement>("button, input, select, [tabindex]")?.focus(); }, [open]);
if (!open) return null;
return <div className="ui-dialog-backdrop" onPointerDown={(event) => event.target === event.currentTarget && onOpenChange(false)}><div className="ui-dialog" ref={ref} role="dialog" aria-modal="true" aria-labelledby={titleId}><header><h2 id={titleId}>{title}</h2><IconButton label="关闭对话框" onClick={() => onOpenChange(false)}><Icon name="close" /></IconButton></header>{children}</div></div>;
}
export function EmptyState({ title, description, action }: { title: string; description?: string; action?: ReactNode }) {
return <div className="ui-empty"><strong>{title}</strong>{description && <p>{description}</p>}{action}</div>;
}
export function Skeleton({ width = "100%", className = "" }: { width?: string | number; className?: string }) { return <span className={`ui-skeleton ${className}`} style={{ width }} aria-hidden="true" />; }
export function Toast({ tone = "info", children, onDismiss }: { tone?: StatusTone | "danger"; children: ReactNode; onDismiss?: () => void }) {
return <div className={`ui-toast ${tone}`} role={tone === "danger" ? "alert" : "status"}>{children}{onDismiss && <IconButton label="关闭通知" onClick={onDismiss}><Icon name="close" /></IconButton>}</div>;
}
export function DataToolbar({ children, className = "", ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={`ui-data-toolbar ${className}`} role="toolbar" {...props}>{children}</div>; }
export function DemoOverlays() {
const [open, setOpen] = useState(false);
return <><Button onClick={() => setOpen(true)}></Button><Dialog open={open} onOpenChange={setOpen} title="示例"></Dialog></>;
}

8
src/ui/ui.css Normal file
View File

@ -0,0 +1,8 @@
.ui-button { min-height: 28px; display:inline-flex; align-items:center; justify-content:center; gap:6px; border:var(--border-subtle); border-radius:var(--radius-sm); padding:5px 9px; color:var(--color-text); background:#1b2027; font-size:var(--font-sm); }
.ui-button:hover:not(:disabled) { background:var(--color-hover); }.ui-button:active:not(:disabled){transform:translateY(1px)}.ui-button:disabled{cursor:not-allowed;opacity:.45}.ui-button.primary{background:#326ba9;border-color:#457dbd}.ui-button.primary:hover:not(:disabled){background:#3d78b8}.ui-button.danger{background:#8e3037;border-color:#b84a53}.ui-icon-button{width:28px;padding:0}.ui-icon{flex:none}.ui-spinner{width:11px;height:11px;border:2px solid currentColor;border-right-color:transparent;border-radius:50%;animation:ui-spin .7s linear infinite}
.ui-badge{display:inline-flex;min-width:16px;height:16px;align-items:center;justify-content:center;padding:0 4px;border-radius:8px;background:#343b45;font:600 var(--font-xs) ui-monospace}.ui-badge.danger{background:var(--color-danger);color:white}
.ui-status{display:inline-flex;align-items:center;gap:5px;color:var(--color-muted);font:600 var(--font-xs) ui-monospace}.ui-status-mark{width:15px;height:15px;display:grid;place-items:center;border:1px solid currentColor;border-radius:3px}.ui-status.online{color:#82d5aa}.ui-status.warning{color:#e5bb67}.ui-status.offline{color:#a4abb5}.ui-status.compact .ui-status-mark{width:10px;height:10px;border-radius:50%}.ui-status.compact svg{display:none}
.ui-field{display:grid;gap:4px}.ui-field-label{color:var(--color-muted);font-size:var(--font-sm)}.ui-input-wrap,.ui-field select{display:flex;align-items:center;gap:7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#111419;padding:6px 8px}.ui-field input{width:100%;min-width:0;border:0;outline:0;background:transparent}.ui-field select{color:var(--color-text)}.ui-checkbox{display:flex;align-items:center;gap:7px}.ui-tabs{display:flex;overflow:auto}.ui-tabs>button{flex:0 0 auto}.ui-tooltip{position:relative;display:inline-flex}.ui-tooltip [role=tooltip]{position:absolute;left:50%;bottom:calc(100% + 7px);z-index:var(--z-popover);transform:translateX(-50%);visibility:hidden;opacity:0;white-space:nowrap;padding:5px 7px;border:var(--border-subtle);border-radius:var(--radius-sm);background:#080a0d;color:var(--color-text);transition:opacity var(--duration-fast)}.ui-tooltip:hover [role=tooltip],.ui-tooltip:focus-within [role=tooltip]{visibility:visible;opacity:1}
.ui-popover{position:absolute;z-index:var(--z-popover);padding:10px;border:1px solid #3a424e;border-radius:var(--radius-md);background:#15191f;box-shadow:0 8px 24px #090b0ecc}.ui-dialog-backdrop{position:fixed;inset:0;z-index:var(--z-dialog);display:grid;place-items:center;background:#06080bb8}.ui-dialog{width:min(480px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;border:1px solid #3a424e;border-radius:var(--radius-md);background:var(--color-content);box-shadow:0 20px 60px #000a;padding:16px}.ui-dialog>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}.ui-dialog h2{margin:0;font-size:16px}.ui-empty{display:grid;justify-items:center;gap:8px;padding:40px;color:var(--color-muted);text-align:center}.ui-empty p{margin:0}.ui-skeleton{display:inline-block;height:12px;border-radius:3px;background:linear-gradient(90deg,#222831,#343c47,#222831);background-size:200% 100%;animation:ui-shimmer 1.4s ease infinite}.ui-toast{position:fixed;right:16px;bottom:36px;z-index:var(--z-popover);display:flex;align-items:center;gap:12px;max-width:360px;padding:10px 12px;border:var(--border-subtle);border-left:3px solid var(--color-info);border-radius:var(--radius-sm);background:#171b21;box-shadow:0 8px 24px #0008}.ui-toast.danger{border-left-color:var(--color-danger)}.ui-data-toolbar{display:flex;align-items:center;gap:6px}
@keyframes ui-spin{to{transform:rotate(360deg)}}@keyframes ui-shimmer{to{background-position:-200% 0}}
@media(prefers-reduced-motion:reduce){.ui-spinner,.ui-skeleton{animation:none}.ui-button:active{transform:none}}

13
src/ui/ui.test.tsx Normal file
View File

@ -0,0 +1,13 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { Button, Dialog, Popover, StatusIndicator } from ".";
function DialogFixture() { const [open, setOpen] = useState(false); return <><button onClick={() => setOpen(true)}></button><Dialog open={open} onOpenChange={setOpen} title="确认操作"><Button></Button></Dialog></>; }
function PopoverFixture() { const [open, setOpen] = useState(true); return <div><Popover open={open} onOpenChange={setOpen}><button></button></Popover><button></button></div>; }
describe("设计系统关键交互", () => {
it("Dialog 支持 Escape 关闭并将焦点归还触发器", () => { render(<DialogFixture />); const trigger = screen.getByRole("button", { name: "触发器" }); trigger.focus(); fireEvent.click(trigger); expect(screen.getByRole("dialog")).toBeInTheDocument(); fireEvent.keyDown(document, { key: "Escape" }); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); expect(trigger).toHaveFocus(); });
it("Popover 点击外部后关闭", () => { render(<PopoverFixture />); expect(screen.getByRole("dialog")).toBeInTheDocument(); fireEvent.pointerDown(screen.getByRole("button", { name: "外部" })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); });
it("加载态会禁用按钮,状态同时提供文本", () => { render(<><Button loading></Button><StatusIndicator status="warning" label="DEGRADED" /></>); expect(screen.getByRole("button", { name: "保存" })).toBeDisabled(); expect(screen.getByText("DEGRADED")).toBeInTheDocument(); });
});