feat(web): 对齐 Alright 主题视觉规范(配色/字体/图标/背景/动效),并补充设计规范文档

This commit is contained in:
2026-10-22 20:27:10 +08:00
parent e522a98f6f
commit 386c0f77c7
50 changed files with 6344 additions and 1094 deletions
+205 -97
View File
@@ -1,119 +1,178 @@
import { useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/BackToTop'
import { SearchBar } from './components/SearchBar'
import { Sidebar } from './components/Sidebar'
import { SiteCard } from './components/SiteCard'
import { TopBar } from './components/TopBar'
import { MOCK_SECTIONS, type CatalogCard, type CatalogSection } from './catalog'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BackToTop } from './components/common/BackToTop'
import { ArticleDetailView } from './components/content/ArticleDetailView'
import { DetailView } from './components/content/DetailView'
import { HomeView } from './components/content/HomeView'
import { ItemDetailView } from './components/content/ItemDetailView'
import { SiteHeader } from './components/header/SiteHeader'
import { SearchBar } from './components/search/SearchBar'
import { Sidebar } from './components/sidebar/Sidebar'
import { buildCategories } from './data/catalog'
import {
appDetailModel,
posterDetailModel,
posterToRelatedItem,
toRelatedItem,
} from './data/detail'
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
findAppItem,
findArticleItem,
findPosterItem,
relatedAppItems,
relatedPosterItems,
} from './data/types'
import { useProjects } from './useProjects'
import './App.css'
function initialOf(name: string): string {
const [first] = Array.from(name.trim())
return first ? first.toUpperCase() : '#'
}
/**
* 当前展示的视图:
* - home:首页分区卡片流
* - detail:某个分类(可指定分组)的明细
* - item:单个条目(应用或电影)的详情,returnTo 记录返回目标
*/
type View =
| { type: 'home' }
| { type: 'detail'; categoryId: string; groupId?: string }
| { type: 'item'; categoryId: string; itemId: string; returnTo: View }
export default function App() {
const { status, projects, error, live, refreshing, refresh } = useProjects()
const [keyword, setKeyword] = useState('')
const [view, setView] = useState<View>({ type: 'home' })
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// 真实项目放最前面,占位分类跟在后面
const sections = useMemo<CatalogSection[]>(() => {
const localCards: CatalogCard[] = projects.map((project) => ({
id: project.route_path,
title: project.name,
desc: project.route_path,
href: project.route_path,
initial: initialOf(project.name),
mono: true,
}))
const categories = useMemo(() => buildCategories(projects), [projects])
const visibleCategories = useMemo(
() => filterCategories(categories, keyword),
[categories, keyword],
)
return [{ id: 'local', title: '本地服务', cards: localCards }, ...MOCK_SECTIONS]
}, [projects])
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
const query = keyword.trim().toLowerCase()
const searching = query !== ''
const detailCategory =
detailView !== undefined
? categories.find((category) => category.id === detailView.categoryId)
: undefined
// 搜索时过滤卡片,并丢掉没有命中的分区
const sectionsToRender = useMemo(() => {
if (!searching) return sections
const itemCategory =
itemView !== undefined
? categories.find((category) => category.id === itemView.categoryId)
: undefined
return sections
.map((section) => ({
...section,
cards: section.cards.filter(
(card) =>
card.title.toLowerCase().includes(query) ||
card.desc.toLowerCase().includes(query),
),
}))
.filter((section) => section.cards.length > 0)
}, [sections, searching, query])
// 应用与电影共用一个详情模型,这里按分类类型做映射
const detailModel = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return undefined
const noMatch = searching && sectionsToRender.length === 0
if (itemCategory.kind === 'apps') {
const item = findAppItem(itemCategory, itemView.itemId)
return item !== undefined ? appDetailModel(item) : undefined
}
if (itemCategory.kind === 'posters') {
const poster = findPosterItem(itemCategory, itemView.itemId)
return poster !== undefined ? posterDetailModel(poster) : undefined
}
return undefined
}, [itemView, itemCategory])
const relatedItems = useMemo(() => {
if (itemView === undefined || itemCategory === undefined) return []
if (itemCategory.kind === 'apps') {
return relatedAppItems(itemCategory, itemView.itemId, 4).map(toRelatedItem)
}
if (itemCategory.kind === 'posters') {
return relatedPosterItems(itemCategory, itemView.itemId, 4).map(posterToRelatedItem)
}
return []
}, [itemView, itemCategory])
// 切换视图时把内容区滚回顶部
useEffect(() => {
scrollRef.current?.scrollTo({ top: 0 })
}, [view])
// 桌面端滚动作在内容容器里,窄屏滚的是窗口:
// 两处都要驱动「已滚动」状态(顶部渐隐层 + 返回顶部按钮共用)
useEffect(() => {
const sync = () => {
const inner = scrollRef.current?.scrollTop ?? 0
const next = Math.max(inner, window.scrollY) > 4
setScrolled((current) => (current === next ? current : next))
}
window.addEventListener('scroll', sync, { passive: true })
return () => window.removeEventListener('scroll', sync)
}, [])
const openDetail = (categoryId: string) => setView({ type: 'detail', categoryId })
const openItem = (categoryId: string, itemId: string, returnTo: View) =>
setView({ type: 'item', categoryId, itemId, returnTo })
const navigate = (target: NavTarget) =>
setView({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
const goHome = () => setView({ type: 'home' })
const searching = keyword.trim() !== ''
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
*/
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <p className="section-empty">分类不存在</p>
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <p className="section-empty">条目不存在</p>
}
function renderLocalPlaceholder() {
if (status === 'error') {
return (
<div className="notice notice-error" role="alert">
<strong>连不上 Stash 服务</strong>
<p>{error}</p>
<p>
请确认后端已启动(默认 <code>127.0.0.1:8080</code>),开发时 Vite 会把{' '}
<code>/api</code> 代理到后端。
</p>
<button type="button" className="notice-action" onClick={refresh}>
重试
</button>
</div>
<ArticleDetailView
article={article}
categoryTitle={itemCategory.title}
onBack={() => setView(itemView.returnTo)}
/>
)
}
if (status === 'loading') {
return (
<div className="notice">
<strong>正在读取项目列表…</strong>
<p>首次加载会扫描一次托管目录。</p>
</div>
)
if (detailModel === undefined) {
return <p className="section-empty">条目不存在</p>
}
return (
<div className="notice">
<strong>这里还没有项目</strong>
<p>
在托管根目录下创建子目录并放入 <code>index.html</code>,刷新后即可在这里看到它。
</p>
</div>
)
}
function renderSection(section: CatalogSection) {
return (
<section className="section-group" id={section.id} key={section.id}>
<div className="section-heading">
<h2 className="section-title">{section.title}</h2>
<span className="section-badge">{section.cards.length}</span>
</div>
{section.cards.length > 0 ? (
<div className="grid">
{section.cards.map((card, index) => (
<SiteCard key={card.id} card={card} index={index} />
))}
</div>
) : (
renderLocalPlaceholder()
)}
</section>
<ItemDetailView
breadcrumb={`${itemCategory.title} / ${detailModel.title}`}
model={detailModel}
related={relatedItems}
onBack={() => setView(itemView.returnTo)}
onOpenItem={(itemId) => setView({ ...itemView, itemId })}
/>
)
}
return (
<div className="app">
<TopBar
projects={projects}
{/* 主题的顶部背景图:固定 65vh,底部渐隐到页面底色 */}
<div className="site-background" aria-hidden="true" />
<SiteHeader
projectCount={projects.length}
sampleProject={projects[0]?.name}
live={live}
onFocusSearch={() => searchRef.current?.focus()}
/>
@@ -128,25 +187,74 @@ export default function App() {
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={detailView?.categoryId ?? itemView?.categoryId}
activeGroupId={detailView?.groupId}
onNavigate={navigate}
/>
<main className="content">
<div className="content-scroll" id="content-scroll">
{/* 滚动后才淡入的顶部遮罩,避免内容在容器顶边被硬切 */}
<div className={`content-veil${scrolled ? ' is-visible' : ''}`} aria-hidden="true" />
<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>
{sectionsToRender.map(renderSection)}
{noMatch && (
<div className="notice">
<strong>没有匹配「{keyword.trim()}」的内容</strong>
<p>换个关键字试试,或清空搜索查看全部。</p>
{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>
)}
{detailView === undefined && itemView === undefined && (
<>
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={(categoryId, itemId) =>
openItem(categoryId, itemId, { type: 'home' })
}
/>
{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, detailView)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<p className="section-empty">分类不存在</p>
)}
{itemView !== undefined && renderItemView()}
</div>
</main>
</div>
<BackToTop />
<BackToTop visible={scrolled} />
</div>
)
}