diff --git a/.gitignore b/.gitignore index 209f0b2..fc25514 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,6 @@ apps/web/test-results/ .spec-workflow/ .omx/ optimus/ -.superpowers/ \ No newline at end of file +.superpowers/ +.claude +.playwright-mcp/ \ No newline at end of file diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md index c511af0..0b38dc7 100644 --- a/apps/web/e2e/README.md +++ b/apps/web/e2e/README.md @@ -17,9 +17,12 @@ - mailbox 创建 - inbox 读取 - admin 基本操作 +- `/design-system` 结构与视觉回归脚手架 ## 📏 约定 - 端到端测试优先验证“用户路径”,不是内部实现细节 - 当前阶段允许通过 `page.route()` 做 API mock,先保证前端壳层和交互路径稳定 - 更接近真实后端联调的 e2e 可在后续独立 staging 环境中补充 +- `design-system.spec.ts` 默认只跑结构断言;截图基线需在预览页示例稳定后,通过 `PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1` 显式开启 +- `/design-system` 现在是公开页面;相关 e2e 不要求先登录或挂后台壳层 diff --git a/apps/web/e2e/design-system.spec.ts b/apps/web/e2e/design-system.spec.ts new file mode 100644 index 0000000..a9e652d --- /dev/null +++ b/apps/web/e2e/design-system.spec.ts @@ -0,0 +1,54 @@ +import { expect, test, type Page } from "@playwright/test"; + +const snapshotUpdatesEnabled = process.env.PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS === "1"; + +async function mockPublicSession(page: Page) { + await page.route("**/auth/session", async (route) => { + await route.fulfill({ status: 401, json: { error: "not authenticated" } }); + }); +} + +async function visitDesignSystem(page: Page, theme: "light" | "dark") { + await mockPublicSession(page); + await page.addInitScript((nextTheme) => { + window.localStorage.setItem("wemail-workspace-theme", nextTheme); + document.documentElement.dataset.theme = nextTheme; + document.documentElement.style.colorScheme = nextTheme; + }, theme); + + await page.goto("/design-system"); + await expect(page.getByRole("navigation", { name: "首页导航" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Foundations" })).toBeVisible(); + await expect(page.getByRole("navigation", { name: /design system sidebar/i })).toBeVisible(); + await expect(page.getByTestId("design-system-page")).toBeVisible(); + await expect + .poll(async () => page.evaluate(() => document.documentElement.dataset.theme), { timeout: 10_000 }) + .toBe(theme); +} + +test("shows the design system page as a sidebar-driven public docsite", async ({ page }) => { + await visitDesignSystem(page, "light"); + + const sidebarButtons = page.getByRole("navigation", { name: /design system sidebar/i }).getByRole("button"); + await expect(sidebarButtons).toHaveCount(14); + await expect(page.getByText("WeMail Design System v1")).toBeVisible(); + await expect(page.getByRole("button", { name: "打开对话框" })).toBeVisible(); + await expect(page.getByRole("button", { name: "打开抽屉" })).toBeVisible(); +}); + +test.describe("design system visual regression scaffold", () => { + test.skip( + !snapshotUpdatesEnabled, + "Enable PW_UPDATE_DESIGN_SYSTEM_SNAPSHOTS=1 when the preview examples and CI screenshot environment are ready." + ); + + for (const theme of ["light", "dark"] as const) { + test(`captures the ${theme} theme shell`, async ({ page }) => { + await visitDesignSystem(page, theme); + + await expect(page.getByTestId("design-system-page")).toHaveScreenshot(`design-system-${theme}.png`, { + animations: "disabled" + }); + }); + } +}); diff --git a/apps/web/src/app/App.tsx b/apps/web/src/app/App.tsx index fa4f69c..b4ea28d 100644 --- a/apps/web/src/app/App.tsx +++ b/apps/web/src/app/App.tsx @@ -4,6 +4,7 @@ import { BrowserRouter, Navigate, useLocation } from "react-router-dom"; import { AppLayout } from "./AppLayout"; import { AppRoutes } from "./AppRoutes"; import { AuthPage } from "../pages/AuthPage"; +import { DesignSystemPage } from "../pages/DesignSystemPage"; import { WemailLoadingShell } from "../shared/WemailLoadingShell"; import { WemailToastViewport } from "../shared/WemailToastViewport"; import { useAppStore } from "./appStore"; @@ -59,6 +60,16 @@ function AppContent() { }, [location.pathname, session]); const toastViewport = ; + const isPublicDesignSystemPage = location.pathname === "/design-system"; + + if (isPublicDesignSystemPage) { + return ( + <> + {toastViewport} + + + ); + } if (auth.loadingSession) { return ( diff --git a/apps/web/src/app/AppLayout.tsx b/apps/web/src/app/AppLayout.tsx index 67627c0..de91c01 100644 --- a/apps/web/src/app/AppLayout.tsx +++ b/apps/web/src/app/AppLayout.tsx @@ -1,5 +1,5 @@ import { ReactNode, useEffect, useRef, useState } from "react"; -import { NavLink, useLocation } from "react-router-dom"; +import { NavLink, useLocation, useNavigate } from "react-router-dom"; import { Bell, ChevronDown, @@ -18,7 +18,8 @@ import { import type { SessionSummary } from "@wemail/shared"; -import { Button, ButtonLink } from "../shared/button"; +import { Button } from "../shared/button"; +import { Tabs, TabsList, TabsPanel, TabsTrigger } from "../shared/tabs"; import { WemailBrandLockup } from "../shared/WemailBrandLockup"; import type { WorkspaceRailIcon, WorkspaceShellState } from "./workspaceShell"; import type { WorkspaceTheme } from "./useWorkspaceTheme"; @@ -81,6 +82,7 @@ export function AppLayout({ children }: AppLayoutProps) { const location = useLocation(); + const navigate = useNavigate(); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const userMenuRef = useRef(null); const railScrollRef = useRef(null); @@ -108,6 +110,9 @@ export function AppLayout({ }; }, []); + const activeSecondaryRoute = + shell.secondaryNav.find((item) => item.to === location.pathname)?.to ?? shell.secondaryNav[0]?.to ?? ""; + useEffect(() => { const areas = [railScrollRef.current, mainScrollRef.current].filter(Boolean) as Array; if (areas.length === 0) return; @@ -162,19 +167,28 @@ export function AppLayout({
{shell.secondaryNav.length > 0 ? ( -
-
-
- {developerTabs.map((tab, index) => ( - - ))} + setActiveTab(Number(nextValue))} value={String(activeTab)} variant="segmented"> +
+ + {developerTabs.map((tab, index) => ( + + {tab.label} + + ))} + +
- -
- - + {developerTabs.map((tab, index) => ( + + + + + ))} +
@@ -1065,7 +947,7 @@ export function WemailLandingPage({ }) { return (
- +
diff --git a/apps/web/src/features/landing/landing.css b/apps/web/src/features/landing/landing.css index 6bf51b9..b671e9b 100644 --- a/apps/web/src/features/landing/landing.css +++ b/apps/web/src/features/landing/landing.css @@ -169,6 +169,10 @@ transition: width 500ms cubic-bezier(0.22, 1, 0.36, 1), transform 500ms cubic-bezier(0.22, 1, 0.36, 1), padding 500ms cubic-bezier(0.22, 1, 0.36, 1); } +.landing-nav-edge-control { + justify-self: end; +} + .landing-nav.is-floating { width: min(1200px, 100%); padding-top: 16px; @@ -176,7 +180,7 @@ .landing-nav-bar { display: grid; - grid-template-columns: auto minmax(0, 1fr) auto auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 24px; min-height: 80px; @@ -247,6 +251,20 @@ .landing-nav-links { justify-content: center; + gap: 32px; +} + +.landing-nav-actions { + align-items: center; + justify-self: end; + gap: 12px; + margin-right: -1px; +} + +.landing-nav-theme-toggle, +.landing-nav-mobile-toggle { + border: 1px solid var(--border) !important; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--surface) 30%, transparent); } .landing-nav-link { @@ -296,22 +314,26 @@ .landing-mobile-menu-backdrop { position: fixed; inset: 0; + z-index: 35; display: grid; place-items: stretch; padding: 88px 24px 24px; background: color-mix(in srgb, var(--bg) 90%, transparent); backdrop-filter: blur(18px); + pointer-events: auto; } .landing-mobile-menu { - display: grid; - align-content: space-between; + display: flex; + flex-direction: column; gap: 24px; + height: calc(100dvh - 112px); border-radius: 32px; border: 1px solid var(--border); background: color-mix(in srgb, var(--surface) 92%, transparent); padding: 32px 24px; box-shadow: var(--shadow-card); + pointer-events: auto; } .landing-mobile-links { @@ -331,6 +353,13 @@ .landing-mobile-actions { display: grid; gap: 12px; + margin-top: auto; +} + +.landing-mobile-theme-toggle, +.landing-mobile-theme-toggle:hover, +.landing-mobile-theme-toggle:focus-visible { + transform: none; } .landing-hero-section { @@ -895,6 +924,18 @@ margin-top: 12px; } +.landing-marquee-list-compact { + overflow: visible; +} + +.landing-marquee-track-compact { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 12px; + animation: none; +} + .landing-integration-card { flex-direction: column; align-items: flex-start; @@ -1735,6 +1776,18 @@ grid-template-columns: auto 1fr auto; } + .landing-nav-mobile-toggle { + position: static; + justify-self: end; + align-self: center; + transform: none; + } + + .landing-nav-mobile-toggle.ui-button:hover, + .landing-nav-mobile-toggle.ui-button:focus-visible { + transform: none; + } + .landing-hero-section { min-height: auto; padding-top: 124px; @@ -1777,6 +1830,10 @@ padding-inline: 16px; } + .landing-nav-mobile-toggle-tight { + margin-right: -24px; + } + .landing-nav.is-floating { padding-top: 10px; } @@ -1797,13 +1854,72 @@ min-height: calc(100dvh - 88px); } + .landing-hero-section { + padding-top: 100px; + padding-bottom: 24px; + min-height: auto; + } + + .landing-hero-copy-shell { + gap: 16px; + } + .landing-display-title, .landing-testimonial-grid blockquote p { font-size: clamp(2.2rem, 12vw, 3.8rem); } .landing-hero-title { - font-size: clamp(2.8rem, 16vw, 4.6rem); + gap: 2px; + font-size: clamp(2.45rem, 14vw, 4.1rem); + line-height: 0.92; + letter-spacing: -0.06em; + } + + .landing-hero-description { + max-width: none; + font-size: 1rem; + line-height: 1.65; + } + + .landing-hero-bottom { + gap: 16px; + } + + .landing-cta-row { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + } + + .landing-cta-row .ui-button { + width: 100%; + justify-content: center; + } + + .landing-stats-marquee { + padding-top: 20px; + overflow: visible; + } + + .landing-marquee-track, + .landing-marquee-track.reverse { + width: 100%; + display: grid; + grid-template-columns: 1fr; + gap: 12px; + animation: none; + } + + .landing-stat-chip { + width: 100%; + min-width: 0; + padding: 16px 18px; + gap: 12px; + } + + .landing-stat-chip strong { + font-size: 2rem; } .landing-stat-chip, diff --git a/apps/web/src/features/outbound/OutboundPage.tsx b/apps/web/src/features/outbound/OutboundPage.tsx index 517940d..c7c6738 100644 --- a/apps/web/src/features/outbound/OutboundPage.tsx +++ b/apps/web/src/features/outbound/OutboundPage.tsx @@ -2,7 +2,9 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useSearchParams } from "react-router-dom"; import { Button } from "../../shared/button"; -import { FormField, TextInput } from "../../shared/form"; +import { FilterBar, FilterBarActions } from "../../shared/filter-bar"; +import { FormField, SearchInput } from "../../shared/form"; +import { Page, PageBody, PageHeader, PageMain, PageSidebar, PageToolbar } from "../../shared/page-layout"; import type { OutboundHistoryItem } from "../inbox/types"; import { buildOutboundRecords, type OutboundRecord } from "./outboundMockData"; import { OutboundComposeDrawer } from "./OutboundComposeDrawer"; @@ -115,50 +117,51 @@ export function OutboundPage({ outboundHistory, hasActiveMailbox, onSendMail }: return ( <> -
+
-
-
-

邮件中心

-

发件箱

-

按发送结果回看历史、定位失败原因,并把异常 / 无匹配记录和正常外发放在同一套工作流里。

-
-
- - -
-
- -
- 发件箱搜索}> - setSearchValue(event.target.value)} - placeholder="搜索收件人 / 主题 / 发件结果" - type="search" - value={searchValue} - /> - -
- {(Object.keys(FILTER_LABELS) as OutboundFilter[]).map((filterKey) => ( - + - ))} -
-
+
+ } + description="按发送结果回看历史、定位失败原因,并把异常 / 无匹配记录和正常外发放在同一套工作流里。" + kicker="邮件中心" + title="发件箱" + /> + + + + 发件箱搜索}> + setSearchValue(event.target.value)} + placeholder="搜索收件人 / 主题 / 发件结果" + value={searchValue} + /> + + + {(Object.keys(FILTER_LABELS) as OutboundFilter[]).map((filterKey) => ( + + ))} + + + -
-
+ +

发送记录

@@ -193,9 +196,9 @@ export function OutboundPage({ outboundHistory, hasActiveMailbox, onSendMail }: ))}
-
+ -
+ {selectedRecord ? ( <>
@@ -268,9 +271,9 @@ export function OutboundPage({ outboundHistory, hasActiveMailbox, onSendMail }: ) : (

当前还没有发件记录。

)} -
-
- + + +
-
-

总密钥

-
-
- 总数 - {summary.totalKeys} -
-
-
+ -
-

活跃密钥

-
-
- 可用 - {summary.activeKeys} -
-
-
+ -
-

从未使用

-
-
- 未使用 - {summary.unusedKeys} -
-
-
+ -
-

已吊销

-
-
- 失效 - {summary.revokedKeys} -
-
-
+
diff --git a/apps/web/src/pages/DashboardPage.tsx b/apps/web/src/pages/DashboardPage.tsx index 27e167f..929c6db 100644 --- a/apps/web/src/pages/DashboardPage.tsx +++ b/apps/web/src/pages/DashboardPage.tsx @@ -13,6 +13,7 @@ import { dashboardUserRoles } from "../features/dashboard/dashboardMockData"; import { nivoTheme } from "../shared/chart"; +import { MetricCard } from "../shared/metric-card"; const GROWTH_KEYS = ["accounts", "mailboxes"] as const; const GROWTH_LABELS: Record<(typeof GROWTH_KEYS)[number], string> = { @@ -62,13 +63,16 @@ export function DashboardPage() {
{dashboardKpis.map((kpi) => ( -
-

KPI

-

{kpi.label}

- {kpi.value} - {kpi.detail} - {kpi.change} -
+ ))}
diff --git a/apps/web/src/pages/DesignSystemPage.tsx b/apps/web/src/pages/DesignSystemPage.tsx new file mode 100644 index 0000000..917466f --- /dev/null +++ b/apps/web/src/pages/DesignSystemPage.tsx @@ -0,0 +1,174 @@ +import { type CSSProperties, useEffect, useState } from "react"; + +import { type WorkspaceTheme } from "../app/appStore"; +import { PublicSiteNavigation } from "../features/landing/PublicSiteNavigation"; +import { Badge } from "../shared/badge"; +import { DesignSystemDocContent } from "./design-system/DesignSystemDocContent"; +import { DesignSystemPreviewOverlays, DesignSystemSectionList } from "./design-system/DesignSystemPreviewContent"; +import { designSystemGroups } from "./design-system/designSystemContent"; +import { PreviewActionButtons } from "./design-system/designSystemPreviewParts"; +import { designSystemSections, findDesignSystemSection } from "./design-system/designSystemSections"; +import { designSystemPageStyles, designSystemSharedStyles, resolveDesignSystemSidebarLayoutStyle } from "./design-system/designSystemStyles"; + +const DESIGN_SYSTEM_THEME_STORAGE_KEY = "wemail-design-system-preview-theme"; + +function resolveInitialPreviewTheme(): WorkspaceTheme { + if (typeof window !== "undefined") { + const storedTheme = window.localStorage.getItem(DESIGN_SYSTEM_THEME_STORAGE_KEY); + if (storedTheme === "light" || storedTheme === "dark") return storedTheme; + + if (typeof window.matchMedia === "function") { + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; + } + } + + if (typeof document !== "undefined") { + const datasetTheme = document.documentElement.dataset.theme; + if (datasetTheme === "light" || datasetTheme === "dark") return datasetTheme; + } + + return "light"; +} + +const groups = designSystemGroups; + +export function DesignSystemPage() { + const [previewTheme, setPreviewTheme] = useState(resolveInitialPreviewTheme); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const initialGroup = groups[0]; + const initialComponent = initialGroup?.components[0] ?? null; + const [activeGroupId, setActiveGroupId] = useState(initialGroup?.id ?? "foundations"); + const [activeComponentId, setActiveComponentId] = useState(initialComponent?.id ?? null); + const [sidebarLayoutStyle, setSidebarLayoutStyle] = useState(resolveDesignSystemSidebarLayoutStyle); + + const activeGroup = groups.find((group) => group.id === activeGroupId) ?? groups[0]; + const activeComponent = activeGroup?.components.find((component) => component.id === activeComponentId) ?? activeGroup?.components[0] ?? null; + + function togglePreviewTheme() { + setPreviewTheme((current) => (current === "dark" ? "light" : "dark")); + } + + function handleSelectComponent(groupId: string, componentId: string) { + setActiveGroupId(groupId); + setActiveComponentId(componentId); + } + + useEffect(() => { + const previousTheme = document.documentElement.dataset.theme; + const previousColorScheme = document.documentElement.style.colorScheme; + + document.documentElement.dataset.theme = previewTheme; + document.documentElement.style.colorScheme = previewTheme; + window.localStorage.setItem(DESIGN_SYSTEM_THEME_STORAGE_KEY, previewTheme); + + return () => { + if (previousTheme === "light" || previousTheme === "dark") { + document.documentElement.dataset.theme = previousTheme; + } else { + delete document.documentElement.dataset.theme; + } + + document.documentElement.style.colorScheme = previousColorScheme; + }; + }, [previewTheme]); + + useEffect(() => { + function handleResize() { + setSidebarLayoutStyle(resolveDesignSystemSidebarLayoutStyle()); + } + + handleResize(); + window.addEventListener("resize", handleResize); + + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + return ( +
+ +
+
+
+

+ WeMail Design System v1 +

+
+
+ {`${groups.length} groups`} + {`${designSystemSections.length} sections`} + /design-system +
+
+ {previewTheme === "dark" ? "深色模式" : "浅色模式"} + setIsDialogOpen(true)} onOpenDrawer={() => setIsDrawerOpen(true)} /> +
+
+
+
+ +
+ + +
+ {activeComponent ? ( + <> + findDesignSystemSection(sectionId).title)} + /> + + + ) : null} +
+
+ + setIsDialogOpen(false)} + onCloseDrawer={() => setIsDrawerOpen(false)} + /> +
+
+ ); +} diff --git a/apps/web/src/pages/SystemProfilePage.tsx b/apps/web/src/pages/SystemProfilePage.tsx index e559bb5..adbced8 100644 --- a/apps/web/src/pages/SystemProfilePage.tsx +++ b/apps/web/src/pages/SystemProfilePage.tsx @@ -1,5 +1,8 @@ +import { Avatar } from "../shared/avatar"; import { Button } from "../shared/button"; import { FormField, RadioGroupField, SelectInput, TextInput, TextareaInput } from "../shared/form"; +import { KVList } from "../shared/kv-list"; +import { Page, PageHeader } from "../shared/page-layout"; type SystemProfilePageProps = { sessionSummary: { @@ -11,18 +14,16 @@ type SystemProfilePageProps = { export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) { return ( -
+
-
-

账号资料

-

你的账户信息

-

集中维护你的展示身份与基础资料,保持对外沟通时的一致性。

-
+
- +
WeMail Admin {sessionSummary.email} @@ -41,14 +42,12 @@ export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) {
-
- 角色 - {sessionSummary.role} -
-
- 创建时间 - {sessionSummary.createdAtLabel} -
+
@@ -59,11 +58,11 @@ export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) {
-
-

使用偏好

-

按你的工作方式来调整界面

-

这些设置会影响你进入 WeMail 后默认看到的节奏与信息密度。

-
+
-
-

安全与会话

-

管理密码和当前登录状态

-

优先显示你当前的会话信息,并把高风险操作收拢到同一个动作区。

-
+
@@ -156,6 +155,6 @@ export function SystemProfilePage({ sessionSummary }: SystemProfilePageProps) {
-
+ ); } diff --git a/apps/web/src/pages/UsersGlobalSettingsPage.tsx b/apps/web/src/pages/UsersGlobalSettingsPage.tsx index 57b7639..26ef214 100644 --- a/apps/web/src/pages/UsersGlobalSettingsPage.tsx +++ b/apps/web/src/pages/UsersGlobalSettingsPage.tsx @@ -7,6 +7,7 @@ import { InvitePanel } from "../features/admin/InvitePanel"; import { MailboxOversightPanel } from "../features/admin/MailboxOversightPanel"; import { QuotaPanel } from "../features/admin/QuotaPanel"; import type { InviteSummary } from "../features/admin/types"; +import { Page, PageHeader, PageMain } from "../shared/page-layout"; type UsersGlobalSettingsPageProps = { adminUsers: UserSummary[]; @@ -34,16 +35,16 @@ export function UsersGlobalSettingsPage({ onToggleFeatures }: UsersGlobalSettingsPageProps) { return ( -
+
-
-

用户设置

-

全局控制

-

集中管理邀请码、系统级配额、全局功能开关和邮箱总览。

-
+
-
+ -
-
+ + ); -} \ No newline at end of file +} diff --git a/apps/web/src/pages/UsersListPage.tsx b/apps/web/src/pages/UsersListPage.tsx index 07acbc3..2279586 100644 --- a/apps/web/src/pages/UsersListPage.tsx +++ b/apps/web/src/pages/UsersListPage.tsx @@ -3,8 +3,11 @@ import { useState, type FormEvent } from "react"; import type { QuotaSummary, UserSummary } from "@wemail/shared"; import { Button } from "../shared/button"; -import { CheckboxField, FormField, SelectInput, TextInput } from "../shared/form"; +import { Badge } from "../shared/badge"; +import { FilterBar } from "../shared/filter-bar"; +import { CheckboxField, FormField, SearchInput, SelectInput } from "../shared/form"; import { OverlayDrawer } from "../shared/overlay"; +import { Page, PageBody, PageHeader, PageMain, PageSidebar, PageToolbar } from "../shared/page-layout"; import { Table, TableBody, @@ -87,126 +90,141 @@ export function UsersListPage({ } return ( -
+
-
-
-

用户中心

-
-
- -
-
- -
- 搜索用户}> - onSearchChange(event.target.value)} - placeholder="搜索邮箱或显示名" - value={searchValue} - /> - - - 角色筛选}> - onRoleFilterChange(event.target.value as UsersRoleFilter)} value={roleFilter}> - - - - - - - 状态筛选}> - onStatusFilterChange(event.target.value as UsersStatusFilter)} value={statusFilter}> - - - - -
+ + +
+ } + kicker="用户中心" + /> + + + + 搜索用户}> + onSearchChange(event.target.value)} + placeholder="搜索邮箱或显示名" + value={searchValue} + /> + + + 角色筛选}> + onRoleFilterChange(event.target.value as UsersRoleFilter)} value={roleFilter}> + + + + + + + 状态筛选}> + onStatusFilterChange(event.target.value as UsersStatusFilter)} value={statusFilter}> + + + + + + -
-
-
-

用户列表

+ + +
+
+

用户列表

+
-
- - {selectedCount > 0 ? ( -
-
-
-

批量操作

-

已选择 {selectedCount} 个用户

-
-
- - - -
+ + {selectedCount > 0 ? ( +
+ + + + +
+ } + kicker="批量操作" + title={`已选择 ${selectedCount} 个用户`} + /> +
+ ) : null} + + + + + + + 选择全部用户} + onChange={toggleSelectAll} + /> + + 用户 + 邮箱 + 角色 + 创建时间 + 状态 + + 操作 + + + + + {visibleUsers.map((user) => { + const isSelected = selectedUserIds.includes(user.id); + + return ( + + + 选择用户 {user.email}} + onChange={() => toggleUserSelection(user.id)} + /> + + + {buildDisplayName(user.email)} + + {user.email} + {formatRole(user.role)} + {user.createdAt.slice(0, 10)} + + 正常 + + + + + + ); + })} + +
+
+ + + +
+

筛选摘要

+
+
可见用户:{visibleUsers.length}
+
已选用户:{selectedCount}
+
角色筛选:{roleFilter === "all" ? "全部" : formatRole(roleFilter)}
- ) : null} - - - - - - - 选择全部用户} - onChange={toggleSelectAll} - /> - - 用户 - 邮箱 - 角色 - 创建时间 - 状态 - - 操作 - - - - - {visibleUsers.map((user) => { - const isSelected = selectedUserIds.includes(user.id); - - return ( - - - 选择用户 {user.email}} - onChange={() => toggleUserSelection(user.id)} - /> - - - {buildDisplayName(user.email)} - - {user.email} - {formatRole(user.role)} - {user.createdAt.slice(0, 10)} - - 正常 - - - - - - ); - })} - -
-
-
+ + {selectedUser ? ( ) : null} - + ); } diff --git a/apps/web/src/pages/design-system/DesignSystemDocContent.tsx b/apps/web/src/pages/design-system/DesignSystemDocContent.tsx new file mode 100644 index 0000000..424215f --- /dev/null +++ b/apps/web/src/pages/design-system/DesignSystemDocContent.tsx @@ -0,0 +1,161 @@ +import type { DesignSystemApiField, DesignSystemCodeSample, DesignSystemComponentDoc, DesignSystemDocSection } from "./designSystemContent"; +import { designSystemDocStyles, designSystemSharedStyles } from "./designSystemStyles"; + +interface DesignSystemDocContentProps { + componentDoc: DesignSystemComponentDoc; + groupTitle: string; + sectionTitles: string[]; +} + +const COMPONENT_SECTION_ORDER = ["真实示例", "API 接口", "使用说明", "设计规范"] as const; + +type ComponentSectionTitle = (typeof COMPONENT_SECTION_ORDER)[number]; + +function renderParagraphs(paragraphs: string[]) { + return ( +
+ {paragraphs.map((paragraph) => ( +

+ {paragraph} +

+ ))} +
+ ); +} + +function getSectionBodyMap(docSections?: DesignSystemDocSection[]): Map { + return new Map((docSections ?? []).map((section) => [section.title, section.body])); +} + +function renderCodeSamples(codeSamples: DesignSystemCodeSample[]) { + return ( +
+ {codeSamples.map((sample) => ( +
+

{sample.title}

+
+            {sample.code}
+          
+
+ ))} +
+ ); +} + +function renderApiTable(apiFields: DesignSystemApiField[]) { + return ( +
+ + + + + + + + + + + {apiFields.map((field) => ( + + + + + + + ))} + +
proptypedefaultdescription
{field.prop} + {field.type} + + {field.defaultValue} + {field.description}
+
+ ); +} + +function getComponentSections(componentDoc: DesignSystemComponentDoc, sectionTitles: string[]): Array<{ title: ComponentSectionTitle; body: string[] }> { + const sectionBodyMap = getSectionBodyMap(componentDoc.docSections); + const designNoteBodies = (componentDoc.docSections ?? []) + .filter((section) => !["适用场景", "不适用场景", "状态与变体", "设计规范"].includes(section.title)) + .flatMap((section) => section.body); + + const usageBody = [ + componentDoc.summary, + ...(sectionBodyMap.get("适用场景") ?? ["当前文档内容仍在补齐中,现阶段请先结合真实示例确认适用场景。"]), + ...(sectionBodyMap.get("不适用场景") ?? ["当当前任务只需要文本跳转或静态信息承载时,应优先考虑更轻量的原语。"]) + ]; + + const fallbackByTitle: Record = { + "真实示例": ["下方会继续保留该组件关联的 live preview,用来验证真实交互与文档说明是否一致。"], + "API 接口": [ + "API 表结构会在后续任务中补齐;当前阶段先固定阅读顺序与占位区块。", + componentDoc.codeSamples?.length ? "当前可先参考下方代码示例了解已接入的调用方式。" : "当前组件的调用方式将在后续任务中补充为结构化 API 表。" + ], + "使用说明": usageBody, + "设计规范": + sectionBodyMap.get("设计规范") ?? + sectionBodyMap.get("状态与变体") ?? + (sectionTitles.length + ? [`当前页面优先覆盖 ${sectionTitles.join("、")} 相关的状态与变体预览。`] + : designNoteBodies.length + ? designNoteBodies + : ["设计规范会持续沉淀到统一模板中,避免组件说明散落在页面实现里。"]) + }; + + return COMPONENT_SECTION_ORDER.map((title) => ({ + title, + body: fallbackByTitle[title] + })); +} + +export function DesignSystemDocContent({ componentDoc, groupTitle, sectionTitles }: DesignSystemDocContentProps) { + const sections = getComponentSections(componentDoc, sectionTitles); + + return ( +
+
+

{groupTitle} / Component

+

{componentDoc.title}

+ {componentDoc.chineseTitle} +
+ {sectionTitles.map((sectionTitle) => ( + + {sectionTitle} + + ))} +
+
+
+ {sections.map((section) => { + const isExampleSection = section.title === "真实示例"; + const isGuidanceSection = section.title === "API 接口" || section.title === "设计规范"; + + return ( +
+

{section.title}

+ {section.title === "API 接口" && componentDoc.api?.length ? renderApiTable(componentDoc.api) : renderParagraphs(section.body)} +
+ ); + })} + {componentDoc.codeSamples?.length ? ( +
+

代码示例

+ {renderCodeSamples(componentDoc.codeSamples)} +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/pages/design-system/DesignSystemPreviewContent.tsx b/apps/web/src/pages/design-system/DesignSystemPreviewContent.tsx new file mode 100644 index 0000000..d448125 --- /dev/null +++ b/apps/web/src/pages/design-system/DesignSystemPreviewContent.tsx @@ -0,0 +1,143 @@ +import { Alert } from "../../shared/alert"; +import { Button } from "../../shared/button"; +import { SearchInput } from "../../shared/form"; +import { KVList } from "../../shared/kv-list"; +import { OverlayDialog, OverlayDrawer } from "../../shared/overlay"; +import { Text } from "../../shared/typography"; +import type { DesignSystemSectionId } from "./designSystemContent"; +import { findDesignSystemSection } from "./designSystemSections"; +import { designSystemExampleStyles, designSystemPageStyles, designSystemSharedStyles } from "./designSystemStyles"; + +interface DesignSystemSectionListProps { + sectionIds: DesignSystemSectionId[]; +} + +interface DesignSystemPreviewOverlaysProps { + isDialogOpen: boolean; + isDrawerOpen: boolean; + onCloseDialog: () => void; + onCloseDrawer: () => void; +} + +export function DesignSystemSectionList({ sectionIds }: DesignSystemSectionListProps) { + return sectionIds.map((sectionId) => { + const section = findDesignSystemSection(sectionId); + + return ( +
+
+

{section.sprint}

+
+

{section.title}

+ {section.chineseTitle} +

+ {section.summary} +

+
+
+ {section.primitives.slice(0, 4).map((item) => ( + + {item} + + ))} +
+
+
+

Coverage

+
    + {section.coverage.map((item) => ( +
  • {item}
  • + ))} +
+
+
+

Regression checklist

+
    + {section.checklist.map((item) => ( +
  • {item}
  • + ))} +
+
+
+
+
+ {section.preview} +
+
+ ); + }); +} + +export function DesignSystemPreviewOverlays({ + isDialogOpen, + isDrawerOpen, + onCloseDialog, + onCloseDrawer +}: DesignSystemPreviewOverlaysProps) { + return ( + <> + {isDialogOpen ? ( + + + + + } + onClose={onCloseDialog} + title="Dialog live preview" + > +
+ 按 `Tab` 循环焦点,按 `Esc` 或点击遮罩关闭。 + + + 当前弹层已通过 shared layer portal 挂载到 body。 + +
+
+ ) : null} + + {isDrawerOpen ? ( + +
+ 这里展示的是统一抽屉壳层,而不是业务页面专属样式。 + +
+ +
+
+
+ ) : null} + + ); +} diff --git a/apps/web/src/pages/design-system/designSystemContent.ts b/apps/web/src/pages/design-system/designSystemContent.ts new file mode 100644 index 0000000..ace5153 --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemContent.ts @@ -0,0 +1,1098 @@ +export const DESIGN_SYSTEM_SECTION_IDS = [ + "foundations", + "color-theme", + "layout-spacing", + "elevation-radius", + "typography-content", + "buttons-actions", + "form-inputs", + "selection-controls", + "navigation-wayfinding", + "surfaces-cards", + "data-display", + "feedback-status", + "overlays-utilities" +] as const; + +export type DesignSystemSectionId = (typeof DESIGN_SYSTEM_SECTION_IDS)[number]; + +export interface DesignSystemDocSection { + title: string; + body: string[]; +} + +export interface DesignSystemCodeSample { + title: string; + code: string; +} + +export interface DesignSystemApiField { + prop: string; + type: string; + defaultValue: string; + description: string; +} + +export interface DesignSystemComponentDoc { + id: string; + title: string; + chineseTitle: string; + summary: string; + sectionIds: DesignSystemSectionId[]; + docSections?: DesignSystemDocSection[]; + codeSamples?: DesignSystemCodeSample[]; + api?: DesignSystemApiField[]; +} + +export interface DesignSystemGroupDoc { + id: string; + title: string; + chineseTitle: string; + summary: string; + overviewDescription: string; + sectionIds: DesignSystemSectionId[]; + docSections?: DesignSystemDocSection[]; + components: DesignSystemComponentDoc[]; +} + +export const designSystemGroups: DesignSystemGroupDoc[] = [ + { + id: "foundations", + title: "Foundations", + chineseTitle: "基础层", + summary: "统一设计 token、主题、布局节奏和表面层级,作为所有共享原语的视觉地基。", + overviewDescription: "Design tokens、预览地图、文档入口与视觉回归基线都会先在这里校准,后续组件都只复用这套基础语言。", + sectionIds: ["foundations", "color-theme", "layout-spacing", "elevation-radius"], + docSections: [ + { + title: "适用范围", + body: [ + "Foundations 用来集中说明设计 token、主题切换、布局节奏和表面层级,作为所有共享原语的统一视觉起点。", + "当页面需要定义新的颜色、间距或容器层级时,应先回到这一组基础说明确认是否已经存在可复用规范。" + ] + }, + { + title: "适用场景", + body: [ + "用于统一解释颜色、间距、圆角、阴影和页面骨架的来源,帮助设计稿、实现代码和文档站保持同一套视觉语言。", + "当团队准备新增共享组件、重排页面层级或扩展主题时,应先确认这一组基础规范是否已经覆盖需求。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把业务规则、页面专属文案或一次性视觉修饰写进 Foundations;这些内容应留在具体组件或业务页面文档中。", + "不要绕过 token 直接在页面里临时定义颜色、间距或阴影,否则会让设计系统失去统一约束。" + ] + }, + { + title: "状态与变体", + body: [ + "当前首批基础层主要覆盖 color theme、layout spacing、elevation radius 三类文档块,分别负责主题、节奏和表面层级。", + "light / dark 主题、页面容器密度和 surface 层级都应视为基础变体,由共享 token 控制而不是由业务组件各自分叉。" + ] + }, + { + title: "交互示例", + body: [ + "右侧预览会同时展示 token 清单、主题卡片和基础布局样例,作为设计评审与视觉回归的共同参考。", + "如果某个组件在不同主题下表现不一致,应先回到 Foundations 预览确认问题来自 token 还是具体组件实现。" + ] + }, + { + title: "代码片段", + body: [ + "示例片段:import \"./tokens.css\"; import \"./primitives.css\"; 页面与组件都只消费已经定义好的 CSS variables。", + "示例片段:const surfaceStyle = { borderRadius: \"var(--radius-lg)\", boxShadow: \"var(--shadow-sm)\" }; 用统一 token 映射视觉层级。" + ] + }, + { + title: "设计规范", + body: [ + "基础层文档需要和 tokens、README、CHANGELOG 保持同一套命名,避免组件页和样式变量出现双重口径。", + "新增组件只能复用这里已经定义的主题、间距与 elevation 语言,不在业务页单独引入新的视觉档位。" + ] + }, + { + title: "维护约束", + body: [ + "基础层变更需要同步检查设计系统首页、共享样式文件和对应文档,避免只更新其中一个入口。", + "任何新增 token 都应回答它解决了哪一类复用问题,而不是只为当前页面补一个临时值。" + ] + } + ], + components: [ + { + id: "design-tokens", + title: "Design tokens", + chineseTitle: "设计令牌", + summary: "品牌色、语义色、间距、圆角和阴影的统一定义。", + sectionIds: ["foundations", "color-theme", "layout-spacing", "elevation-radius"], + api: [ + { + prop: "scope", + type: '"brand" | "semantic" | "spacing" | "radius" | "elevation"', + defaultValue: '"brand"', + description: "标记当前 token 所属的设计域,帮助页面按主题、间距和表面层级组织说明。" + }, + { + prop: "varName", + type: "string", + defaultValue: '"--brand-500"', + description: "对应共享样式里实际消费的 CSS custom property 名称。" + }, + { + prop: "usage", + type: "string", + defaultValue: '"component surfaces"', + description: "说明这个 token 在按钮、卡片、表格或页面容器中的典型落点。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于统一记录品牌色、语义色、间距、圆角和阴影等基础 token,保证页面与共享原语使用同一套视觉变量。", + "当团队准备新增颜色档位、页面节奏或表面层级时,应先确认是否可以直接复用现有 token。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把业务字段、页面专属文案或一次性视觉修饰包装成 design token;这些内容应留在具体组件或页面实现中。", + "不要绕过共享变量在业务页面直接手写颜色、阴影或间距,否则文档与实现会很快失去同步。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览重点覆盖 brand、semantic、spacing、radius 和 elevation 五类 token,分别对应主题、节奏与表面层级。", + "light / dark 主题切换只应替换 token 值,不应要求组件层额外维护一套独立样式。" + ] + }, + { + title: "交互示例", + body: [ + "右侧真实示例会同时展示色板、主题卡片与 token 行项目,帮助设计评审快速确认命名与视觉映射是否一致。", + "如果某个组件在不同主题下观感异常,应先回到 design tokens 检查语义色和表面层级映射。" + ] + }, + { + title: "设计规范", + body: [ + "新增 token 需要能解释跨页面复用价值,而不是只为当前一个业务块补临时变量。", + "token 名称应与 shared styles、README 和文档站入口保持一致,避免出现多套别名。" + ] + } + ] + }, + { + id: "page-layout", + title: "PageLayout", + chineseTitle: "页面布局", + summary: "页面头部、工具栏、主内容区和侧栏的组合骨架。", + sectionIds: ["layout-spacing"], + api: [ + { + prop: "Page", + type: "layout root", + defaultValue: "required", + description: "提供页面整体容器与垂直节奏,承接 header、body 和局部分区。" + }, + { + prop: "PageBody", + type: '"with-sidebar" body region', + defaultValue: '"main only"', + description: "定义主内容区与侧栏的排布关系,用于列表页、详情页和带过滤器的工作台页面。" + }, + { + prop: "PageToolbar", + type: "toolbar region", + defaultValue: "optional", + description: "承接筛选条、批量操作和摘要信息,避免业务页面重复拼装工具栏骨架。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于管理后台页面、设置页和列表详情页的页面骨架,统一 header、toolbar、main 与 sidebar 的关系。", + "当页面需要稳定的主次区域,而不是一次性自由摆放卡片时,应优先使用 PageLayout 原语。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把单个内容卡片或弹层局部结构包装成 PageLayout;它只处理页面级骨架,不负责局部信息块样式。", + "如果页面只有一个简短正文区域,也不需要为了形式完整强行引入 sidebar 或 toolbar 容器。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖 header + toolbar + main + sidebar 的标准工作台布局,以及无侧栏时的紧凑编排节奏。", + "布局密度应通过 spacing token 与容器组合控制,而不是为每个页面重新手写 margin 和 max-width。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例展示了页头操作、筛选条和双栏正文的组合,便于验证 PageLayout 与 Card、FilterBar 的拼接边界。", + "切换不同页面时应保持相同的 toolbar 与 sidebar 节奏,这样用户能更快建立导航预期。" + ] + }, + { + title: "设计规范", + body: [ + "页面级布局优先依赖 Page、PageHeader、PageBody、PageMain、PageSidebar 等原语,不在业务页面重复定义骨架。", + "新增区域前先确认它属于 header、toolbar、main 还是 sidebar,避免内容层与布局层混在一起。" + ] + } + ] + } + ] + }, + { + id: "content-actions", + title: "Content & Actions", + chineseTitle: "内容与动作", + summary: "承接排版、按钮、卡片和数据展示组件,负责页面中的主要内容编排。", + overviewDescription: "这组组件负责把设计系统真正落到业务页面,包括标题层级、主次动作、卡片容器和数据摘要。", + sectionIds: ["typography-content", "buttons-actions", "surfaces-cards", "data-display"], + components: [ + { + id: "typography", + title: "Typography", + chineseTitle: "排版", + summary: "统一标题、正文、说明、代码和快捷键标签的语义层级。", + sectionIds: ["typography-content"], + api: [ + { + prop: "as", + type: '"h1" | "h2" | "h3" | "p" | "span"', + defaultValue: '"p"', + description: "控制排版原语输出的语义标签,保证层级结构与无障碍阅读顺序一致。" + }, + { + prop: "size", + type: '"hero" | "title-lg" | "title-md" | "body" | "caption"', + defaultValue: '"body"', + description: "映射共享排版 token,控制标题、正文和辅助文案的视觉层级。" + }, + { + prop: "tone", + type: '"default" | "muted" | "brand"', + defaultValue: '"default"', + description: "为正文、说明文字或强调内容附加统一的文本色语义。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于统一标题、正文、说明、代码与快捷键标签的层级,让页面在高密度内容下仍然保持清晰阅读节奏。", + "当页面需要表达主标题、区块说明或辅助提示时,应优先使用共享排版原语,而不是直接手写字号和行高。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 Typography 当作布局容器使用;它负责文本语义和层级,不负责卡片、栅格或页面骨架。", + "不要为了特殊视觉效果跳过现有排版档位单独写字体大小,否则标题与正文很难跨页面保持一致。" + ] + }, + { + title: "状态与变体", + body: [ + "当前示例覆盖 hero、section title、正文、caption、code、kbd 与 muted copy 等高频文本形态。", + "不同文本强调程度应通过 size 与 tone 组合表达,而不是额外定义不透明度或临时色值。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例会同时展示标题层级、说明文字、代码片段和快捷键标签,帮助团队快速检查同页阅读节奏。", + "在表单、卡片和提示组件里复用同一套排版原语,可以减少页面之间的字体风格漂移。" + ] + }, + { + title: "设计规范", + body: [ + "先确定语义标签,再选择视觉档位;不要为了视觉效果牺牲正确的 heading 或 paragraph 结构。", + "正文、说明、代码与快捷键标签都应复用共享排版 token,避免页面各自定义字体系统。" + ] + } + ] + }, + { + id: "button", + title: "Button", + chineseTitle: "按钮", + summary: "覆盖主要、次要、轻量、危险和 icon-only 等动作样式。", + sectionIds: ["buttons-actions"], + codeSamples: [ + { + title: "主次操作组合", + code: `\n` + }, + { + title: "危险与加载状态", + code: `\n` + } + ], + api: [ + { + prop: "variant", + type: '"primary" | "secondary" | "subtle" | "ghost" | "danger" | "icon"', + defaultValue: '"primary"', + description: "定义按钮的视觉层级与语义强度。" + }, + { + prop: "size", + type: '"xs" | "sm" | "md" | "lg"', + defaultValue: '"md"', + description: "控制按钮的高度、内边距和文本密度。" + }, + { + prop: "isLoading", + type: "boolean", + defaultValue: "false", + description: "在异步提交中显示加载态并阻止重复触发。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于页面主次操作、确认提交、轻量辅助动作以及需要明确点击反馈的交互入口。", + "当界面需要把一个动作表达成清晰的按钮层级,而不是纯文本链接时,优先使用 Button。" + ] + }, + { + title: "不适用场景", + body: [ + "不要用 Button 承担纯导航文本、正文内联引用或不需要强调的次级跳转;这类场景更适合链接样式。", + "不要在同一个操作区并列多个 primary 按钮,也不要把危险操作伪装成普通次按钮。" + ] + }, + { + title: "状态与变体", + body: [ + "当前文档站优先覆盖 primary、secondary、subtle、ghost、danger、icon-only 与 loading 几类高频动作变体。", + "同一个动作组里应保留明确主次关系,避免在同一区块并列多个视觉上同权重的主按钮。" + ] + }, + { + title: "交互示例", + body: [ + "推荐同时展示一个主操作按钮、一个次操作按钮和一个危险操作按钮,帮助评审动作层级是否清晰。", + "需要展示加载态时,应让按钮保留原位置并明确 loading label,避免用户误以为点击没有生效。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例: 组合展示主次操作。", + "静态示例: 用于异步提交中的禁用反馈。" + ] + }, + { + title: "设计规范", + body: [ + "按钮文案应直接表达结果或下一步动作,避免使用模糊词汇如“确认一下”“继续处理”。", + "icon-only 按钮必须补充 aria-label,危险操作优先使用 danger 变体并与普通动作拉开视觉距离。" + ] + } + ] + }, + { + id: "card", + title: "Card", + chineseTitle: "卡片", + summary: "统一信息分组、数据容器和空状态承载方式。", + sectionIds: ["surfaces-cards"], + api: [ + { + prop: "variant", + type: '"default" | "data" | "status"', + defaultValue: '"default"', + description: "定义卡片承载普通信息、数据摘要还是状态提示的视觉结构。" + }, + { + prop: "tone", + type: '"default" | "brand" | "warning" | "info"', + defaultValue: '"default"', + description: "控制卡片在品牌、提醒或信息语义下的强调方式。" + }, + { + prop: "padding", + type: '"sm" | "md" | "lg"', + defaultValue: '"md"', + description: "统一 header、body、footer 的内边距密度,避免页面局部手写 spacing。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接一组相关信息、摘要数据或局部操作,让页面在高密度信息里仍然保留清晰分区。", + "当内容需要共享同一标题、正文和底部动作容器时,优先使用 Card,而不是在页面里手写边框盒子。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把整页布局直接塞进单个 Card;页面级栅格、分栏和主次区域仍应由 PageLayout 或 section 容器承担。", + "不要为了制造层级而无节制堆叠阴影卡片,连续信息块更适合通过间距和标题分组来解决。" + ] + }, + { + title: "状态与变体", + body: [ + "常见卡片变体包括基础信息卡、带操作 footer 的任务卡、带空状态说明的容器卡以及数据摘要卡。", + "是否需要 header、body、footer 应由内容结构决定,而不是为了视觉完整强行补齐三段式。" + ] + }, + { + title: "交互示例", + body: [ + "适合展示一个含标题与正文的基础卡片,再补一个带底部操作区的任务卡,帮助团队校验结构边界。", + "如果卡片内含按钮、标签或复制工具,应验证这些动作不会把卡片误导成整块可点击容器。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:域名配额展示剩余可用量与说明。", + "静态示例:摘要信息。" + ] + }, + { + title: "设计规范", + body: [ + "卡片只负责建立信息边界,不应该再承担页面级布局职责;页面编排仍由 PageLayout 和 section 容器控制。", + "同一视图中的卡片层级应依赖统一的 radius 与 elevation token,不为单个业务块临时定义新的阴影或圆角。" + ] + } + ] + }, + { + id: "data-display", + title: "Data display", + chineseTitle: "数据展示", + summary: "表格、键值列表、头像和统计卡等摘要展示原语。", + sectionIds: ["data-display"], + api: [ + { + prop: "TableContainer.variant", + type: '"default" | "liquid"', + defaultValue: '"default"', + description: "控制表格容器的表面风格,适配常规列表与更轻盈的数据面板。" + }, + { + prop: "KVList.items", + type: "Array<{ key: string; value: ReactNode; hint?: string; action?: ReactNode }>", + defaultValue: "[]", + description: "定义键值列表的字段、提示与附加操作,用于环境信息和配置摘要。" + }, + { + prop: "MetricCard.tone", + type: '"default" | "hero"', + defaultValue: '"default"', + description: "区分普通 KPI 卡与强调型指标卡,让关键数据有更明确的视觉层级。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接表格、键值列表、头像身份块和指标摘要卡,帮助页面稳定展示结构化信息。", + "当页面需要在不依赖真实接口的情况下校验数据密度与层级时,应优先复用这组展示原语。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 Data display 组件当作输入控件或导航容器使用;它们负责展示结果,不负责采集或切换。", + "如果内容只是简短正文说明,没有结构化字段或指标层级,也不需要强行包装成数据展示原语。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖 Avatar、KVList、MetricCard 和 Table shell,分别对应身份摘要、键值信息、核心指标和列表结果。", + "数据展示组件的层级应依赖统一的 badge、caption 与 container tone,而不是在业务页额外发明新的强调样式。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例同时摆放头像组、键值列表、指标卡和紧凑表格,方便对比不同数据密度下的节奏是否协调。", + "列表里的状态展示可以直接复用 Badge、Tag 等反馈原语,避免数据组件内部再重复定义语义色。" + ] + }, + { + title: "设计规范", + body: [ + "优先保证字段标签、指标标题和状态色在不同数据组件之间语义一致,再考虑局部强调样式。", + "如果一个数据块需要解释、动作和状态提示,应通过 Card、Badge、Alert 等原语组合,而不是让单一展示组件承担所有职责。" + ] + } + ] + } + ] + }, + { + id: "forms-navigation-feedback", + title: "Forms, Navigation & Feedback", + chineseTitle: "表单、导航与反馈", + summary: "覆盖输入、选择、路径导航、状态反馈和系统提示等高频交互组件。", + overviewDescription: "输入控件、路径组件和反馈组件会一起定义页面交互密度,让用户既能完成操作,也能获得明确状态回馈。", + sectionIds: ["form-inputs", "selection-controls", "navigation-wayfinding", "feedback-status"], + components: [ + { + id: "search-input", + title: "SearchInput", + chineseTitle: "搜索输入框", + summary: "统一搜索、筛选和快速清除交互。", + sectionIds: ["form-inputs"], + api: [ + { + prop: "placeholder", + type: "string", + defaultValue: '"搜索…"', + description: "说明搜索对象和预期输入内容,帮助用户快速理解筛选范围。" + }, + { + prop: "aria-label", + type: "string", + defaultValue: '"搜索"', + description: "为仅含图标或弱化 label 的搜索场景补充明确的无障碍名称。" + }, + { + prop: "value / defaultValue", + type: "string", + defaultValue: '""', + description: "支持受控与非受控输入,适配即时搜索与初始化筛选值两类场景。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于列表页、筛选条和弹层内的快速搜索入口,统一前缀图标、占位文案与清除交互。", + "当用户需要频繁按关键字缩小结果范围时,应优先使用 SearchInput,而不是普通 TextInput。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 SearchInput 用作需要复杂格式校验的表单字段,例如邮箱、密码或 API Key 输入;这类场景应使用普通输入控件。", + "如果页面没有即时筛选或查询反馈,单独放一个搜索框会制造误导,应该先明确搜索对象和结果承载区。" + ] + }, + { + title: "状态与变体", + body: [ + "首批文档优先覆盖默认搜索态、已输入可清除态以及与筛选条并列时的紧凑布局态。", + "是否显示清除按钮、是否带前缀图标、是否放进 FilterBar,都是 SearchInput 的常见组合变体。" + ] + }, + { + title: "交互示例", + body: [ + "典型示例是账号列表页顶部搜索框:输入关键字后立即过滤列表,并支持一键清除恢复默认结果。", + "当搜索与标签、状态等筛选器联动时,应保证控件同行对齐,并让占位文案说明搜索对象。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:。", + "静态示例:。" + ] + }, + { + title: "设计规范", + body: [ + "搜索框应直接表达可搜索对象,例如账号、地址或创建人,避免只写泛化的“请输入关键字”。", + "如果搜索会和其他筛选联动,建议放进 FilterBar 组合里保持同一行节奏。" + ] + } + ] + }, + { + id: "multi-select", + title: "MultiSelect", + chineseTitle: "多选器", + summary: "统一标签筛选、权限筛选和组合条件选择。", + sectionIds: ["form-inputs"], + api: [ + { + prop: "options", + type: "Array<{ label: string; value: string }>", + defaultValue: "[]", + description: "定义可选标签、权限或筛选条件,是多选器渲染候选项的基础数据。" + }, + { + prop: "defaultValue", + type: "string[]", + defaultValue: "[]", + description: "用于带初始筛选值的列表页,让多选器在首屏就呈现当前过滤结果。" + }, + { + prop: "aria-label", + type: "string", + defaultValue: '"多选器"', + description: "为紧凑筛选条或无可视标签场景补充清晰的控件名称。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于标签筛选、权限筛选和组合条件选择,让用户可以在同一控件内快速选择多个维度。", + "当页面需要展示已选状态并允许继续增删条件时,应优先使用 MultiSelect,而不是多个离散 checkbox。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把只有两三个永久可见选项的场景强行做成 MultiSelect;这类场景更适合直接使用 checkbox 组。", + "如果筛选条件之间互斥,只能单选,也不应使用多选器,应改用 Select 或 Radio。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖默认空态与带默认值的已选态,重点验证标签筛选与组合条件在同一筛选条中的节奏。", + "多选器的展示重点是已选项反馈、候选列表滚动与紧凑布局对齐,不额外承担复杂表单校验。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例展示了标签筛选场景:默认勾选异常账号,再结合搜索框和状态下拉形成组合过滤。", + "如果多选器放进 FilterBar,应确保选中项不会撑破工具栏节奏,并保留清晰的 aria-label。" + ] + }, + { + title: "设计规范", + body: [ + "多选器应清楚区分候选项与已选结果,避免用户在筛选面板里反复确认当前状态。", + "当条件较多时,优先使用统一滚动区域与标签密度,不在业务页面私自扩展弹层样式。" + ] + } + ] + }, + { + id: "selection-controls", + title: "Selection controls", + chineseTitle: "选择控件", + summary: "Checkbox、Radio、Switch 等二元与组选项控件。", + sectionIds: ["selection-controls"], + api: [ + { + prop: "checked / defaultChecked", + type: "boolean", + defaultValue: "false", + description: "控制二元开关和单项选择的默认状态,适配受控与非受控场景。" + }, + { + prop: "label", + type: "string", + defaultValue: '""', + description: "为 Checkbox、Radio、Switch 提供统一可读标签,减少页面自行拼接文案。" + }, + { + prop: "variant", + type: '"default" | "card"', + defaultValue: '"default"', + description: "让单项选择既能作为普通表单控件,也能作为卡片式筛选块出现。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接 Checkbox、Radio、Switch 等二元与组选项控件,统一选择状态、标签和排列密度。", + "当页面需要让用户启用功能、勾选筛选条件或在互斥选项中做决定时,应优先复用这组控件。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把大量标签筛选塞进 selection controls;当选项数量较多且需要折叠、搜索或多选反馈时应切到 MultiSelect。", + "不要让单个 Switch 承担复杂确认逻辑,涉及危险操作时仍应配合按钮、弹层或说明文案。" + ] + }, + { + title: "状态与变体", + body: [ + "当前示例覆盖 checked、unchecked、card-style 与 grouped controls 等高频状态,便于回归开关、单选与多选的视觉一致性。", + "同一组选择控件应共享标签密度、禁用态和焦点反馈,不在业务层额外定义不同交互语言。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例展示了通知开关、异常筛选 checkbox、汇总方式 radio 和卡片式选择块四种组合方式。", + "如果选择控件出现在设置页和筛选条两个场景,应优先复用同一套 label 与状态说明,避免术语漂移。" + ] + }, + { + title: "设计规范", + body: [ + "选择控件首先表达状态切换与范围边界,不要通过颜色或阴影单独创造新的语义。", + "当同一视图存在多组选项时,优先按用途分组并给出清晰标签,而不是靠视觉距离让用户自行猜测。" + ] + } + ] + }, + { + id: "navigation", + title: "Navigation", + chineseTitle: "导航组件", + summary: "Breadcrumb、Tabs、Pagination、Steps 等路径与流程组件。", + sectionIds: ["navigation-wayfinding"], + api: [ + { + prop: "Breadcrumb items", + type: "ReactNode[]", + defaultValue: "[]", + description: "按路径层级描述当前位置与返回入口,适合详情页和多层管理后台。" + }, + { + prop: "Tabs.defaultValue", + type: "string", + defaultValue: '"overview"', + description: "定义首屏展示的标签页,让局部视图切换有稳定默认态。" + }, + { + prop: "Pagination.page / total / pageSize", + type: "number", + defaultValue: "1 / 0 / 20", + description: "统一分页栏的页码、总量和每页数量结构,避免列表页各自定义分页规则。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于表达页面路径、内容分段、结果分页和任务流程,帮助用户理解自己当前所在的位置与下一步去向。", + "当页面需要在多个平级视图之间切换,或需要展示多步操作进度时,应优先复用这一组导航原语。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把 Navigation 当成主要操作区来承载保存、删除等动作;导航负责定位与切换,不负责提交业务结果。", + "同一层级内容不要同时叠加 Tabs、Steps 和二级 Breadcrumb,重复路径信号会增加理解成本。" + ] + }, + { + title: "状态与变体", + body: [ + "当前常见变体包括 Breadcrumb 路径导航、Tabs 内容切换、Pagination 结果分页与 Steps 流程进度。", + "是否需要图标、数字、禁用态或完成态,应跟随组件职责,而不是为视觉丰富度额外增加状态。" + ] + }, + { + title: "交互示例", + body: [ + "管理后台详情页适合组合 Breadcrumb 与 Tabs:上层路径帮助返回列表,局部视图切换交由 Tabs 处理。", + "涉及多步配置流程时,可以用 Steps 展示进度,但每一步的主操作仍应留在正文或底部操作区。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:账号详情。", + "静态示例:概览活动。" + ] + }, + { + title: "设计规范", + body: [ + "导航组件首先服务于定位和切换,不应混入主操作按钮语义;主动作仍应留在工具栏或正文操作区。", + "同一页面里不要同时堆叠多个相同层级的导航模式,避免用户同时处理 breadcrumb、tabs 和 steps 的重复路径信号。" + ] + } + ] + }, + { + id: "feedback", + title: "Feedback", + chineseTitle: "反馈组件", + summary: "Tag、Badge、Alert、Progress、Skeleton 和 Spinner 等反馈状态。", + sectionIds: ["feedback-status"], + api: [ + { + prop: "variant", + type: '"success" | "warning" | "danger" | "info" | "brand"', + defaultValue: '"info"', + description: "统一 Badge、Tag、Alert 等反馈组件的语义色映射。" + }, + { + prop: "title", + type: "string", + defaultValue: '""', + description: "用于 Alert 等强提示组件的标题文案,帮助用户快速理解事件性质。" + }, + { + prop: "value", + type: "number", + defaultValue: "0", + description: "用于 Progress 等进度型反馈,表达任务完成比例与当前阶段。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于提示当前状态、异步进度、风险警告和加载占位,让用户及时理解系统是否成功响应了操作。", + "当页面需要补充状态密度但不想打断主流程时,优先使用 Badge、Tag 或 Progress;需要明确提醒时再升级到 Alert。" + ] + }, + { + title: "不适用场景", + body: [ + "不要用高优先级 Alert 展示每一条普通提示,否则真正的风险提醒会被淹没。", + "不要让 Skeleton 或 Spinner 长时间替代真实内容;如果加载超过合理时长,应补充明确说明或失败反馈。" + ] + }, + { + title: "状态与变体", + body: [ + "首批反馈文档覆盖 success、warning、danger、info 几类语义状态,以及 loading、empty、in-progress 等交互阶段。", + "Badge、Tag、Progress、Alert、Skeleton 和 Spinner 分别对应不同强度的反馈层级,选择时应先判断是否会打断用户主流程。" + ] + }, + { + title: "交互示例", + body: [ + "例如在 API Key 列表中,用 Badge 标识状态、用 Alert 呈现失败原因、用 Spinner 或 Skeleton 承接短暂加载。", + "涉及批量任务时,可以用 Progress 展示完成度,同时在任务结束后切换为明确的成功或失败文案。" + ] + }, + { + title: "代码片段", + body: [ + "静态示例:请在 3 天内完成续费。。", + "静态示例:运行中 组合展示状态。" + ] + }, + { + title: "设计规范", + body: [ + "反馈组件的颜色必须复用语义色 token,让 success、warning、danger 等状态在全站保持同一套认知映射。", + "Skeleton 和 Spinner 只用于短暂加载反馈,不应替代真正的空状态说明。" + ] + } + ] + } + ] + }, + { + id: "overlays-utilities", + title: "Overlays & Utilities", + chineseTitle: "弹层与工具型原语", + summary: "提供弹层、提示层、滚动区域和复制等辅助能力。", + overviewDescription: "这组能力主要解决复杂交互和信息补充,不改变主页面结构,但决定整个系统的细节完成度。", + sectionIds: ["overlays-utilities"], + components: [ + { + id: "overlay", + title: "Overlay", + chineseTitle: "弹层", + summary: "统一对话框、抽屉、聚焦管理和背景锁定。", + sectionIds: ["overlays-utilities"], + api: [ + { + prop: "title", + type: "string", + defaultValue: '""', + description: "为对话框或抽屉提供清晰标题,帮助用户理解当前弹层任务。" + }, + { + prop: "closeOnBackdrop", + type: "boolean", + defaultValue: "false", + description: "控制点击遮罩时是否允许关闭,适配轻量预览与需要强确认的两类场景。" + }, + { + prop: "onClose", + type: "() => void", + defaultValue: "required", + description: "统一弹层关闭出口,承接 Esc、关闭按钮和遮罩点击等收尾动作。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于承接对话框、抽屉等需要临时打断主流程的交互,同时统一 focus trap、背景锁定与 portal 挂载。", + "当页面需要补充配置、确认危险操作或展示分步信息时,应优先使用 Overlay 原语,而不是页面里手写 fixed panel。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把轻量提示或只读补充说明做成 Overlay;这类场景更适合 Tooltip、Popover 或页内提示块。", + "不要在同一时刻堆叠多个业务弹层,除非流程明确要求,否则会让焦点与关闭语义失控。" + ] + }, + { + title: "状态与变体", + body: [ + "当前真实示例覆盖 dialog 与 drawer 两种壳层,重点验证 focus trap、closeOnBackdrop 与统一 footer 行为。", + "弹层的宽度、关闭方式和 footer 组合应作为变体处理,但背景锁定和焦点管理必须保持同一套规则。" + ] + }, + { + title: "交互示例", + body: [ + "设计系统首页顶部已挂出打开对话框与打开抽屉两个真实入口,用于验证共享壳层而不是业务专属样式。", + "在弹层内部嵌入搜索、告警和键值摘要时,应确保 Tab 顺序与关闭行为仍然稳定。" + ] + }, + { + title: "设计规范", + body: [ + "弹层首先解决交互收束与焦点管理,不应承担页面级布局职责。", + "所有对话框和抽屉都应通过统一 overlay layer 渲染,避免每个业务模块各自实现 portal 与 scroll lock。" + ] + } + ] + }, + { + id: "tooltip-popover", + title: "Tooltip & Popover", + chineseTitle: "提示层与浮层", + summary: "承接轻量提示和补充操作面板。", + sectionIds: ["overlays-utilities"], + api: [ + { + prop: "TooltipContent", + type: "ReactNode", + defaultValue: "required", + description: "承载 hover 或 focus 后出现的简短说明文案。" + }, + { + prop: "PopoverContent", + type: "ReactNode", + defaultValue: "required", + description: "用于展示补充操作或上下文信息面板,内容可以比 tooltip 更丰富。" + }, + { + prop: "Trigger", + type: "interactive element", + defaultValue: "required", + description: "定义触发提示层或浮层的入口,保证 hover、focus 与 click 行为一致。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "Tooltip 用于承载简短解释、术语说明或图标按钮提示;Popover 用于展示补充操作面板和上下文设置。", + "当页面需要在不离开当前上下文的情况下补充说明或附加动作时,应优先考虑这一组轻量浮层。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把长篇文档、表单流程或危险确认放进 Tooltip 或 Popover;这类场景应升级为 Overlay 或独立页面。", + "Tooltip 不应用来承载必须阅读的信息,因为用户在移动端或键盘场景下不一定稳定触发悬停。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览重点覆盖 hover / focus 提示与 click 打开的快捷面板两类模式,帮助区分 Tooltip 和 Popover 的职责边界。", + "这组组件的关键变体不是颜色,而是内容密度、触发方式与是否允许继续操作。" + ] + }, + { + title: "交互示例", + body: [ + "真实示例同时展示聚焦提示和快捷面板,便于验证 tooltip 文案、popover 操作区与触发器之间的距离感。", + "如果同一个图标既需要解释又需要动作,优先判断用户真正需要的是提示还是操作面板,避免两者叠加。" + ] + }, + { + title: "设计规范", + body: [ + "Tooltip 文案应短促直接,Popover 内容应围绕当前上下文,不要让用户在浮层里重新理解一套页面结构。", + "两类浮层都应复用统一的定位、层级和间距语言,避免每个业务入口呈现不同的悬浮体验。" + ] + } + ] + }, + { + id: "copy-utility", + title: "Copy utility", + chineseTitle: "复制工具", + summary: "统一命令、链接和片段复制反馈。", + sectionIds: ["overlays-utilities"], + api: [ + { + prop: "value", + type: "string", + defaultValue: '""', + description: "定义要复制到剪贴板的命令、链接或代码片段内容。" + }, + { + prop: "children", + type: "ReactNode", + defaultValue: '"复制"', + description: "控制按钮展示文案,让复制动作可以适配命令、链接和字段值等不同语境。" + }, + { + prop: "copiedLabel", + type: "string", + defaultValue: '"已复制"', + description: "复制成功后的即时反馈文案,帮助用户确认动作已经完成。" + } + ], + docSections: [ + { + title: "适用场景", + body: [ + "用于命令行指令、API key 前缀、访问链接和代码片段复制,让用户在高频操作中得到统一反馈。", + "当页面包含需要重复复制的短文本时,应优先使用 Copy utility,而不是自己实现按钮与成功提示。" + ] + }, + { + title: "不适用场景", + body: [ + "不要把长篇正文或整块富文本直接交给 Copy utility;这类场景更适合下载、导出或专门的代码块复制方案。", + "如果内容本身不可见或用户无法判断复制对象,也不应只放一个复制按钮而不补充上下文说明。" + ] + }, + { + title: "状态与变体", + body: [ + "当前预览覆盖测试命令复制、Playwright 命令复制和片段复制反馈,重点验证默认态与已复制态的切换。", + "复制工具的主要变体来自文案语境与所在容器,而不是重新设计一套独立按钮样式。" + ] + }, + { + title: "交互示例", + body: [ + "设计系统首页基础层与 Overlays & Utilities 区块都保留了复制命令按钮,方便验证不同容器中的反馈一致性。", + "如果复制动作出现在卡片、代码示例和工具栏中,应保持相同的成功提示和可聚焦行为。" + ] + }, + { + title: "设计规范", + body: [ + "复制按钮必须让用户知道将要复制什么内容,避免只显示抽象动词导致误操作。", + "复制反馈应短促、明确且不会打断主流程,优先使用同一套 success 文案和按钮状态切换。" + ] + } + ] + } + ] + } +]; diff --git a/apps/web/src/pages/design-system/designSystemPreviewParts.tsx b/apps/web/src/pages/design-system/designSystemPreviewParts.tsx new file mode 100644 index 0000000..0e86271 --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemPreviewParts.tsx @@ -0,0 +1,131 @@ +import type { ReactNode } from "react"; + +import type { WorkspaceTheme } from "../../app/appStore"; +import { Button } from "../../shared/button"; +import { CopyButton } from "../../shared/copy-button"; +import { designSystemSharedStyles } from "./designSystemStyles"; + +interface SwatchProps { + name: string; + hex: string; + varName: string; +} + +interface TokenRowProps { + label: string; + value: ReactNode; + hint: string; +} + +interface ThemeShowcaseCardProps { + description: string; + theme: WorkspaceTheme; + title: string; +} + +export function Swatch({ name, hex, varName }: SwatchProps) { + return ( +
+
+ ); +} + +export function TokenRow({ label, value, hint }: TokenRowProps) { + return ( +
+
{value}
+
+ {label} + {hint} +
+
+ ); +} + +export function ThemeShowcaseCard({ description, theme, title }: ThemeShowcaseCardProps) { + const isDark = theme === "dark"; + + return ( +
+ {title} +
+
+ {isDark ? "Dark surface" : "Light surface"} + + Accent + +
+ {description} +
+
+ ); +} + +export function DesignSystemQuickActions() { + return ( +
+ Quick actions +
+ 复制测试命令 + + 复制 e2e 命令 + +
+
+ ); +} + +export function PreviewActionButtons({ onOpenDialog, onOpenDrawer }: { onOpenDialog: () => void; onOpenDrawer: () => void }) { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/pages/design-system/designSystemSections.tsx b/apps/web/src/pages/design-system/designSystemSections.tsx new file mode 100644 index 0000000..0786ef1 --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemSections.tsx @@ -0,0 +1,820 @@ +import { Bell, CircleAlert, Mail, ShieldCheck } from "lucide-react"; +import type { ReactNode } from "react"; + +import { Alert } from "../../shared/alert"; +import { Avatar } from "../../shared/avatar"; +import { Badge } from "../../shared/badge"; +import { Breadcrumb, BreadcrumbCurrent, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbSeparator } from "../../shared/breadcrumb"; +import { Button } from "../../shared/button"; +import { Card, CardBody, CardFooter, CardHeader } from "../../shared/card"; +import { CopyButton } from "../../shared/copy-button"; +import { Divider } from "../../shared/divider"; +import { EmptyState } from "../../shared/empty-state"; +import { Checkbox, FormField, MultiSelect, Radio, SearchInput, SelectInput, TextareaInput } from "../../shared/form"; +import { Icon } from "../../shared/icon"; +import { KVList } from "../../shared/kv-list"; +import { MetricCard } from "../../shared/metric-card"; +import { Page, PageBody, PageHeader, PageMain, PageSidebar, PageToolbar } from "../../shared/page-layout"; +import { Pagination } from "../../shared/pagination"; +import { Popover, PopoverContent, PopoverTrigger } from "../../shared/popover"; +import { Progress } from "../../shared/progress"; +import { ScrollArea, ScrollAreaScrollbar, ScrollAreaThumb, ScrollAreaViewport } from "../../shared/scroll-area"; +import { Skeleton } from "../../shared/skeleton"; +import { Spinner } from "../../shared/spinner"; +import { StepItem, Steps, StepsList } from "../../shared/steps"; +import { Switch } from "../../shared/switch"; +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeaderCell, + TableRow +} from "../../shared/table"; +import { Tabs, TabsList, TabsPanel, TabsTrigger } from "../../shared/tabs"; +import { Tag } from "../../shared/tag"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../shared/tooltip"; +import { Code, Heading, Kbd, Label, Muted, Text } from "../../shared/typography"; +import { FilterBar, FilterBarActions, FilterBarSummary } from "../../shared/filter-bar"; +import { DesignSystemQuickActions, Swatch, ThemeShowcaseCard, TokenRow } from "./designSystemPreviewParts"; +import { designSystemExampleStyles, designSystemPageStyles, designSystemSharedStyles } from "./designSystemStyles"; +import type { DesignSystemSectionId } from "./designSystemContent"; + +export interface DesignSystemSection { + id: DesignSystemSectionId; + title: string; + chineseTitle: string; + sprint: string; + summary: string; + primitives: string[]; + coverage: string[]; + checklist: string[]; + preview: ReactNode; +} + +const demoMultiSelectOptions = [ + { label: "异常账号", value: "exceptions" }, + { label: "近 7 天活跃", value: "7d" }, + { label: "管理员创建", value: "admin" } +]; + +const demoKVListItems = [ + { key: "环境", value: "Prod" }, + { key: "区域", value: "APAC", hint: "默认" }, + { key: "健康度", value: "98.6%" } +]; + +export const designSystemSections: DesignSystemSection[] = [ + { + id: "foundations", + title: "Foundations", + chineseTitle: "基础层", + sprint: "基础", + summary: "锁定设计原则、发布节奏、预览锚点与文档入口,保证后续原语只做填充,不再重构页面结构。", + primitives: ["Design tokens", "Preview map", "Docs index", "Visual regression entry"], + coverage: ["设计原则", "Sprint 节奏", "预览分区锚点", "文档与回归约束"], + checklist: ["13 个 section id 固定", "README/CHANGELOG 对应同一分区地图", "light/dark 回归入口一致"], + preview: ( +
+
+ {[ + ["路由", "/design-system"], + ["当前目标", "Public showcase"], + ["系统范围", " 原语集成"], + ["视觉回归", "Playwright light/dark"] + ].map(([label, value]) => ( +
+ {label} + {value} +
+ ))} +
+
+
+ Usage rules +
    +
  • 原语统一走 token,不在业务层重新定义视觉变量。
  • +
  • 新增页面先从 `/design-system` 找已有模式,再决定是否扩原语。
  • +
  • 每个 section 都对应文档和回归锚点。
  • +
+
+ +
+
+ ) + }, + { + id: "color-theme", + title: "Color & Theme", + chineseTitle: "色彩与主题", + sprint: "已接入", + summary: "展示品牌色、语义色和 light/dark 主题入口,后续把真实组件的主题差异都收敛到这里校验。", + primitives: ["Brand tokens", "Semantic tokens", "Theme toggles"], + coverage: ["品牌色", "语义色", "主题切换", "对比度观察点"], + checklist: ["light/dark 均可截图", "token 名称与 docs 一致", "不依赖业务页颜色说明"], + preview: ( +
+
+ + + + +
+
+ + +
+
+ ) + }, + { + id: "layout-spacing", + title: "Layout & Spacing", + chineseTitle: "布局与间距", + sprint: "基础", + summary: "先把页面骨架、栅格与空间尺度摆清楚,为 PageLayout、Card 和表单节奏预留稳定容器。", + primitives: ["PageLayout", "Section spacing", "Container rhythm", "FilterBar"], + coverage: ["4px 网格", "双栏 section 容器", "信息密度层级"], + checklist: ["移动端自动折行", "分区元信息与预览区可独立扩展", "后续示例注入不改外层布局"], + preview: ( +
+
+ {[4, 8, 12, 16, 24, 32].map((value) => ( +
+ {`--space-${value / 4}`} +
+ ))} +
+
+ PageLayout preview +
+ + 导出} + description="统一页面头部、操作区与内容区节奏。" + kicker="账号中心" + title="账号列表" + /> + + + 搜索布局}> + + + 状态布局}> + + + + + 角色布局}> + + + + + + + + + + + + + 主内容区 + + + + + 侧栏摘要 + + + + +
+
+
+ ) + }, + { + id: "elevation-radius", + title: "Elevation & Radius", + chineseTitle: "阴影与圆角", + sprint: "基础", + summary: "卡片、表单、弹层的外观层级先在这里归档,避免每个组件各自解释阴影和圆角档位。", + primitives: ["Radius scale", "Elevation scale", "Surface transitions"], + coverage: ["6 档圆角", "5 档阴影", "卡片层级示意"], + checklist: ["token 名称与 preview 一致", "适用于 light/dark", "后续组件只引用 token"], + preview: ( +
+
+
+
+
+ Default + +
+
+ Focus ring + +
+
+ Hover intent + +
+
+ Disabled + +
+
+
+ ) + }, + { + id: "typography-content", + title: "Typography & Content", + chineseTitle: "排版与内容原子", + sprint: "计划中", + summary: "承接 Typography、Divider、Icon、Spinner、Skeleton 等原子,统一文案密度和内容骨架。", + primitives: ["Typography", "Divider", "Icon", "Spinner", "Skeleton"], + coverage: ["字号层级", "语义文字", "装饰原子", "加载占位"], + checklist: ["中英文混排稳定", "caption/code/kbd 有固定槽位", "Skeleton/Spinner 保留回归入口"], + preview: ( +
+
+ Scale preview +
+ + Design token preview + + + 原语文案与信息层级 + + 在这里统一验证 heading、body、muted、code 和快捷键标签的视觉节奏。 + 后续页面不再自己写标题字号和辅助说明 class。 +
+ --brand-500 + +
+
+
+
+ Atoms in use +
+
+ + + +
+ + + + + +
+
+
+ ) + }, + { + id: "buttons-actions", + title: "Buttons & Actions", + chineseTitle: "按钮与动作", + sprint: "迭代中", + summary: "按钮体系已经存在一部分,这里先固定动作位布局,后续再把 subtle、xs、copy 等增强态逐步接入。", + primitives: ["Button", "ButtonLink", "ButtonAnchor", "CopyButton"], + coverage: ["主要/次要动作", "链接态", "icon-only", "复制反馈"], + checklist: ["动作组顺序稳定", "禁用/加载态预留", "视觉回归不依赖业务数据"], + preview: ( +
+
+
+ Primary action +
+ + +
+
+
+ Lightweight action +
+ + +
+
+
+ Utility action +
+ + 复制命令 +
+
+
+ Danger +
+ + +
+
+
+
+ Size matrix +
+ + + + +
+
+
+ ) + }, + { + id: "form-inputs", + title: "Form Inputs", + chineseTitle: "输入控件", + sprint: "计划中", + summary: "TextInput、Select、Textarea 已有基础能力,但预览页先为 SearchInput、MultiSelect 和组合表单节奏预留固定区域。", + primitives: ["TextInput", "SelectInput", "TextareaInput", "FormField", "SearchInput", "MultiSelect", "FilterBar"], + coverage: ["字段容器", "标签/帮助文案", "前后缀输入", "搜索与多选"], + checklist: ["单字段与组合字段分开展示", "错误/帮助/禁用态预留", "后续真实示例不改 section id"], + preview: ( +
+
+
+ + + +
+
+ + + +
+
+ + + + + + + +
+
+ + + +
+
+
+
+ + + +
+
+ + + +
+
+
+ FilterBar composition +
+ + + 搜索筛选条}> + + + 角色筛选条}> + + + + + + 状态筛选条}> + + + + + + + + + + 共 12 条结果 + +
+
+
+ ) + }, + { + id: "selection-controls", + title: "Selection Controls", + chineseTitle: "选择控件", + sprint: "计划中", + summary: "Switch 已有,Checkbox/Radio 还要补独立原子。这里先把单选、多选、开关拆成独立回归单元。", + primitives: ["Switch", "Checkbox", "Radio", "CheckboxField", "RadioGroupField"], + coverage: ["二元开关", "单项选择", "组选项", "状态排列"], + checklist: ["checked/unchecked/disabled 明确", "键盘交互后续可测", "语义标签位固定"], + preview: ( +
+
+
+ Switch + +
+
+ Checkbox + +
+
+ Radio + +
+
+ Grouped controls +
+ + +
+
+
+
+ ) + }, + { + id: "navigation-wayfinding", + title: "Navigation & Wayfinding", + chineseTitle: "导航与路径", + sprint: "计划中", + summary: "Breadcrumb、Pagination、Tabs、Steps 会影响多个页面骨架,这里先给这四类导航各留稳定位置。", + primitives: ["Breadcrumb", "Pagination", "Tabs", "Steps"], + coverage: ["分段导航", "路径层级", "分页", "流程步骤"], + checklist: ["可在单独 section 做视觉回归", "active/disabled/current 预留", "后续迁移旧导航时不改锚点"], + preview: ( +
+
+
+ Breadcrumb trail + + + + 工作台 + + + + 设计系统 + + + + 导航原语 + + + +
+
+ Segmented tabs + + + 概览 + Token + 状态 + + 同一套原语可直接承接顶栏和 landing 页的 tab 切换。 + Token 预览 + 状态矩阵 + +
+
+ Pagination bar +
+ undefined} page={2} pageSize={20} total={120} /> +
+
+
+ Steps tracker + + + + + + + +
+
+
+ ) + }, + { + id: "surfaces-cards", + title: "Surfaces & Cards", + chineseTitle: "卡片与容器", + sprint: "计划中", + summary: "用于承接 Card、EmptyState 以及页面级容器组合,明确 header/body/footer 和强调态容器的边界。", + primitives: ["Card", "CardHeader", "CardBody", "CardFooter", "EmptyState"], + coverage: ["基础卡片", "数据卡片", "状态卡片", "空状态容器"], + checklist: ["Card 变体与 padding 位置固定", "EmptyState 不抢其他 section 位置", "容器层级与 tokens 对齐"], + preview: ( +
+
+ + + + Base card + + 默认容器 + + + 用于设置页、摘要区、过滤器和普通信息分组。 + + + + + + + + Data card + + 2,184 + + + + 最近 7 天收到的邮件 + + + + + + 需要复核 + + + + 3 个账号连续 30 天无活动,建议清理或归档。 + + + + 新建筛选 + + } + description="当前筛选条件下还没有结果,调整状态或创建人后会在这里刷新。" + icon={} + title="暂无账号结果" + /> +
+
+ ) + }, + { + id: "data-display", + title: "Data Display & Charts", + chineseTitle: "数据展示", + sprint: "现有 + S5 补充", + summary: "Table、Chart 已经有基础原语,但预览页要额外承接 KVList、Avatar 和数据密度组合的回归面。", + primitives: ["Table", "Chart", "KVList", "Avatar", "MetricCard"], + coverage: ["表格容器", "图表主题", "键值列表", "头像与身份块"], + checklist: ["可离线渲染", "light/dark 数据配色稳定", "后续不会依赖真实接口数据"], + preview: ( +
+
+
+ Avatar stack +
+ + + +
+
+
+ KV list + +
+
+
+ + +
+
+ Table shell +
+ + + + + ID + 地址 + 状态 + + + + + acct_001 + ops@wemail.ai + + 启用 + + + + acct_002 + growth@wemail.ai + + 停用 + + + +
+
+
+
+
+ ) + }, + { + id: "feedback-status", + title: "Feedback & Status", + chineseTitle: "反馈与状态", + sprint: "规划中", + summary: "Tag、Badge、Progress、Alert、Spinner、Skeleton 等都会在这里汇总,避免状态型原语分散到多个页面。", + primitives: ["Tag", "Badge", "Progress", "Alert", "Spinner", "Skeleton"], + coverage: ["静态状态", "进度反馈", "页内提示", "加载骨架"], + checklist: ["语义色与 token 对齐", "成功/警告/错误态都有入口", "快照稳定后再提交基线"], + preview: ( +
+
+
+ Tag / Badge +
+ + 新版 + + } size="md" variant="info"> + 合规 + + + 启用 + + + 阻塞 + +
+
+
+ Alert + + design token 已切到共享入口后,建议优先走一轮 light / dark 截图核对。 + +
+
+ Progress + +
+
+ Loading states +
+ + + +
+
+
+
+ ) + }, + { + id: "overlays-utilities", + title: "Overlays & Utilities", + chineseTitle: "弹层与工具型原语", + sprint: "规划中", + summary: "Overlay 现有,Tooltip/Popover/ScrollArea 还未归档。这里作为所有弹层和工具型原语的统一回归面。", + primitives: ["Overlay", "Modal", "Drawer", "Tooltip", "Popover", "ScrollArea", "CopyButton"], + coverage: ["模态层", "抽屉", "悬浮提示", "滚动容器", "辅助动作"], + checklist: ["焦点管理后续单测/e2e 可挂载", "视觉回归截图只截稳定壳层", "工具型原语不侵入其他分区"], + preview: ( +
+
+
+ Tooltip / Popover +
+ + 聚焦提示 + 统一的 hover / focus 提示层。 + + + 打开快捷面板 + + 这里适合轻量筛选和补充配置,不适合长表单。 + + +
+
+
+ Scroll / Utility + + +
+ {Array.from({ length: 6 }, (_, index) => ( + + 滚动项 {index + 1} + + ))} +
+
+ + + +
+ + 复制回归命令 + +
+
+
+ + + + Dialog shell + + + + 预览页不直接挂打开态 modal,避免挡住整页;统一壳层由 Overlay 原语与 e2e 共同验证。 + + + + + + Drawer shell + + + + 抽屉、tooltip、popover 共用同一套层级 token 与交互语义,真实打开态放到单测和后续示例页。 + + +
+
+ ) + } +]; + +export function findDesignSystemSection(sectionId: DesignSystemSectionId): DesignSystemSection { + const section = designSystemSections.find((item) => item.id === sectionId); + + if (!section) { + throw new Error(`Unknown design system section: ${sectionId}`); + } + + return section; +} diff --git a/apps/web/src/pages/design-system/designSystemStyles.ts b/apps/web/src/pages/design-system/designSystemStyles.ts new file mode 100644 index 0000000..c6f938b --- /dev/null +++ b/apps/web/src/pages/design-system/designSystemStyles.ts @@ -0,0 +1,253 @@ +import { type CSSProperties } from "react"; + +export const designSystemSharedStyles = { + stack: { + display: "grid", + gap: "16px" + } satisfies CSSProperties, + chipRow: { + display: "flex", + flexWrap: "wrap", + gap: "8px" + } satisfies CSSProperties, + chip: { + display: "inline-flex", + alignItems: "center", + gap: "6px", + borderRadius: "999px", + padding: "6px 10px", + fontSize: "12px", + lineHeight: 1.2, + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-secondary, rgba(15, 23, 42, 0.04))", + color: "var(--text, #111827)" + } satisfies CSSProperties, + previewCard: { + minWidth: 0, + overflowWrap: "anywhere", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + borderRadius: "16px", + padding: "16px", + background: "var(--surface-muted)", + display: "grid", + gap: "12px" + } satisfies CSSProperties +}; + +export const designSystemPageStyles = { + shell: { + maxWidth: "1440px", + width: "min(1440px, calc(100vw - 24px))", + margin: "0 auto", + padding: "84px 0 32px" + } satisfies CSSProperties, + hero: { + display: "grid", + gap: "20px", + gridTemplateColumns: "minmax(0, 1fr)", + alignItems: "start", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + sectionLayout: { + display: "grid", + gap: "20px", + gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", + alignItems: "start" + } satisfies CSSProperties, + metaGrid: { + display: "grid", + gap: "12px" + } satisfies CSSProperties, + sidebarShell: { + display: "grid", + gap: "20px", + alignSelf: "start", + position: "sticky", + top: "96px", + padding: "8px 0", + borderRadius: "24px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + sidebarNav: { + minHeight: 0, + padding: "10px 10px 22px 22px", + border: "none", + borderRadius: 0, + background: "transparent", + boxShadow: "none", + backdropFilter: "none" + } satisfies CSSProperties, + sidebarGroup: { + gap: "10px" + } satisfies CSSProperties, + sidebarGroupHeader: { + margin: 0, + padding: "0 2px" + } satisfies CSSProperties, + sidebarGroupList: { + gap: "10px" + } satisfies CSSProperties, + sidebarButton: { + cursor: "pointer" + } satisfies CSSProperties, + sidebarButtonActive: { + boxShadow: "0 12px 24px rgba(0, 0, 0, 0.16)" + } satisfies CSSProperties, + sidebarButtonMeta: { + fontSize: "12px" + } satisfies CSSProperties, + sidebarDot: { + width: "10px", + height: "10px", + borderRadius: "999px", + background: "currentColor", + display: "inline-block" + } satisfies CSSProperties, + emphasisChip: { + ...designSystemSharedStyles.chip, + background: "var(--brand-50, rgba(255, 122, 0, 0.12))", + borderColor: "var(--brand-200, rgba(255, 122, 0, 0.2))", + color: "var(--brand-600, #b45309)" + } satisfies CSSProperties, + denseList: { + margin: 0, + paddingLeft: "18px", + display: "grid", + gap: "8px", + color: "var(--text-muted, #667085)" + } satisfies CSSProperties, + subheading: { + margin: 0, + fontSize: "14px", + fontWeight: 700, + color: "var(--text, #111827)" + } satisfies CSSProperties +}; + +export const designSystemExampleStyles = { + previewPane: { + minWidth: 0, + display: "grid", + gap: "16px" + } satisfies CSSProperties, + previewGrid: { + minWidth: 0, + display: "grid", + gap: "16px" + } satisfies CSSProperties, + twoColumnGrid: { + minWidth: 0, + display: "grid", + gap: "12px", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 180px), 1fr))" + } satisfies CSSProperties, + denseGrid: { + minWidth: 0, + display: "grid", + gap: "10px", + gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 128px), 1fr))" + } satisfies CSSProperties, + fullWidthCard: { + minWidth: 0, + overflowX: "auto", + scrollbarGutter: "stable" + } satisfies CSSProperties +}; + +export const designSystemDocStyles = { + shell: { + display: "grid", + gap: "20px", + padding: "24px", + borderRadius: "24px", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + header: { + display: "grid", + gap: "12px", + paddingBottom: "16px", + borderBottom: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))" + } satisfies CSSProperties, + sectionList: { + display: "grid", + gap: "16px" + } satisfies CSSProperties, + section: { + display: "grid", + gap: "10px", + padding: "18px 20px", + borderRadius: "18px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-muted)", + boxShadow: "0 18px 42px rgba(15, 23, 42, 0.08)" + } satisfies CSSProperties, + sectionHeading: { + margin: 0, + fontSize: "18px" + } satisfies CSSProperties, + paragraphGroup: { + display: "grid", + gap: "8px" + } satisfies CSSProperties, + exampleNote: { + padding: "14px 16px", + borderRadius: "14px", + background: "var(--surface-secondary, rgba(15, 23, 42, 0.04))", + color: "var(--text-muted, #667085)" + } satisfies CSSProperties, + guidanceNote: { + padding: "14px 16px", + borderRadius: "14px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-primary, rgba(255,255,255,0.9))" + } satisfies CSSProperties, + codeSampleList: { + display: "grid", + gap: "12px" + } satisfies CSSProperties, + codeSampleCard: { + display: "grid", + gap: "8px" + } satisfies CSSProperties, + codeSampleHeading: { + margin: 0, + fontSize: "15px", + color: "var(--text, #111827)" + } satisfies CSSProperties, + codeSamplePre: { + margin: 0, + padding: "14px 16px", + overflowX: "auto", + borderRadius: "14px", + border: "1px solid var(--border-subtle, rgba(15, 23, 42, 0.08))", + background: "var(--surface-secondary, rgba(15, 23, 42, 0.04))", + color: "var(--text, #111827)", + fontSize: "13px", + lineHeight: 1.6, + whiteSpace: "pre-wrap" + } satisfies CSSProperties +}; + +export function resolveDesignSystemSidebarLayoutStyle(viewportWidth?: number): CSSProperties { + const width = viewportWidth ?? (typeof window !== "undefined" ? window.innerWidth : 1280); + + if (width < 980) { + return { + display: "grid", + gap: "24px", + gridTemplateColumns: "minmax(0, 1fr)", + alignItems: "start" + }; + } + + return { + display: "grid", + gap: "28px", + gridTemplateColumns: "minmax(240px, 280px) minmax(0, 1fr)", + alignItems: "start" + }; +} diff --git a/apps/web/src/shared/README.md b/apps/web/src/shared/README.md index 51a0186..42e95cf 100644 --- a/apps/web/src/shared/README.md +++ b/apps/web/src/shared/README.md @@ -6,12 +6,12 @@ - API client - 样式 - 通用 hooks -- 通用 UI 基础组件 -- 通用按钮原语 -- 通用表单原语 -- 通用弹层原语 -- 通用表格原语 -- 通用开关原语 +- 通用 UI 基础组件与 design token +- 通用按钮、表单、导航、反馈、容器、数据展示原语 +- 通用页面级布局与筛选条原语 +- 通用统计卡与摘要型原语 +- 通用弹层与 shared layer 工具 +- 通用表格、开关、排版、状态与工具型原语 - 工具函数 ## 🚫 不放什么 diff --git a/apps/web/src/shared/alert/AlertPrimitives.tsx b/apps/web/src/shared/alert/AlertPrimitives.tsx new file mode 100644 index 0000000..3dc9f41 --- /dev/null +++ b/apps/web/src/shared/alert/AlertPrimitives.tsx @@ -0,0 +1,70 @@ +import { CircleAlert, Info, TriangleAlert, X, type LucideIcon } from "lucide-react"; +import { forwardRef, type ButtonHTMLAttributes, type HTMLAttributes, type ReactNode } from "react"; + +type AlertVariant = "info" | "success" | "warning" | "error"; +type AlertAppearance = "soft" | "outline"; + +type AlertProps = HTMLAttributes & { + actions?: ReactNode; + appearance?: AlertAppearance; + dismissLabel?: string; + icon?: ReactNode; + onClose?: ButtonHTMLAttributes["onClick"]; + title?: ReactNode; + variant?: AlertVariant; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +const iconMap: Record = { + error: CircleAlert, + info: Info, + success: Info, + warning: TriangleAlert +}; + +export const Alert = forwardRef(function Alert( + { + actions, + appearance = "soft", + children, + className, + dismissLabel = "关闭提示", + icon, + onClose, + role = "alert", + title, + variant = "info", + ...props + }, + ref +) { + const Icon = iconMap[variant]; + + return ( +
+ +
+ {title ?
{title}
: null} + {children ?
{children}
: null} + {actions ?
{actions}
: null} +
+ {onClose ? ( + + ) : null} +
+ ); + } +); diff --git a/apps/web/src/shared/alert/README.md b/apps/web/src/shared/alert/README.md new file mode 100644 index 0000000..f94e71d --- /dev/null +++ b/apps/web/src/shared/alert/README.md @@ -0,0 +1,18 @@ +# 🚨 shared/alert + +共享静态提示原语层。 + +## ✅ 放什么 +- `Alert` +- 信息 / 成功 / 警告 / 错误提示 +- 标题、正文、可选操作区与关闭按钮 + +## 🚫 不放什么 +- Toast 级自动消失逻辑 +- 业务错误映射与重试状态机 +- 页面级布局编排 + +## 可访问性 +- 默认 `role="alert"` +- 关闭按钮必须带 `dismissLabel` +- 图标默认 `aria-hidden` diff --git a/apps/web/src/shared/alert/alert.css b/apps/web/src/shared/alert/alert.css new file mode 100644 index 0000000..c66c984 --- /dev/null +++ b/apps/web/src/shared/alert/alert.css @@ -0,0 +1,65 @@ +.ui-alert { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: start; + padding: 14px 16px; + border: 1px solid transparent; + border-radius: calc(var(--radius-card) - 8px); +} + +.ui-alert-soft.ui-alert-info { + background: color-mix(in srgb, var(--info-soft, rgba(59, 130, 246, 0.14)) 100%, transparent); + border-color: color-mix(in srgb, var(--info-500, #3b82f6) 18%, transparent); +} + +.ui-alert-soft.ui-alert-success { + background: color-mix(in srgb, var(--success-soft, rgba(32, 201, 151, 0.16)) 100%, transparent); + border-color: color-mix(in srgb, #20c997 18%, transparent); +} + +.ui-alert-soft.ui-alert-warning { + background: color-mix(in srgb, var(--warning-soft, rgba(255, 184, 77, 0.16)) 100%, transparent); + border-color: color-mix(in srgb, #ffb84d 24%, transparent); +} + +.ui-alert-soft.ui-alert-error { + background: color-mix(in srgb, rgba(239, 68, 68, 0.14) 100%, transparent); + border-color: color-mix(in srgb, #ef4444 24%, transparent); +} + +.ui-alert-outline { + background: var(--surface-strong); + border-color: var(--border); +} + +.ui-alert-copy { + display: grid; + gap: 6px; +} + +.ui-alert-title { + font-weight: 700; + color: var(--text); +} + +.ui-alert-body, +.ui-alert-actions { + color: var(--text-muted); +} + +.ui-alert-icon, +.ui-alert-close { + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.ui-alert-close { + width: 28px; + height: 28px; + border: 0; + border-radius: 999px; + background: transparent; +} diff --git a/apps/web/src/shared/alert/index.ts b/apps/web/src/shared/alert/index.ts new file mode 100644 index 0000000..b734013 --- /dev/null +++ b/apps/web/src/shared/alert/index.ts @@ -0,0 +1 @@ +export { Alert } from "./AlertPrimitives"; diff --git a/apps/web/src/shared/avatar/AvatarPrimitives.tsx b/apps/web/src/shared/avatar/AvatarPrimitives.tsx new file mode 100644 index 0000000..05afeee --- /dev/null +++ b/apps/web/src/shared/avatar/AvatarPrimitives.tsx @@ -0,0 +1,65 @@ +import { forwardRef, useState, type HTMLAttributes, type ReactNode } from "react"; + +type AvatarSize = "xs" | "sm" | "md" | "lg" | "xl"; +type AvatarShape = "circle" | "square"; + +type AvatarProps = HTMLAttributes & { + alt?: string; + fallback?: ReactNode; + name?: string; + shape?: AvatarShape; + size?: AvatarSize; + src?: string; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +function getInitials(name?: string, alt?: string) { + const source = name ?? alt ?? ""; + const parts = source + .trim() + .split(/\s+/) + .filter(Boolean) + .slice(0, 2); + + if (parts.length === 0) return "?"; + return parts.map((part) => part.charAt(0).toUpperCase()).join(""); +} + +export const Avatar = forwardRef(function Avatar( + { + alt, + className, + fallback, + name, + shape = "circle", + size = "md", + src, + ...props + }, + ref +) { + const [hasImageError, setHasImageError] = useState(false); + const initials = fallback ?? getInitials(name, alt); + const canRenderImage = Boolean(src && !hasImageError); + + return ( + + {canRenderImage ? ( + {alt} setHasImageError(true)} src={src} /> + ) : ( + + )} + + ); + } +); diff --git a/apps/web/src/shared/avatar/README.md b/apps/web/src/shared/avatar/README.md new file mode 100644 index 0000000..0ebb90c --- /dev/null +++ b/apps/web/src/shared/avatar/README.md @@ -0,0 +1,18 @@ +# 👤 shared/avatar + +共享头像原语层。 + +## ✅ 放什么 +- `Avatar` +- 图片 / fallback initials 展示 +- 尺寸、形状和加载失败兜底 + +## 🚫 不放什么 +- 在线状态气泡 +- 业务级头像上传流程 +- 组织 / 成员权限逻辑 + +## 状态约定 +- `data-state="image" | "fallback"` +- `size`: `xs` / `sm` / `md` / `lg` / `xl` +- `shape`: `circle` / `square` diff --git a/apps/web/src/shared/avatar/avatar.css b/apps/web/src/shared/avatar/avatar.css new file mode 100644 index 0000000..d3b4978 --- /dev/null +++ b/apps/web/src/shared/avatar/avatar.css @@ -0,0 +1,60 @@ +.ui-avatar { + position: relative; + overflow: hidden; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, var(--surface-muted)); + color: var(--text); + font-weight: 700; + user-select: none; +} + +.ui-avatar-square { + border-radius: calc(var(--radius-field) - 2px); +} + +.ui-avatar-xs { + width: 24px; + height: 24px; + font-size: 0.72rem; +} + +.ui-avatar-sm { + width: 32px; + height: 32px; + font-size: 0.82rem; +} + +.ui-avatar-md { + width: 40px; + height: 40px; + font-size: 0.92rem; +} + +.ui-avatar-lg { + width: 52px; + height: 52px; + font-size: 1.05rem; +} + +.ui-avatar-xl { + width: 64px; + height: 64px; + font-size: 1.2rem; +} + +.ui-avatar-image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.ui-avatar-fallback { + display: inline-flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; +} diff --git a/apps/web/src/shared/avatar/index.ts b/apps/web/src/shared/avatar/index.ts new file mode 100644 index 0000000..7adb30c --- /dev/null +++ b/apps/web/src/shared/avatar/index.ts @@ -0,0 +1 @@ +export { Avatar } from "./AvatarPrimitives"; diff --git a/apps/web/src/shared/badge/BadgePrimitives.tsx b/apps/web/src/shared/badge/BadgePrimitives.tsx new file mode 100644 index 0000000..49807f8 --- /dev/null +++ b/apps/web/src/shared/badge/BadgePrimitives.tsx @@ -0,0 +1,43 @@ +import { forwardRef, type HTMLAttributes } from "react"; + +type BadgeVariant = "neutral" | "brand" | "info" | "success" | "warning" | "danger"; +type BadgeAppearance = "soft" | "solid"; +type BadgeSize = "sm" | "md"; +type BadgeStatusRole = "none" | "status"; + +type BadgeProps = HTMLAttributes & { + appearance?: BadgeAppearance; + size?: BadgeSize; + statusRole?: BadgeStatusRole; + variant?: BadgeVariant; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +export const Badge = forwardRef(function Badge( + { + className, + appearance = "soft", + size = "sm", + statusRole = "none", + variant = "neutral", + ...props + }, + ref +) { + return ( + + ); +}); diff --git a/apps/web/src/shared/badge/README.md b/apps/web/src/shared/badge/README.md new file mode 100644 index 0000000..04ac9bf --- /dev/null +++ b/apps/web/src/shared/badge/README.md @@ -0,0 +1,26 @@ +# 🎖️ shared/badge + +共享状态徽标原语层。 + +## ✅ 放什么 +- `Badge` +- 启用/停用、审核、风险、运行状态等 status 语义展示 +- `variant / appearance / size` 的统一视觉分层 +- 可选 `statusRole="status"`,用于需要被屏幕阅读器感知的即时状态 + +## 🚫 不放什么 +- 分类归档、主题标签、来源标记 +- 可选择 chip 或筛选器按钮 +- 包含复杂图标布局的业务状态卡 + +## 状态约定 +- `variant`: `neutral` / `brand` / `info` / `success` / `warning` / `danger` +- `appearance`: `soft` / `solid` +- `size`: `sm` / `md` +- `statusRole`: `none` / `status` + +## 使用约定 +- `Badge` 固定输出 `data-usage="status"`,明确其状态用途 +- 默认不启用 live region,只有动态状态反馈场景才传 `statusRole="status"` +- 如果内容表达的是分类或归属,不要用 `Badge`,改用 `shared/tag` +- 样式文件位于 `shared/badge/badge.css`,后续统一由样式 barrel 接入 diff --git a/apps/web/src/shared/badge/badge.css b/apps/web/src/shared/badge/badge.css new file mode 100644 index 0000000..42d9098 --- /dev/null +++ b/apps/web/src/shared/badge/badge.css @@ -0,0 +1,88 @@ +.ui-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 0; + max-width: 100%; + border: 1px solid transparent; + border-radius: var(--radius-full); + font-size: var(--font-caption); + font-weight: 700; + line-height: var(--line-caption); + white-space: nowrap; +} + +.ui-badge-size-sm { + padding: 5px 10px; +} + +.ui-badge-size-md { + padding: 7px 12px; +} + +.ui-badge-neutral { + --ui-badge-bg-soft: color-mix(in srgb, var(--neutral-100) 72%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--neutral-300) 72%, transparent); + --ui-badge-fg-soft: var(--neutral-700); + --ui-badge-bg-solid: var(--neutral-700); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-brand { + --ui-badge-bg-soft: color-mix(in srgb, var(--brand-100) 76%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--brand-500) 24%, transparent); + --ui-badge-fg-soft: var(--brand-700); + --ui-badge-bg-solid: var(--brand-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-info { + --ui-badge-bg-soft: color-mix(in srgb, var(--info-100) 76%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--info-500) 22%, transparent); + --ui-badge-fg-soft: var(--info-600); + --ui-badge-bg-solid: var(--info-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-success { + --ui-badge-bg-soft: color-mix(in srgb, var(--success-100) 76%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--success-500) 22%, transparent); + --ui-badge-fg-soft: var(--success-600); + --ui-badge-bg-solid: var(--success-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-warning { + --ui-badge-bg-soft: color-mix(in srgb, var(--warning-100) 78%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--warning-500) 22%, transparent); + --ui-badge-fg-soft: var(--warning-600); + --ui-badge-bg-solid: var(--warning-500); + --ui-badge-fg-solid: var(--neutral-900); +} + +.ui-badge-danger { + --ui-badge-bg-soft: color-mix(in srgb, var(--danger-100) 78%, transparent); + --ui-badge-border-soft: color-mix(in srgb, var(--danger-500) 22%, transparent); + --ui-badge-fg-soft: var(--danger-600); + --ui-badge-bg-solid: var(--danger-500); + --ui-badge-fg-solid: var(--neutral-0); +} + +.ui-badge-soft { + background: var(--ui-badge-bg-soft); + border-color: var(--ui-badge-border-soft); + color: var(--ui-badge-fg-soft); +} + +.ui-badge-solid { + background: var(--ui-badge-bg-solid); + border-color: transparent; + color: var(--ui-badge-fg-solid); + box-shadow: inset 0 -1px 0 color-mix(in srgb, var(--ui-badge-bg-solid) 78%, black); +} + +@media (prefers-reduced-motion: reduce) { + .ui-badge { + transition: none; + } +} diff --git a/apps/web/src/shared/badge/index.ts b/apps/web/src/shared/badge/index.ts new file mode 100644 index 0000000..08219ec --- /dev/null +++ b/apps/web/src/shared/badge/index.ts @@ -0,0 +1 @@ +export { Badge } from "./BadgePrimitives"; diff --git a/apps/web/src/shared/breadcrumb/BreadcrumbPrimitives.tsx b/apps/web/src/shared/breadcrumb/BreadcrumbPrimitives.tsx new file mode 100644 index 0000000..5822c81 --- /dev/null +++ b/apps/web/src/shared/breadcrumb/BreadcrumbPrimitives.tsx @@ -0,0 +1,87 @@ +import { + forwardRef, + type AnchorHTMLAttributes, + type HTMLAttributes, + type LiHTMLAttributes, + type OlHTMLAttributes, + type ReactNode +} from "react"; + +type BreadcrumbSeparatorProps = LiHTMLAttributes & { + children?: ReactNode; +}; + +function cx(...parts: Array) { + return parts.filter(Boolean).join(" "); +} + +export const Breadcrumb = forwardRef>(function Breadcrumb( + { "aria-label": ariaLabel = "面包屑导航", className, ...props }, + ref +) { + return