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
+108 -31
View File
@@ -17,6 +17,7 @@ import {
import { filterCategories } from './data/filter'
import type { NavTarget } from './data/nav'
import {
type Category,
findAppItem,
findArticleItem,
findPosterItem,
@@ -25,25 +26,17 @@ import {
} from './data/types'
import { useProjects } from './useProjects'
import { useCatalog } from './useCatalog'
import { useViewRoute } from './useViewRoute'
import type { ItemRoute, View } from './data/view'
import './App.css'
/**
* 当前展示的视图:
* - 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()
// 内容目录来自后端 SQLite 内容库;本地服务分类仍由托管项目动态生成
const catalog = useCatalog()
const [keyword, setKeyword] = useState('')
const [view, setView] = useState<View>({ type: 'home' })
// 当前视图由地址栏决定:刷新 / 收藏 / 前进后退都回到同一页(见 useViewRoute.ts)
const { view, go, back } = useViewRoute()
const [scrolled, setScrolled] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(null)
@@ -57,6 +50,15 @@ export default function App() {
[categories, keyword],
)
// 侧栏不显示「本地服务」,也不显示被标记 show_in_sidebar = 0 的分区
const sidebarCategories = useMemo(
() =>
categories.filter(
(category) => category.id !== 'local' && category.showInSidebar !== false,
),
[categories],
)
const detailView = view.type === 'detail' ? view : undefined
const itemView = view.type === 'item' ? view : undefined
@@ -119,18 +121,81 @@ export default function App() {
return () => window.removeEventListener('scroll', sync)
}, [])
const openDetail = (categoryId: string) => setView({ type: 'detail', categoryId })
/**
* 「打开这个分区」应该落到哪个视图。
*
* 应用类分区配了首页展示块时,直接落到那个分组;其余情况打开整个分区。
* 这样卡片上的「···」和分区标题都通向卡片里实际展示的那块内容。
*/
const detailViewFor = (categoryId: string): View => {
const category = categories.find((item) => item.id === categoryId)
const openItem = (categoryId: string, itemId: string, returnTo: View) =>
setView({ type: 'item', categoryId, itemId, returnTo })
if (category?.kind === 'apps' && category.block !== undefined) {
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
if (group !== undefined) {
return { type: 'detail', categoryId, groupId: group.id }
}
}
const navigate = (target: NavTarget) =>
setView({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
return { type: 'detail', categoryId }
}
const goHome = () => setView({ type: 'home' })
const openDetail = (categoryId: string) => go(detailViewFor(categoryId))
/** 条目所在的分组:应用按组组织,海报 / 图文直接挂在分区下 */
const groupOfItem = (category: Category, itemId: string): string | undefined => {
if (category.kind !== 'apps') return undefined
return category.groups.find((group) => group.items.some((item) => item.id === itemId))?.id
}
const openItem = (categoryId: string, itemId: string) => {
const category = categories.find((item) => item.id === categoryId)
go({
type: 'item',
categoryId,
itemId,
// 地址里带上条目自己的分组,刷新后「返回」才有确定的落点
groupId: category === undefined ? undefined : groupOfItem(category, itemId),
})
}
/**
* 条目详情的「返回」目标:优先回到该条目所在的分组,
* 没有分组(海报 / 图文)时回到分区,走和侧栏一样的展示块规则。
*/
const detailTargetFor = (item: ItemRoute): View =>
item.groupId === undefined
? detailViewFor(item.categoryId)
: { type: 'detail', categoryId: item.categoryId, groupId: item.groupId }
/**
* 侧栏导航:带分组时直接打开那个分组;
* 点分区本身时,交给 openDetail 按展示块落到对应节点。
*/
const navigate = (target: NavTarget) => {
if (target.groupId === undefined) {
openDetail(target.categoryId)
return
}
go({ type: 'detail', categoryId: target.categoryId, groupId: target.groupId })
}
const goHome = () => go({ type: 'home' })
const searching = keyword.trim() !== ''
const viewCategoryId = detailView?.categoryId ?? itemView?.categoryId
/**
* 首屏数据还没回来时,地址指向的分类 / 条目只是「暂时查不到」,
* 不能直接报「不存在」——刷新详情页时最容易被这个误导。
*/
const dataPending =
viewCategoryId !== undefined &&
(catalog.status === 'loading' || (viewCategoryId === 'local' && status === 'loading'))
/**
* 条目详情按分类类型分流:
* 图文走长文版式,应用 / 电影走通用的条目版式。
@@ -138,26 +203,38 @@ export default function App() {
function renderItemView() {
if (itemView === undefined) return null
if (itemCategory === undefined) {
return <p className="section-empty">分类不存在</p>
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)
}
if (itemCategory.kind === 'articles') {
const article = findArticleItem(itemCategory, itemView.itemId)
if (article === undefined) {
return <p className="section-empty">条目不存在</p>
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
}
return (
<ArticleDetailView
article={article}
categoryTitle={itemCategory.title}
onBack={() => setView(itemView.returnTo)}
onBack={() => back(detailTargetFor(itemView))}
/>
)
}
if (detailModel === undefined) {
return <p className="section-empty">条目不存在</p>
return (
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '条目不存在'}
</p>
)
}
return (
@@ -165,8 +242,8 @@ export default function App() {
breadcrumb={`${itemCategory.title} / ${detailModel.title}`}
model={detailModel}
related={relatedItems}
onBack={() => setView(itemView.returnTo)}
onOpenItem={(itemId) => setView({ ...itemView, itemId })}
onBack={() => back(detailTargetFor(itemView))}
onOpenItem={(itemId) => openItem(itemCategory.id, itemId)}
/>
)
}
@@ -190,11 +267,11 @@ export default function App() {
<div className="layout">
<Sidebar
projects={projects}
nav={catalog.nav}
categories={sidebarCategories}
live={live}
refreshing={refreshing}
onRefresh={refresh}
activeCategoryId={detailView?.categoryId ?? itemView?.categoryId}
activeCategoryId={viewCategoryId}
activeGroupId={detailView?.groupId}
onNavigate={navigate}
/>
@@ -244,9 +321,7 @@ export default function App() {
<HomeView
categories={visibleCategories}
onOpenDetail={openDetail}
onOpenItem={(categoryId, itemId) =>
openItem(categoryId, itemId, { type: 'home' })
}
onOpenItem={openItem}
/>
{searching && visibleCategories.length === 0 && (
@@ -263,12 +338,14 @@ export default function App() {
category={detailCategory}
groupId={detailView.groupId}
onBack={goHome}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId, detailView)}
onOpenItem={(itemId) => openItem(detailCategory.id, itemId)}
/>
)}
{detailView !== undefined && detailCategory === undefined && (
<p className="section-empty">分类不存在</p>
<p className="section-empty">
{dataPending ? '正在读取内容库…' : '分类不存在'}
</p>
)}
{itemView !== undefined && renderItemView()}