feat(web): 地址栏驱动视图,刷新详情页不再回首页

- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
2026-10-24 17:18:30 +08:00
parent 41fed5e64d
commit 19ead7b716
22 changed files with 2098 additions and 1321 deletions
@@ -1,23 +1,28 @@
import { useState } from 'react'
import type { NavModel, NavTarget } from '../../data/nav'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
/**
* 分类导航树:顶层直达链接 + 可折叠分组。
* 分类导航树:直接由内容树派生,不再读独立的导航数据。
*
* 导航内容来自内容库(`nav_entry` 表),由父组件透传进来;
* 点击叶子节点不跳转页面,而是把目标(分类 + 可选分组)交给 onNavigate,
* 由父组件切换右侧的明细视图。
* - 有分组的分类(apps)渲染成可折叠节点,子项是分组(分组可用 show_in_sidebar 单独隐藏);
* - 没有分组、或分组全被隐藏的分类,直接是链接。
*/
type CategoryNavProps = {
/** 侧栏导航数据;内容库为空时是空模型 */
nav: NavModel
/** 内容目录;顺序即侧栏顺序 */
categories: Category[]
/** 当前明细视图的分类 / 分组,用于高亮 */
activeCategoryId?: string
activeGroupId?: string
onNavigate: (target: NavTarget) => void
}
export function CategoryNav({ nav, activeCategoryId, activeGroupId, onNavigate }: CategoryNavProps) {
export function CategoryNav({
categories,
activeCategoryId,
activeGroupId,
onNavigate,
}: CategoryNavProps) {
const [openGroups, setOpenGroups] = useState<string[]>([])
const toggle = (id: string) => {
@@ -31,52 +36,62 @@ export function CategoryNav({ nav, activeCategoryId, activeGroupId, onNavigate }
return (
<nav className="nav-card card-box" aria-label="站点分类">
<ul className="nav-group">
{nav.topLevel.map((leaf) => (
<li className="nav-item" key={leaf.id}>
<button
type="button"
className={`nav-link${
activeCategoryId === leaf.target.categoryId && activeGroupId === undefined
? ' active'
: ''
}`}
onClick={() => onNavigate(leaf.target)}
>
<span>{leaf.label}</span>
</button>
</li>
))}
{categories.map((category) => {
// 只显示允许出现在侧栏的分组
const groups =
category.kind === 'apps'
? category.groups.filter((group) => group.showInSidebar !== false)
: []
{nav.groups.map((group) => (
<li className={`nav-item${isOpen(group.id) ? ' open' : ''}`} key={group.id}>
<button
type="button"
className="nav-link has-children"
aria-expanded={isOpen(group.id)}
onClick={() => toggle(group.id)}
>
<span>{group.label}</span>
</button>
// 没有分组的分类:整行就是一个链接
if (groups.length === 0) {
return (
<li className="nav-item" key={category.id}>
<button
type="button"
className={`nav-link${
activeCategoryId === category.id && activeGroupId === undefined ? ' active' : ''
}`}
onClick={() => onNavigate({ categoryId: category.id })}
>
<span>{category.title}</span>
</button>
</li>
)
}
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
也不会在过渡期间出现滚动条 */}
<div className={`submenu-wrap${isOpen(group.id) ? ' is-open' : ''}`}>
<ul className="submenu">
{group.leaves.map((leaf) => (
<li key={leaf.id}>
<button
type="button"
className={`nav-link${activeGroupId === leaf.target.groupId ? ' active' : ''}`}
onClick={() => onNavigate(leaf.target)}
>
<span>{leaf.label}</span>
</button>
</li>
))}
</ul>
</div>
</li>
))}
// 有分组:分区可折叠,分组是真正的链接
return (
<li className={`nav-item${isOpen(category.id) ? ' open' : ''}`} key={category.id}>
<button
type="button"
className="nav-link has-children"
aria-expanded={isOpen(category.id)}
onClick={() => toggle(category.id)}
>
<span>{category.title}</span>
</button>
{/* 用 grid-template-rows 做高度动画:不需要猜 max-height,
也不会在过渡期间出现滚动条 */}
<div className={`submenu-wrap${isOpen(category.id) ? ' is-open' : ''}`}>
<ul className="submenu">
{groups.map((group) => (
<li key={group.id}>
<button
type="button"
className={`nav-link${activeGroupId === group.id ? ' active' : ''}`}
onClick={() => onNavigate({ categoryId: category.id, groupId: group.id })}
>
<span>{group.name}</span>
</button>
</li>
))}
</ul>
</div>
</li>
)
})}
</ul>
</nav>
)
+6 -5
View File
@@ -1,5 +1,6 @@
import type { Project } from '../../api'
import type { NavModel, NavTarget } from '../../data/nav'
import type { NavTarget } from '../../data/nav'
import type { Category } from '../../data/types'
import { CategoryNav } from './CategoryNav'
import { ProfileCard } from './ProfileCard'
import { ServicePanel } from './ServicePanel'
@@ -10,8 +11,8 @@ import { ServicePanel } from './ServicePanel'
*/
type SidebarProps = {
projects: Project[]
/** 侧栏导航数据,来自内容库 */
nav: NavModel
/** 内容目录;侧栏直接由它派生 */
categories: Category[]
live: boolean
refreshing: boolean
onRefresh: () => void
@@ -22,7 +23,7 @@ type SidebarProps = {
export function Sidebar({
projects,
nav,
categories,
live,
refreshing,
onRefresh,
@@ -42,7 +43,7 @@ export function Sidebar({
/>
<CategoryNav
nav={nav}
categories={categories}
activeCategoryId={activeCategoryId}
activeGroupId={activeGroupId}
onNavigate={onNavigate}