feat(web): 页头滚动信息、移除返回按钮、面包屑左侧可点、侧栏加首页

- 页头中间信息改为无缝滚动 ticker(3 份拼接,24s 线性,尊重 reduced-motion)
- 去掉分区 / 条目 / 文章三处「返回」「返回首页」按钮
- 面包屑移到左侧,中间项可点击并通过 onNavigate 走 SPA 路由
- 侧栏新增「首页」,首页时高亮,点击回首页
This commit is contained in:
stash
2026-10-29 11:26:47 +08:00
parent 2724d6f3ff
commit 22f7853bdd
9 changed files with 221 additions and 130 deletions
@@ -1,34 +1,39 @@
import { Card, Paragraph, Space, Text, Title } from '@stash/ui'
import { Breadcrumb, Card, Paragraph, Text, Title } from '@stash/ui'
import type { ArticleItem } from '../../data/types'
import { viewPath } from '../../data/view'
import { ArticleBody } from './ArticleBody'
/**
* 文章详情页(对应模板图二):
* 大标题 + [编辑] + 元信息行 + 白色正文卡。
* 面包屑 + 大标题 + [编辑] + 元信息行 + 白色正文卡。
*
* 正文是一整段 Markdown,交给 ArticleBody 解析渲染;
* 渲染细节(支持哪些语法)都收在那一个组件里。
*/
type ArticleDetailViewProps = {
article: ArticleItem
categoryId: string
categoryTitle: string
onBack: () => void
onNavigate: (href: string) => void
}
export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDetailViewProps) {
export function ArticleDetailView({
article,
categoryId,
categoryTitle,
onNavigate,
}: ArticleDetailViewProps) {
const meta = article.meta ?? ['2 年前', categoryTitle]
const crumbs = [
{ label: '首页', href: '/' },
{ label: categoryTitle, href: viewPath({ type: 'detail', categoryId }) },
{ label: '文章' },
]
return (
<div className="detail-view article-view">
<Card padding="none" className="detail-nav">
<Space justify="between" wrap>
<button type="button" className="detail-back" onClick={onBack}>
返回
</button>
<Text size="xs" tone="muted">
{categoryTitle} / 文章
</Text>
</Space>
<Breadcrumb className="detail-breadcrumb" items={crumbs} onNavigate={onNavigate} />
</Card>
<header className="article-head">
+15 -11
View File
@@ -1,12 +1,13 @@
import { Breadcrumb, Card, Empty, Space, Title } from '@stash/ui'
import { categoryCount, type Category } from '../../data/types'
import { viewPath } from '../../data/view'
import { AppGrid } from './AppGrid'
import { CesCardGrid } from './CesCardGrid'
import { GroupBlock } from './GroupBlock'
import { PosterGrid } from './PosterGrid'
/**
* 分类明细视图:返回条 + 面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
* 分类明细视图:面包屑 + hero(图标 / 标题 / 数量)+ 内容区。
*
* 内容区按分类类型切换:
* - apps:指定分组时只渲染该组,否则单组扁平渲染、多组带小标题分组渲染;
@@ -17,12 +18,13 @@ type DetailViewProps = {
category: Category
/** 从侧栏子菜单进入时指定的分组 */
groupId?: string
onBack: () => void
/** 点击面包屑中间项(SPA 路由) */
onNavigate: (href: string) => void
/** 点击卡片 / 海报进入条目详情 */
onOpenItem: (itemId: string) => void
}
export function DetailView({ category, groupId, onBack, onOpenItem }: DetailViewProps) {
export function DetailView({ category, groupId, onNavigate, onOpenItem }: DetailViewProps) {
const groups = category.kind === 'apps' ? category.groups : []
const activeGroup =
groupId !== undefined ? groups.find((group) => group.id === groupId) : undefined
@@ -33,10 +35,13 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
activeGroup !== undefined
? [
{ label: '首页', href: '/' },
{ label: category.title },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
{ label: activeGroup.name },
]
: [{ label: '首页', href: '/' }, { label: category.title }]
: [
{ label: '首页', href: '/' },
{ label: category.title, href: viewPath({ type: 'detail', categoryId: category.id }) },
]
function renderBody() {
if (category.kind === 'posters') {
@@ -71,12 +76,11 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
return (
<div className="detail-view">
<Card padding="none" className="detail-nav">
<Space justify="between" wrap>
<button type="button" className="detail-back" onClick={onBack}>
返回首页
</button>
<Breadcrumb items={crumbs} />
</Space>
<Breadcrumb
className="detail-breadcrumb"
items={crumbs}
onNavigate={onNavigate}
/>
</Card>
<div className="detail-hero">
@@ -1,4 +1,15 @@
import { Card, Icon, IconButton, Paragraph, Space, Tag, Text, Title } from '@stash/ui'
import {
Breadcrumb,
Card,
Icon,
IconButton,
Paragraph,
Space,
Tag,
Text,
Title,
type BreadcrumbItem,
} from '@stash/ui'
import type { ItemDetailModel } from '../../data/detail'
import type { RelatedItem } from '../../data/types'
import { RelatedList } from './RelatedList'
@@ -6,38 +17,29 @@ import { RelatedList } from './RelatedList'
/**
* 条目详情页:应用与电影共用。
*
* 版式:标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
* 版式:面包屑 + 标题 + 主图(方形头像 / 竖版海报)+ 标签行 + 一句话评价 + 正文
* + 操作条(浏览 / 点赞 / 分享 / 跳转)+ 可选横幅 + 「推荐阅读」。
* 具体形态由 ItemDetailModel 决定,这里不判断条目类型。
*/
type ItemDetailViewProps = {
breadcrumb: string
crumbs: BreadcrumbItem[]
model: ItemDetailModel
related: RelatedItem[]
onBack: () => void
onNavigate: (href: string) => void
onOpenItem: (itemId: string) => void
}
export function ItemDetailView({
breadcrumb,
crumbs,
model,
related,
onBack,
onNavigate,
onOpenItem,
}: ItemDetailViewProps) {
return (
<div className="detail-view item-view">
<Card padding="none" className="detail-nav">
<Space justify="between" wrap>
<button type="button" className="detail-back" onClick={onBack}>
返回
</button>
<span className="detail-breadcrumb">
<Text size="xs" tone="muted">
{breadcrumb}
</Text>
</span>
</Space>
<Breadcrumb className="detail-breadcrumb" items={crumbs} onNavigate={onNavigate} />
</Card>
<Card padding="none" className="item-card">
@@ -1,29 +0,0 @@
import { Space, Tag, Text } from '@stash/ui'
import type { ReactNode } from 'react'
/**
* 顶栏中间的一条信息,形如「[标签] 文本」。
* 标签用组件库的 Tag(success / primary 两种语气)。
*/
type HeaderInfoItemProps = {
/** 可选的前缀标签 */
tag?: string
/** 标签配色:默认蓝色,状态类用绿色 */
tone?: 'blue' | 'green'
children: ReactNode
}
export function HeaderInfoItem({ tag, tone = 'blue', children }: HeaderInfoItemProps) {
return (
<Space gap="sm">
{tag !== undefined && (
<Tag tone={tone === 'green' ? 'success' : 'primary'} shape="rounded">
{tag}
</Tag>
)}
<Text size="sm" tone="sub">
{children}
</Text>
</Space>
)
}
+56 -16
View File
@@ -1,8 +1,7 @@
import { Icon, IconButton, Space } from '@stash/ui'
import { HeaderInfoItem } from './HeaderInfoItem'
import { Icon, IconButton, Tag } from '@stash/ui'
/**
* 顶部栏:左品牌、中间运行信息、右侧操作图标。
* 顶部栏:左品牌、中间滚动信息、右侧操作图标。
* 外层 <header> 由 Layout 提供,这里只渲染栏内的内容。
*/
type SiteHeaderProps = {
@@ -13,6 +12,40 @@ type SiteHeaderProps = {
onFocusSearch: () => void
}
type InfoGroupProps = Omit<SiteHeaderProps, 'onFocusSearch'> & {
'aria-hidden'?: boolean
}
/** 运行信息:同一份内容渲染两遍,轨道平移 50% 形成无缝滚动 */
function InfoGroup({
projectCount,
sampleProject,
live,
'aria-hidden': ariaHidden,
}: InfoGroupProps) {
return (
<span className="header-ticker-group" aria-hidden={ariaHidden}>
<span className="header-ticker-item">共 {projectCount} 个本地应用</span>
{sampleProject !== undefined && (
<span className="header-ticker-item">
<Tag tone="primary" shape="rounded">
本地
</Tag>
{sampleProject}
</span>
)}
<span className="header-ticker-item">
<Tag tone={live ? 'success' : 'neutral'} shape="rounded">
状态
</Tag>
{live ? '实时同步已连接' : '等待服务连接'}
</span>
</span>
)
}
export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) {
return (
<div className="site-header-inner">
@@ -23,19 +56,26 @@ export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }:
<span className="site-brand-name">STASH</span>
</a>
<Space gap="lg" className="header-info">
<HeaderInfoItem>共 {projectCount} 个本地应用</HeaderInfoItem>
<div className="header-ticker" role="status" aria-label="运行状态">
<div className="header-ticker-track">
<InfoGroup projectCount={projectCount} sampleProject={sampleProject} live={live} />
<InfoGroup
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
// 后两份只用于视觉无缝,读屏忽略
aria-hidden
/>
<InfoGroup
projectCount={projectCount}
sampleProject={sampleProject}
live={live}
aria-hidden
/>
</div>
</div>
{sampleProject !== undefined && (
<HeaderInfoItem tag="本地">{sampleProject}</HeaderInfoItem>
)}
<HeaderInfoItem tag="状态" tone={live ? 'green' : 'blue'}>
{live ? '实时同步已连接' : '等待服务连接'}
</HeaderInfoItem>
</Space>
<Space gap="sm" className="header-actions">
<div className="header-actions">
<IconButton
label="聚焦搜索框(Ctrl+K)"
icon={<Icon name="search_3_line" />}
@@ -43,7 +83,7 @@ export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }:
/>
{/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */}
<IconButton label="登录" icon={<Icon name="user_3_line" />} />
</Space>
</div>
</div>
)
}
@@ -16,9 +16,14 @@ type CategoryNavProps = {
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页(高亮「首页」项) */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
const HOME_KEY = 'home'
const categoryKey = (id: string) => `cat:${id}`
const groupKey = (categoryId: string, groupId: string) => `group:${categoryId}:${groupId}`
@@ -26,35 +31,46 @@ export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: CategoryNavProps) {
const items: MenuItem[] = categories.map((category) => {
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
const items: MenuItem[] = [
{ key: HOME_KEY, label: '首页' },
...categories.map((category) => {
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
return {
key: categoryKey(category.id),
label: category.title,
children:
groups.length > 0
? groups.map((group) => ({
key: groupKey(category.id, group.id),
label: group.name,
}))
: undefined,
}
})
return {
key: categoryKey(category.id),
label: category.title,
children:
groups.length > 0
? groups.map((group) => ({
key: groupKey(category.id, group.id),
label: group.name,
}))
: undefined,
}
}),
]
const activeKey =
activeCategoryId === undefined
const activeKey = homeActive
? HOME_KEY
: activeCategoryId === undefined
? undefined
: activeGroupId !== undefined
? groupKey(activeCategoryId, activeGroupId)
: categoryKey(activeCategoryId)
const handleSelect = (key: string) => {
if (key === HOME_KEY) {
onHome()
return
}
if (key.startsWith('group:')) {
const [, categoryId, groupId] = key.split(':')
onNavigate({ categoryId, groupId })
@@ -18,7 +18,11 @@ type SidebarProps = {
onRefresh: () => void
activeCategoryId?: string
activeGroupId?: string
/** 是否在首页 */
homeActive?: boolean
onNavigate: (target: NavTarget) => void
/** 点击「首页」 */
onHome: () => void
}
export function Sidebar({
@@ -29,7 +33,9 @@ export function Sidebar({
onRefresh,
activeCategoryId,
activeGroupId,
homeActive = false,
onNavigate,
onHome,
}: SidebarProps) {
return (
<div className="sidebar">
@@ -46,7 +52,9 @@ export function Sidebar({
categories={categories}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
homeActive={homeActive}
onNavigate={onNavigate}
onHome={onHome}
/>
</div>
)