From 22f7853bdd7550e4782476807160ba6bbbfbd4a9 Mon Sep 17 00:00:00 2001 From: stash Date: Thu, 29 Oct 2026 11:26:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=A1=B5=E5=A4=B4=E6=BB=9A?= =?UTF-8?q?=E5=8A=A8=E4=BF=A1=E6=81=AF=E3=80=81=E7=A7=BB=E9=99=A4=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E6=8C=89=E9=92=AE=E3=80=81=E9=9D=A2=E5=8C=85=E5=B1=91?= =?UTF-8?q?=E5=B7=A6=E4=BE=A7=E5=8F=AF=E7=82=B9=E3=80=81=E4=BE=A7=E6=A0=8F?= =?UTF-8?q?=E5=8A=A0=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 页头中间信息改为无缝滚动 ticker(3 份拼接,24s 线性,尊重 reduced-motion) - 去掉分区 / 条目 / 文章三处「返回」「返回首页」按钮 - 面包屑移到左侧,中间项可点击并通过 onNavigate 走 SPA 路由 - 侧栏新增「首页」,首页时高亮,点击回首页 --- stash-web/src/App.css | 68 ++++++++++++++---- stash-web/src/App.tsx | 31 ++++---- .../components/content/ArticleDetailView.tsx | 29 ++++---- .../src/components/content/DetailView.tsx | 26 ++++--- .../src/components/content/ItemDetailView.tsx | 34 ++++----- .../src/components/header/HeaderInfoItem.tsx | 29 -------- .../src/components/header/SiteHeader.tsx | 72 ++++++++++++++----- .../src/components/sidebar/CategoryNav.tsx | 54 +++++++++----- stash-web/src/components/sidebar/Sidebar.tsx | 8 +++ 9 files changed, 221 insertions(+), 130 deletions(-) delete mode 100644 stash-web/src/components/header/HeaderInfoItem.tsx diff --git a/stash-web/src/App.css b/stash-web/src/App.css index 2ff9c20..fcdbe53 100644 --- a/stash-web/src/App.css +++ b/stash-web/src/App.css @@ -69,9 +69,60 @@ letter-spacing: 0.04em; } -.header-info { +/* ---------- 页头滚动信息 ---------- */ + +.header-ticker { flex: 1; - justify-content: center; + min-width: 0; + overflow: hidden; + mask-image: linear-gradient( + 90deg, + transparent 0, + #000 32px, + #000 calc(100% - 32px), + transparent 100% + ); +} + +.header-ticker-track { + display: flex; + width: max-content; + animation: header-ticker 24s linear infinite; +} + +/* 每组占满整条轨道宽度:任何时候只看到一份信息,滚动时下一份从右侧进入 */ +.header-ticker-group { + display: inline-flex; + align-items: center; + justify-content: space-evenly; + gap: var(--su-space-8); + min-width: 100%; +} + +.header-ticker-item { + display: inline-flex; + align-items: center; + gap: var(--su-space-2); + font-size: var(--su-font-size-sm); + line-height: var(--su-line-height-sm); + color: var(--su-text-sub); + white-space: nowrap; +} + +@keyframes header-ticker { + from { + transform: translateX(0); + } + + to { + transform: translateX(calc(-100% / 3)); + } +} + +@media (prefers-reduced-motion: reduce) { + .header-ticker-track { + animation: none; + } } .header-actions { @@ -335,19 +386,12 @@ .detail-nav { display: flex; align-items: center; - justify-content: space-between; padding: 12px var(--su-space-5); } -.detail-back { - color: var(--su-color-primary); - font-size: var(--su-font-size-md); - font-weight: var(--su-font-weight-bold); - cursor: pointer; -} - -.detail-back:hover { - text-decoration: underline; +/* 面包屑在左侧,12px(与旧版 breadcrumb 一致) */ +.detail-breadcrumb { + font-size: var(--su-font-size-xs); } .detail-nav > * { diff --git a/stash-web/src/App.tsx b/stash-web/src/App.tsx index a7a060c..eea80d0 100644 --- a/stash-web/src/App.tsx +++ b/stash-web/src/App.tsx @@ -28,7 +28,7 @@ import { import { useProjects } from './useProjects' import { useCatalog } from './useCatalog' import { useViewRoute } from './useViewRoute' -import type { ItemRoute, View } from './data/view' +import { parseViewPath, viewPath, type View } from './data/view' import './App.css' export default function App() { @@ -37,7 +37,7 @@ export default function App() { const catalog = useCatalog() const [keyword, setKeyword] = useState('') // 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts) - const { view, go, back } = useViewRoute() + const { view, go } = useViewRoute() const [scrolled, setScrolled] = useState(false) const searchRef = useRef(null) @@ -169,15 +169,6 @@ export default function App() { }) } - /** - * 条目详情的「返回」目标:优先回到该条目所在的分组, - * 没有分组(海报 / 图文)时回到分区,走和侧栏一样的展示块规则。 - */ - const detailTargetFor = (item: ItemRoute): View => - item.groupId === undefined - ? detailViewFor(item.categoryId) - : { type: 'detail', categoryId: item.categoryId, groupId: item.groupId } - /** * 侧栏导航:带分组时直接打开那个分组; * 点分区本身时,交给 openDetail 按展示块落到对应节点。 @@ -193,6 +184,9 @@ export default function App() { const goHome = () => go({ type: 'home' }) + /** 面包屑点击:把 href 解析回视图再跳转(保持 SPA 不刷新) */ + const navigateHref = (href: string) => go(parseViewPath(href)) + const searching = keyword.trim() !== '' const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId @@ -224,8 +218,9 @@ export default function App() { return ( back(detailTargetFor(itemView))} + onNavigate={navigateHref} /> ) } @@ -236,10 +231,14 @@ export default function App() { return ( back(detailTargetFor(itemView))} + onNavigate={navigateHref} onOpenItem={(itemId) => openItem(itemCategory.id, itemId)} /> ) @@ -283,7 +282,9 @@ export default function App() { onRefresh={refresh} activeCategoryId={viewCategoryId} activeGroupId={detailView?.groupId} + homeActive={view.type === 'home'} onNavigate={navigate} + onHome={goHome} /> } > @@ -335,7 +336,7 @@ export default function App() { openItem(detailCategory.id, itemId)} /> )} diff --git a/stash-web/src/components/content/ArticleDetailView.tsx b/stash-web/src/components/content/ArticleDetailView.tsx index ac817f7..fbdbb30 100644 --- a/stash-web/src/components/content/ArticleDetailView.tsx +++ b/stash-web/src/components/content/ArticleDetailView.tsx @@ -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 (
- - - - {categoryTitle} / 文章 - - +
diff --git a/stash-web/src/components/content/DetailView.tsx b/stash-web/src/components/content/DetailView.tsx index ab92f51..2116a1e 100644 --- a/stash-web/src/components/content/DetailView.tsx +++ b/stash-web/src/components/content/DetailView.tsx @@ -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 (
- - - - +
diff --git a/stash-web/src/components/content/ItemDetailView.tsx b/stash-web/src/components/content/ItemDetailView.tsx index 6054480..2c9efef 100644 --- a/stash-web/src/components/content/ItemDetailView.tsx +++ b/stash-web/src/components/content/ItemDetailView.tsx @@ -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 (
- - - - - {breadcrumb} - - - + diff --git a/stash-web/src/components/header/HeaderInfoItem.tsx b/stash-web/src/components/header/HeaderInfoItem.tsx deleted file mode 100644 index 1f01edd..0000000 --- a/stash-web/src/components/header/HeaderInfoItem.tsx +++ /dev/null @@ -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 ( - - {tag !== undefined && ( - - {tag} - - )} - - {children} - - - ) -} diff --git a/stash-web/src/components/header/SiteHeader.tsx b/stash-web/src/components/header/SiteHeader.tsx index 737bb09..d06212d 100644 --- a/stash-web/src/components/header/SiteHeader.tsx +++ b/stash-web/src/components/header/SiteHeader.tsx @@ -1,8 +1,7 @@ -import { Icon, IconButton, Space } from '@stash/ui' -import { HeaderInfoItem } from './HeaderInfoItem' +import { Icon, IconButton, Tag } from '@stash/ui' /** - * 顶部栏:左品牌、中间运行信息、右侧操作图标。 + * 顶部栏:左品牌、中间滚动信息、右侧操作图标。 * 外层
由 Layout 提供,这里只渲染栏内的内容。 */ type SiteHeaderProps = { @@ -13,6 +12,40 @@ type SiteHeaderProps = { onFocusSearch: () => void } +type InfoGroupProps = Omit & { + 'aria-hidden'?: boolean +} + +/** 运行信息:同一份内容渲染两遍,轨道平移 50% 形成无缝滚动 */ +function InfoGroup({ + projectCount, + sampleProject, + live, + 'aria-hidden': ariaHidden, +}: InfoGroupProps) { + return ( + + 共 {projectCount} 个本地应用 + + {sampleProject !== undefined && ( + + + 本地 + + {sampleProject} + + )} + + + + 状态 + + {live ? '实时同步已连接' : '等待服务连接'} + + + ) +} + export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: SiteHeaderProps) { return (
@@ -23,19 +56,26 @@ export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: STASH - - 共 {projectCount} 个本地应用 +
+
+ + + +
+
- {sampleProject !== undefined && ( - {sampleProject} - )} - - - {live ? '实时同步已连接' : '等待服务连接'} - -
- - +
} @@ -43,7 +83,7 @@ export function SiteHeader({ projectCount, sampleProject, live, onFocusSearch }: /> {/* TODO(auth): 后端登录接口就绪后接上,目前仅作入口占位 */} } /> - +
) } diff --git a/stash-web/src/components/sidebar/CategoryNav.tsx b/stash-web/src/components/sidebar/CategoryNav.tsx index 96c9897..2d441ac 100644 --- a/stash-web/src/components/sidebar/CategoryNav.tsx +++ b/stash-web/src/components/sidebar/CategoryNav.tsx @@ -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 }) diff --git a/stash-web/src/components/sidebar/Sidebar.tsx b/stash-web/src/components/sidebar/Sidebar.tsx index f7e4b9a..8183024 100644 --- a/stash-web/src/components/sidebar/Sidebar.tsx +++ b/stash-web/src/components/sidebar/Sidebar.tsx @@ -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 (
@@ -46,7 +52,9 @@ export function Sidebar({ categories={categories} activeCategoryId={activeCategoryId} activeGroupId={activeGroupId} + homeActive={homeActive} onNavigate={onNavigate} + onHome={onHome} />
)