547 lines
18 KiB
TypeScript
547 lines
18 KiB
TypeScript
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>
|
|
)
|
|
}
|