feat: stash-ui 0.1.0(40 个组件 + 文档站 + 主题机制)

This commit is contained in:
stash
2026-10-28 20:44:18 +08:00
commit 52296b3368
153 changed files with 17129 additions and 0 deletions
+546
View File
@@ -0,0 +1,546 @@
import { useEffect, useState } from 'react'
import {
Avatar,
Badge,
Breadcrumb,
Button,
Card,
Checkbox,
Divider,
Drawer,
Grid,
Icon,
IconButton,
Input,
ItemBadge,
Modal,
Notice,
Pagination,
Paragraph,
ScrollTop,
Select,
Skeleton,
Space,
Spinner,
Switch,
Tag,
Text,
Title,
Toaster,
toast,
} from '../../src'
import styles from './demo.module.css'
/**
* 拼装示例:用组件库搭一个完整的三栏内容页。
*
* 只用 `@stash/ui` 的组件 + 这一页自己的布局样式,
* 没有引入任何业务组件;浅色/暗色由 `<html data-scheme>` 切换。
*/
const NAV = ['工作台', '常用推荐', '推荐电影', 'CES 现场报道']
const HOT_POSTS = [
'iPhone 17e 最全汇总:最便宜的苹果 AI 手机',
'二十年磨一剑,微软量子芯片碾压全球超算',
'做饭将被 AI 彻底改变!AI 厨电成新风口',
'对未来讲话?从 AWE 2024 看未来趋势',
]
const POST_TAGS = ['未分类', '苹果新品', '闪电接口']
const GALLERY = [1, 2, 3, 4, 5, 6]
function useTheme() {
// 支持 ?scheme=dark 直接进深色,方便截图和分享
const [scheme, setScheme] = useState<'light' | 'dark'>(() =>
new URLSearchParams(window.location.search).get('scheme') === 'dark' ? 'dark' : 'light',
)
useEffect(() => {
document.documentElement.dataset.scheme = scheme
}, [scheme])
return {
scheme,
toggle: () => setScheme((current) => (current === 'light' ? 'dark' : 'light')),
}
}
/** 刷新时的占位卡片:结构和真实卡片一致,避免加载完成后跳动 */
function SkeletonPost() {
return (
<Card padding="lg" aria-busy="true">
<Space gap="sm">
<Skeleton variant="circle" width={36} height={36} />
<Space direction="column" gap="xs" align="start">
<Skeleton width={96} />
<Skeleton width={64} />
</Space>
</Space>
<div style={{ marginTop: 'var(--su-space-4)' }}>
<Skeleton lines={2} />
</div>
<div style={{ marginTop: 'var(--su-space-4)' }}>
<Skeleton variant="rect" height={220} />
</div>
</Card>
)
}
export function Page() {
const { scheme, toggle } = useTheme()
const [keyword, setKeyword] = useState('')
const [scrolled, setScrolled] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const [removed, setRemoved] = useState(false)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [autoSync, setAutoSync] = useState(true)
const [showCover, setShowCover] = useState(true)
const [notify, setNotify] = useState(false)
const [sort, setSort] = useState('new')
const [drawerOpen, setDrawerOpen] = useState(false)
const [navOpen, setNavOpen] = useState(false)
const refreshFeed = () => {
setLoading(true)
window.setTimeout(() => setLoading(false), 1200)
}
useEffect(() => {
const sync = () => setScrolled(window.scrollY > 40)
window.addEventListener('scroll', sync, { passive: true })
return () => window.removeEventListener('scroll', sync)
}, [])
return (
<div className={styles.page}>
<div className={styles.glow} aria-hidden="true" />
<header className={styles.header}>
<div className={styles.headerInner}>
<a className={styles.brand} href="/demo.html">
<span className={styles.brandMark}>S</span>
<span className={styles.brandName}>STASH</span>
</a>
<nav className={styles.nav} aria-label="主导航">
{NAV.map((item, index) => (
<a
key={item}
href="#top"
className={`${styles.navLink}${index === 0 ? ` ${styles.navLinkActive}` : ''}`}
onClick={(event) => event.preventDefault()}
>
{item}
</a>
))}
</nav>
<div className={styles.headerActions}>
<span className={styles.onlyMobile}>
<IconButton
label="打开导航"
icon={<Icon name="menu_line" />}
onClick={() => setNavOpen(true)}
/>
</span>
<IconButton label="搜索" icon={<Icon name="search_3_line" />} />
<IconButton
label="刷新列表"
icon={<Icon name="refresh_3_line" />}
spinning={loading}
onClick={refreshFeed}
/>
<IconButton
label={scheme === 'light' ? '切换到深色' : '切换到浅色'}
icon={<Icon name={scheme === 'light' ? 'moon_line' : 'sun_line'} />}
onClick={toggle}
/>
<IconButton
label="设置"
icon={<Icon name="settings_1_line" />}
onClick={() => setDrawerOpen(true)}
/>
<IconButton label="账号" icon={<Icon name="user_3_line" />} variant="soft" />
</div>
</div>
</header>
<div id="top" className={styles.layout}>
<aside className={styles.rail}>
<Card padding="lg" className={styles.profile}>
<Avatar size="lg" fallback="S" verified />
<h1 className={styles.profileName}>Stash 工作台</h1>
<Tag shape="rounded">管理员</Tag>
<p className={styles.profileBio}>
本地静态页面托管工具,内容库驱动分类、分组与条目;这一页全部由基础组件拼成。
</p>
<span className={styles.profileMeta}>IP 归属地:局域网</span>
</Card>
<Grid columns={2} gap="sm">
<Card padding="sm" hoverable>
<span className={styles.statLabel}>文章</span>
<strong className={styles.statValue}>16</strong>
</Card>
<Card padding="sm" hoverable>
<span className={styles.statLabel}>评论</span>
<strong className={styles.statValue}>0</strong>
</Card>
</Grid>
<Card padding="sm">
<h2 className={styles.cardTitle}>🔗 社交媒体</h2>
<Space gap="md">
<IconButton label="链接" icon={<Icon name="link_line" />} size="xs" variant="soft" />
<IconButton label="分享" icon={<Icon name="share_3_line" />} size="xs" variant="soft" />
<IconButton label="浏览" icon={<Icon name="eye_line" />} size="xs" variant="soft" />
<IconButton label="账号" icon={<Icon name="user_3_line" />} size="xs" variant="soft" />
</Space>
</Card>
<Card padding="md">
<h2 className={styles.cardTitle}>⚙️ 偏好</h2>
<Space direction="column" gap="md" align="stretch">
<Space justify="between">
<Text>自动同步</Text>
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
</Space>
<Space justify="between">
<Text>显示封面</Text>
<Switch checked={showCover} onChange={setShowCover} label="显示封面" />
</Space>
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
{autoSync && (
<Space gap="sm">
<Spinner size="sm" label="同步中" />
<Text size="xs" tone="muted">
正在同步内容库…
</Text>
</Space>
)}
</Space>
</Card>
</aside>
<main className={styles.feed}>
{removed && (
<Notice
title="已删除《本地内容库改版》(示例)"
action={{ label: '撤销', onClick: () => setRemoved(false) }}
>
这里只是演示确认流程,没有真的删数据。
</Notice>
)}
<Breadcrumb
items={[{ label: '首页', href: '#top' }, { label: '工作台' }, { label: '常用推荐' }]}
/>
<Card padding="sm">
<Space justify="between" wrap>
<Space gap="sm" wrap>
<Tag variant="solid" tone="primary" icon={<Icon name="search_3_line" />}>
全部
</Tag>
<Tag>🛸 前沿科技</Tag>
<Tag>🏠 建筑力学</Tag>
<Tag>玩物志趣</Tag>
</Space>
<Space gap="sm" wrap>
<Select
value={sort}
onChange={setSort}
options={[
{ value: 'new', label: '最近更新' },
{ value: 'hot', label: '最热门' },
{ value: 'name', label: '按名称' },
]}
/>
<Input
icon="search_3_line"
placeholder="筛选内容…"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
onClear={() => setKeyword('')}
clearable
style={{ width: 220, maxWidth: '100%' }}
/>
</Space>
</Space>
</Card>
{loading ? (
<>
<SkeletonPost />
<SkeletonPost />
</>
) : (
<>
<Card padding="lg">
<Space gap="sm">
<Avatar size="md" fallback="S" verified />
<Space direction="column" gap="xs" align="start">
<Text strong>Stash</Text>
<Text size="xs" tone="muted">
2 年前 · 局域网
</Text>
</Space>
</Space>
<Title level={3} className={styles.postTitle}>
<ItemBadge tone="sticky">置顶</ItemBadge>
本地内容库改版:分区、分组与条目的三层结构
</Title>
<Paragraph lines={2}>
侧栏由内容树派生,首页卡片由 section_block 决定展示哪个节点;
条目只存 node_key,所属分区由它推导,改编码时自动跟随。
</Paragraph>
{showCover && (
<Grid columns={2} gap="sm" className={styles.media}>
<span className={`${styles.tile} ${styles.tileA}`} />
<span className={`${styles.tile} ${styles.tileB}`} />
</Grid>
)}
<Divider spacing="sm" />
<Space justify="between" wrap>
<Space gap="sm">
{POST_TAGS.map((tag, index) => (
<Tag key={tag} variant="solid" tone={index === 0 ? 'primary' : 'danger'}>
{tag}
</Tag>
))}
</Space>
<Space gap="md">
<span className={styles.action}>
<Icon name="eye_line" />
1,599
</span>
<span className={styles.action}>
<Icon name="thumb_up_2_line" />
22
</span>
<span className={styles.action}>
<Icon name="share_3_line" />
6
</span>
<IconButton
size="xs"
label="删除这篇内容"
icon={<Icon name="delete_2_line" />}
onClick={() => setConfirmOpen(true)}
/>
</Space>
</Space>
</Card>
<Card padding="lg">
<Space gap="sm">
<Avatar size="md" fallback="S" />
<Space direction="column" gap="xs" align="start">
<Text strong>Stash</Text>
<Text size="xs" tone="muted">
3 天前 · 局域网
</Text>
</Space>
</Space>
<Title level={3} className={styles.postTitle}>
<ItemBadge tone="paid">付费</ItemBadge>
主题与令牌:为什么把颜色收进 CSS 变量
</Title>
<Paragraph lines={2}>
组件只消费语义令牌(bg-card / text-main / border),
换主题时只覆盖变量——暗色主题就是这么来的。
</Paragraph>
<Notice
title="付费内容"
action={{ label: '登录后查看', onClick: () => undefined }}
>
本内容为付费内容,登录后可阅读全文。
</Notice>
<Divider spacing="sm" />
<Space justify="between" wrap>
<Space gap="sm">
<Tag>设计系统</Tag>
<Tag tone="success">已发布</Tag>
</Space>
<Space gap="md">
<span className={styles.action}>
<Icon name="eye_line" />
842
</span>
<span className={styles.action}>
<Icon name="thumb_up_2_line" />
31
</span>
</Space>
</Space>
</Card>
</>
)}
<Card padding="sm">
<Space justify="between" wrap>
<Space gap="sm">
<Text size="sm" tone="muted">
共 16 篇内容
</Text>
<Tag tone="neutral">第 {page} / 3 页</Tag>
</Space>
<Pagination page={page} total={16} pageSize={6} onChange={setPage} />
</Space>
</Card>
</main>
<aside className={`${styles.rail} ${styles.railRight}`}>
<Card padding="md">
<h2 className={styles.cardTitle}>
🔥 热门文章
<Badge count={HOT_POSTS.length} tone="neutral" />
</h2>
<ul className={styles.hotList}>
{HOT_POSTS.map((title, index) => (
<li key={title}>
<a
className={styles.hotItem}
href="#top"
onClick={(event) => event.preventDefault()}
>
<span className={styles.hotTitle}>{title}</span>
<ItemBadge tone="hot">HOT</ItemBadge>
</a>
{index < HOT_POSTS.length - 1 && <Divider spacing="sm" />}
</li>
))}
</ul>
</Card>
<Card padding="md">
<h2 className={styles.cardTitle}>🖼 图集</h2>
<Grid columns={3} gap="sm">
{GALLERY.map((item) => (
<span key={item} className={`${styles.thumb} ${styles[`thumb${item}`]}`} />
))}
</Grid>
</Card>
<Notice title="这一页怎么来的">
全部由 Card / Tag / ItemBadge / Avatar / Grid / Divider / Button / Input / Modal /
Pagination 拼装,没有任何业务组件;右上角可以切换深浅色。
</Notice>
</aside>
</div>
<Modal
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="删除这篇内容?"
footer={
<>
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
取消
</Button>
<Button
variant="danger"
onClick={() => {
setConfirmOpen(false)
setRemoved(true)
toast.success('已删除《本地内容库改版》')
}}
>
删除
</Button>
</>
}
>
删除后不可恢复,占位数据会被一起清掉。
</Modal>
<Drawer
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
title="设置"
footer={
<>
<Button variant="ghost" onClick={() => setDrawerOpen(false)}>
取消
</Button>
<Button
onClick={() => {
setDrawerOpen(false)
toast.success('设置已保存')
}}
>
保存
</Button>
</>
}
>
<Space direction="column" gap="lg" align="stretch">
<Space justify="between">
<Text>自动同步</Text>
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
</Space>
<Space justify="between">
<Text>显示封面</Text>
<Switch checked={showCover} onChange={setShowCover} label="显示封面" />
</Space>
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
<Space direction="column" gap="sm" align="stretch">
<Text size="sm" tone="sub">
默认排序
</Text>
<Select
value={sort}
onChange={setSort}
options={[
{ value: 'new', label: '最近更新' },
{ value: 'hot', label: '最热门' },
{ value: 'name', label: '按名称' },
]}
/>
</Space>
</Space>
</Drawer>
<Drawer open={navOpen} onClose={() => setNavOpen(false)} placement="left" width={280} title="导航">
<Space direction="column" gap="sm" align="stretch">
{NAV.map((item, index) => (
<a
key={item}
href="#top"
className={`${styles.drawerNav}${index === 0 ? ` ${styles.drawerNavActive}` : ''}`}
onClick={(event) => {
event.preventDefault()
setNavOpen(false)
}}
>
{item}
</a>
))}
<Divider spacing="sm" />
<Text size="sm" tone="muted">
窄屏下顶部导航会收进这个抽屉里;桌面端直接显示在页头。
</Text>
</Space>
</Drawer>
<Toaster />
<ScrollTop visible={scrolled} />
</div>
)
}
+48
View File
@@ -0,0 +1,48 @@
/* 只放全局 reset 与页面底色;布局都在 demo.module.css */
:root {
font-family: var(--su-font-sans);
font-size: 14px;
line-height: 22px;
color: var(--su-text-main);
background-color: var(--su-bg-page);
color-scheme: light;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
:root[data-scheme='dark'] {
color-scheme: dark;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background-color: var(--su-bg-page);
color: var(--su-text-main);
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
color: inherit;
border: none;
background: none;
}
ul {
list-style: none;
}
:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
+333
View File
@@ -0,0 +1,333 @@
/* 拼装示例的页面级布局:只做排版,不重写组件样式 */
.page {
position: relative;
min-height: 100vh;
padding-bottom: 80px;
}
/* 顶部柔光:浅色下是蓝/橙/绿三团,深色下自动变淡 */
.glow {
position: fixed;
inset: 0 0 auto;
height: 420px;
pointer-events: none;
background:
radial-gradient(560px 240px at 18% 0%, rgba(56, 88, 246, 0.18), transparent 70%),
radial-gradient(520px 220px at 72% -6%, rgba(255, 186, 58, 0.16), transparent 72%),
radial-gradient(460px 240px at 96% 6%, rgba(74, 189, 134, 0.14), transparent 70%);
mask-image: linear-gradient(to bottom, #000 40%, transparent);
}
.header {
position: sticky;
top: 0;
z-index: var(--su-z-header);
border-bottom: 1px solid transparent;
backdrop-filter: blur(12px);
background: color-mix(in srgb, var(--su-bg-page) 72%, transparent);
}
.headerInner {
display: flex;
align-items: center;
gap: var(--su-space-6);
max-width: 1280px;
height: 64px;
margin: 0 auto;
padding: 0 var(--su-space-6);
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--su-space-2);
flex-shrink: 0;
}
.brandMark {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: var(--su-radius-sm);
background-image: linear-gradient(135deg, #4d6bff, var(--su-color-primary));
color: var(--su-white);
font-weight: 600;
}
.brandName {
font-size: var(--su-font-size-lg);
letter-spacing: 0.04em;
}
.nav {
display: flex;
gap: var(--su-space-1);
}
.navLink {
padding: 6px var(--su-space-3);
border-radius: var(--su-radius-sm);
font-size: var(--su-font-size-md);
color: var(--su-text-sub);
transition:
color var(--su-duration-base) var(--su-ease-in-out),
background-color var(--su-duration-base) var(--su-ease-in-out);
}
.navLink:hover {
background-color: var(--su-bg-subtle);
color: var(--su-text-main);
}
.navLinkActive {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
.headerActions {
display: flex;
align-items: center;
gap: var(--su-space-2);
margin-left: auto;
}
/* 导航收进抽屉时,页头出现菜单按钮 */
.onlyMobile {
display: none;
}
.drawerNav {
display: block;
padding: var(--su-space-2) var(--su-space-3);
border-radius: var(--su-radius-sm);
font-size: var(--su-font-size-md);
color: var(--su-text-sub);
}
.drawerNavActive {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
font-weight: var(--su-font-weight-bold);
}
.layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) 300px;
gap: var(--su-space-5);
align-items: start;
max-width: 1280px;
margin: 0 auto;
padding: var(--su-space-6);
}
.rail {
display: flex;
flex-direction: column;
gap: var(--su-space-4);
}
.feed {
display: flex;
flex-direction: column;
gap: var(--su-space-4);
}
.profile {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--su-space-2);
text-align: center;
}
.profileName {
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
font-weight: var(--su-font-weight-bold);
}
.profileBio {
margin-top: var(--su-space-2);
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
color: var(--su-text-sub);
}
.profileMeta {
font-size: var(--su-font-size-xs);
color: var(--su-text-muted);
}
.statLabel {
display: block;
font-size: var(--su-font-size-xs);
color: var(--su-text-sub);
}
.statValue {
display: block;
margin-top: var(--su-space-2);
font-size: var(--su-font-size-xl);
line-height: var(--su-line-height-xl);
font-weight: var(--su-font-weight-bold);
}
.cardTitle {
display: flex;
align-items: center;
gap: var(--su-space-2);
margin-bottom: var(--su-space-3);
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
font-weight: var(--su-font-weight-bold);
}
.authorName {
font-size: var(--su-font-size-md);
font-weight: var(--su-font-weight-bold);
}
.meta {
font-size: var(--su-font-size-xs);
color: var(--su-text-muted);
}
.postTitle {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--su-space-2);
margin: var(--su-space-3) 0 var(--su-space-2);
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
font-weight: var(--su-font-weight-bold);
}
.excerpt {
margin-bottom: var(--su-space-3);
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-sub);
}
.media {
margin-bottom: var(--su-space-3);
}
.tile {
display: block;
aspect-ratio: 16 / 10;
border-radius: var(--su-radius-sm);
}
.tileA {
background-image: linear-gradient(135deg, #3858f6, #7c3aed);
}
.tileB {
background-image: linear-gradient(135deg, #ff8338, #ff3e3e);
}
.action {
display: inline-flex;
align-items: center;
gap: var(--su-space-1);
font-size: var(--su-font-size-sm);
color: var(--su-text-sub);
}
.pageInfo {
font-size: var(--su-font-size-sm);
color: var(--su-text-muted);
}
.hotList {
display: flex;
flex-direction: column;
}
.hotItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--su-space-2);
padding: var(--su-space-2) 0;
font-size: var(--su-font-size-sm);
color: var(--su-text-sub);
transition: color var(--su-duration-base) var(--su-ease-in-out);
}
.hotItem:hover {
color: var(--su-color-primary);
}
.hotTitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thumb {
display: block;
aspect-ratio: 1;
border-radius: var(--su-radius-sm);
}
.thumb1 {
background-image: linear-gradient(135deg, #3858f6, #22d3ee);
}
.thumb2 {
background-image: linear-gradient(135deg, #ff8338, #ffba3a);
}
.thumb3 {
background-image: linear-gradient(135deg, #4abd86, #0ea5e9);
}
.thumb4 {
background-image: linear-gradient(135deg, #7c3aed, #db2777);
}
.thumb5 {
background-image: linear-gradient(135deg, #f59e0b, #ef4444);
}
.thumb6 {
background-image: linear-gradient(135deg, #0ea5e9, #6366f1);
}
@media (max-width: 1100px) {
.layout {
grid-template-columns: 240px minmax(0, 1fr);
}
.railRight {
display: none;
}
}
@media (max-width: 820px) {
.nav {
display: none;
}
.onlyMobile {
display: inline-flex;
}
.layout {
grid-template-columns: minmax(0, 1fr);
}
.rail {
display: none;
}
.headerInner {
gap: var(--su-space-3);
padding: 0 var(--su-space-4);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import '../../src/theme/tokens.css'
import '../../src/theme/iconfont.css'
import './demo.css'
import { Page } from './Page'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Page />
</StrictMode>,
)