refactor(web): 前端改用 @stash/ui 组件库

- 新增本地依赖 @stash/ui(file:../../stash-ui),引入其 style.css 与设计令牌
- 页面骨架改用 Layout(contained + header/sider),搜索框改为吸顶
- 卡片/菜单/按钮/标签/头像/分页/空态/提示全部换成组件库组件
- 删除本地 iconfont 与 1400 行手写组件样式,App.css 只保留页面级样式
- 数据层、路由、过滤逻辑未改动;33 个 Rust 测试通过
This commit is contained in:
stash
2026-10-28 12:44:50 +08:00
parent 19ead7b716
commit 64f1fbf906
35 changed files with 735 additions and 2154 deletions
+88 -116
View File
@@ -1,3 +1,4 @@
import { Empty, Layout, Notice } from '@stash/ui'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
@@ -39,7 +40,6 @@ export default function App() {
const { view, go, back } = useViewRoute()
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
const categories = useMemo(
() => withLocalCategory(projects, catalog.categories),
@@ -103,20 +103,18 @@ export default function App() {
return []
}, [itemView, itemCategory])
// 切换视图时把内容区滚回顶部
// 切换视图时回到顶部(页面整体滚动)
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 })
window.scrollTo({ top: 0 })
}, [view])
// 桌面端滚动作在内容容器里,窄屏滚的是窗口:
// 两处都要驱动「已滚动」状态(顶部渐隐层 + 返回顶部按钮共用)
useEffect(() => {
const sync = () => {
const inner = scrollRef.current?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
const next = window.scrollY > 4
setScrolled((current) => (current === next ? current : next))
}
sync()
window.addEventListener('scroll', sync, { passive: true })
return () => window.removeEventListener('scroll', sync)
}, [])
@@ -125,7 +123,6 @@ export default function App() {
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
* 这样卡片上的「···」和分区标题都通向卡片里实际展示的那块内容。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
@@ -203,21 +200,13 @@ export default function App() {
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)
return <Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
@@ -230,11 +219,7 @@ export default function App() {
}
if (detailModel === undefined) {
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
return <Empty text={dataPending ? '正在读取内容库…' : '条目不存在'} />
}
return (
@@ -249,111 +234,98 @@ export default function App() {
}
return (
<div className="app">
<>
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
/>
<Layout
contained
maxWidth={1380}
siderWidth={260}
className="app"
header={
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
/>
}
sider={
<Sidebar
projects={projects}
categories={sidebarCategories}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
onNavigate={navigate}
/>
}
>
<div className="content">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
<div className="search-row">
<SearchBar value={keyword} onChange={setKeyword} inputRef={searchRef} />
</div>
<h1 className="sr-only">Stash 本地应用工作区</h1>
<div className="layout">
<Sidebar
projects={projects}
categories={sidebarCategories}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
onNavigate={navigate}
/>
{status === 'error' && (
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: refresh }}
>
{error}
</Notice>
)}
<main className="content">
{/* 滚动后才淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<Notice
tone="error"
title="内容库读取失败"
action={{ label: '重试', onClick: catalog.reload }}
>
{catalog.error}
</Notice>
)}
<div
className="content-scroll"
id="content-scroll"
ref={scrollRef}
onScroll={(event) => {
const next = event.currentTarget.scrollTop > 4
setScrolled((current) => (current === next ? current : next))
}}
>
<h1 className="sr-only">Stash 本地应用工作区</h1>
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && <Empty text="正在读取内容库…" />}
{status === 'error' && (
<div className="notice notice-error" role="alert">
<strong>连不上 Stash 服务</strong>
<p>{error}</p>
<button type="button" className="notice-action" onClick={refresh}>
重试
</button>
</div>
)}
{/* 内容库读取失败通常是用户改坏了 data 列里的 JSON,错误信息会带条目 id */}
{catalog.status === 'error' && (
<div className="notice notice-error" role="alert">
<strong>内容库读取失败</strong>
<p>{catalog.error}</p>
<button type="button" className="notice-action" onClick={catalog.reload}>
重试
</button>
</div>
)}
{detailView === undefined && itemView === undefined && (
<>
{catalog.status === 'loading' && (
<p className="section-empty">正在读取内容库…</p>
)}
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
</div>
)}
</>
)}
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onBack={goHome}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={openItem}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)}
{searching && visibleCategories.length === 0 && (
<Notice title={`没有匹配「${keyword.trim()}」的内容`}>
换个关键字试试,或清空搜索查看全部。
</Notice>
)}
</>
)}
{itemView !== undefined && renderItemView()}
</div>
</main>
</div>
{detailView !== undefined && detailCategory !== undefined && (
<DetailView
category={detailCategory}
groupId={detailView.groupId}
onBack={goHome}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<Empty text={dataPending ? '正在读取内容库…' : '分类不存在'} />
)}
{itemView !== undefined && renderItemView()}
</div>
</Layout>
<BackToTop visible={scrolled} />
</div>
</>
)
}