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