feat(web): 地址栏驱动视图,刷新详情页不再回首页
- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
This commit is contained in:
+108
-31
@@ -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()}
|
||||
|
||||
Reference in New Issue
Block a user