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()}
|
||||
|
||||
+1
-14
@@ -1,4 +1,3 @@
|
||||
import type { NavModel } from './data/nav'
|
||||
import type { Category } from './data/types'
|
||||
|
||||
export type Project = {
|
||||
@@ -79,28 +78,17 @@ const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
|
||||
|
||||
type CatalogPayload = {
|
||||
categories?: Category[]
|
||||
nav?: NavModel
|
||||
}
|
||||
|
||||
/** 内容目录的完整返回:分类树 + 侧栏导航 */
|
||||
/** 内容目录的完整返回;侧栏由这份分类树派生 */
|
||||
export type Catalog = {
|
||||
categories: Category[]
|
||||
nav: NavModel
|
||||
}
|
||||
|
||||
/** 服务端字段可能缺失,统一在这里补成空数组,组件层就不用判空 */
|
||||
function normalizeNav(nav: NavModel | undefined): NavModel {
|
||||
return {
|
||||
topLevel: Array.isArray(nav?.topLevel) ? nav.topLevel : [],
|
||||
groups: Array.isArray(nav?.groups) ? nav.groups : [],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取完整内容目录。
|
||||
*
|
||||
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
|
||||
* 分类与导航一起下发,因为首页和侧栏同时需要它们。
|
||||
*/
|
||||
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
|
||||
const response = await fetch(CATALOG_ENDPOINT, {
|
||||
@@ -116,7 +104,6 @@ export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
|
||||
|
||||
return {
|
||||
categories: Array.isArray(payload.categories) ? payload.categories : [],
|
||||
nav: normalizeNav(payload.nav),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,10 +58,6 @@ export function ArrowUpIcon({ className }: IconProps) {
|
||||
return <i className={base('arrow-up', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function ArrowLeftIcon({ className }: IconProps) {
|
||||
return <i className={base('arrow-left', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
export function ArrowDownIcon({ className }: IconProps) {
|
||||
return <i className={base('arrow-down', className)} aria-hidden="true" />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 极简 Markdown 渲染:只覆盖文章正文实际用到的子集。
|
||||
*
|
||||
* 支持:
|
||||
* - `## 小标题`(1~6 级井号都渲染成同一级小标题,标题层级由版式决定)
|
||||
* - 空行分段
|
||||
* - 单独一行的 `` 作为配图
|
||||
* - 行内 `**粗体**`、`` `代码` ``、`[文字](链接)`
|
||||
*
|
||||
* 不支持列表、表格、引用块——这里渲染的是文章正文,不是通用 Markdown 编辑器。
|
||||
* 需要完整语法时再换 react-markdown,渲染层已经隔离在这个组件里。
|
||||
*/
|
||||
|
||||
type Block =
|
||||
| { kind: 'heading'; text: string }
|
||||
| { kind: 'image'; alt: string; src: string }
|
||||
| { kind: 'paragraph'; text: string }
|
||||
|
||||
/** 行内语法:粗体 / 代码 / 链接;其余按纯文本处理 */
|
||||
const INLINE_PATTERN = /(\*\*[^*]+\*\*|`[^`]+`|\[[^\]]+\]\([^)]+\))/g
|
||||
|
||||
/** 把 Markdown 文本切成块;规则尽量简单,宁可少解析也不误伤正文 */
|
||||
function parseBlocks(markdown: string): Block[] {
|
||||
const blocks: Block[] = []
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n')
|
||||
|
||||
let paragraph: string[] = []
|
||||
|
||||
// 空行或遇到标题/图片时,把攒着的行收成一个段落
|
||||
const flush = () => {
|
||||
if (paragraph.length === 0) return
|
||||
blocks.push({ kind: 'paragraph', text: paragraph.join(' ') })
|
||||
paragraph = []
|
||||
}
|
||||
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim()
|
||||
|
||||
if (line === '') {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
|
||||
const heading = /^#{1,6}\s+(.*)$/.exec(line)
|
||||
if (heading !== null) {
|
||||
flush()
|
||||
blocks.push({ kind: 'heading', text: heading[1] })
|
||||
continue
|
||||
}
|
||||
|
||||
const image = /^!\[([^\]]*)\]\(([^)\s]+)\)$/.exec(line)
|
||||
if (image !== null) {
|
||||
flush()
|
||||
blocks.push({ kind: 'image', alt: image[1], src: image[2] })
|
||||
continue
|
||||
}
|
||||
|
||||
paragraph.push(line)
|
||||
}
|
||||
|
||||
flush()
|
||||
return blocks
|
||||
}
|
||||
|
||||
/** 渲染一行里的行内语法 */
|
||||
function renderInline(text: string): ReactNode[] {
|
||||
return text
|
||||
.split(INLINE_PATTERN)
|
||||
.filter((part) => part !== '')
|
||||
.map((part, index) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) {
|
||||
return <strong key={index}>{part.slice(2, -2)}</strong>
|
||||
}
|
||||
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return <code key={index}>{part.slice(1, -1)}</code>
|
||||
}
|
||||
|
||||
const link = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(part)
|
||||
if (link !== null) {
|
||||
return (
|
||||
<a key={index} href={link[2]} target="_blank" rel="noreferrer">
|
||||
{link[1]}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
return <span key={index}>{part}</span>
|
||||
})
|
||||
}
|
||||
|
||||
type ArticleBodyProps = {
|
||||
/** Markdown 正文;为空时组件渲染空内容 */
|
||||
markdown?: string
|
||||
}
|
||||
|
||||
export function ArticleBody({ markdown }: ArticleBodyProps) {
|
||||
if (markdown === undefined || markdown.trim() === '') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{parseBlocks(markdown).map((block, index) => {
|
||||
if (block.kind === 'heading') {
|
||||
return (
|
||||
<h3 className="article-body-heading" key={index}>
|
||||
{renderInline(block.text)}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.kind === 'image') {
|
||||
return (
|
||||
<img
|
||||
className="article-body-image"
|
||||
key={index}
|
||||
src={block.src}
|
||||
alt={block.alt}
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="article-body-text" key={index}>
|
||||
{renderInline(block.text)}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ArticleItem } from '../../data/types'
|
||||
import { ArrowLeftIcon } from '../common/Icon'
|
||||
import { ArticleBody } from './ArticleBody'
|
||||
|
||||
/**
|
||||
* 文章详情页(对应模板图二):
|
||||
* 大标题 + [编辑] + 元信息行 + 白色正文卡(段落 / 小标题 / 配图混排)。
|
||||
* 大标题 + [编辑] + 元信息行 + 白色正文卡。
|
||||
*
|
||||
* 正文是 block 数组,后端以后可以直接把富文本结构映射过来,
|
||||
* 不用再改渲染层。
|
||||
* 正文是一整段 Markdown,交给 ArticleBody 解析渲染;
|
||||
* 渲染细节(支持哪些语法)都收在那一个组件里。
|
||||
*/
|
||||
type ArticleDetailViewProps = {
|
||||
article: ArticleItem
|
||||
@@ -21,7 +21,6 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
|
||||
<div className="detail-view article-view">
|
||||
<div className="detail-nav card-box">
|
||||
<button type="button" className="detail-back" onClick={onBack}>
|
||||
<ArrowLeftIcon />
|
||||
返回
|
||||
</button>
|
||||
<span className="detail-breadcrumb">{categoryTitle} / 文章</span>
|
||||
@@ -46,33 +45,7 @@ export function ArticleDetailView({ article, categoryTitle, onBack }: ArticleDet
|
||||
</header>
|
||||
|
||||
<article className="article-body card-box">
|
||||
{(article.blocks ?? []).map((block, index) => {
|
||||
if (block.type === 'heading') {
|
||||
return (
|
||||
<h3 className="article-body-heading" key={index}>
|
||||
{block.text}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
return (
|
||||
<img
|
||||
className="article-body-image"
|
||||
key={index}
|
||||
src={block.src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p className="article-body-text" key={index}>
|
||||
{block.text}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
<ArticleBody markdown={article.body} />
|
||||
</article>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { categoryCount, type Category } from '../../data/types'
|
||||
import { ArrowLeftIcon } from '../common/Icon'
|
||||
import { AppGrid } from './AppGrid'
|
||||
import { CesCardGrid } from './CesCardGrid'
|
||||
import { GroupBlock } from './GroupBlock'
|
||||
@@ -68,7 +67,6 @@ export function DetailView({ category, groupId, onBack, onOpenItem }: DetailView
|
||||
<div className="detail-view">
|
||||
<div className="detail-nav card-box">
|
||||
<button type="button" className="detail-back" onClick={onBack}>
|
||||
<ArrowLeftIcon />
|
||||
返回首页
|
||||
</button>
|
||||
<span className="detail-breadcrumb">{breadcrumb}</span>
|
||||
|
||||
@@ -22,8 +22,10 @@ export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps
|
||||
{categories.map((category) => (
|
||||
<SectionCard
|
||||
key={category.id}
|
||||
title={category.title}
|
||||
count={categoryCount(category)}
|
||||
// 卡片标题跟着实际展示的内容走:配了展示块就用块标题(如「常用站点」),
|
||||
// 没配(如「本地服务」)才用分类标题
|
||||
title={category.block?.title ?? category.title}
|
||||
count={cardCount(category)}
|
||||
barColor={category.barColor}
|
||||
onMore={() => onOpenDetail(category.id)}
|
||||
>
|
||||
@@ -34,13 +36,36 @@ export function HomeView({ categories, onOpenDetail, onOpenItem }: HomeViewProps
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片右上角的数量角标。
|
||||
*
|
||||
* 配了「分组展示块」时,卡片实际展示的是那个分组,所以数量也应该只数它;
|
||||
* 否则(电影 / 文章 / 本地服务)数整个分区。
|
||||
*/
|
||||
function cardCount(category: Category): number {
|
||||
if (category.kind === 'apps' && category.block !== undefined) {
|
||||
const group = category.groups.find((item) => item.id === category.block?.nodeKey)
|
||||
if (group !== undefined) return group.items.length
|
||||
}
|
||||
|
||||
return categoryCount(category)
|
||||
}
|
||||
|
||||
/** 按分类类型输出对应的预览网格;应用类为空时给出提示文案 */
|
||||
function renderPreview(
|
||||
category: Category,
|
||||
onOpenItem: (categoryId: string, itemId: string) => void,
|
||||
) {
|
||||
if (category.kind === 'apps') {
|
||||
const items = flattenAppItems(category).slice(0, category.previewLimit ?? 4)
|
||||
// 配了展示块就只展示那个分组;没有配置(如「本地服务」)则退回全部条目
|
||||
const blockGroup =
|
||||
category.block !== undefined
|
||||
? category.groups.find((group) => group.id === category.block?.nodeKey)
|
||||
: undefined
|
||||
|
||||
const source = category.block !== undefined ? (blockGroup?.items ?? []) : flattenAppItems(category)
|
||||
const limit = category.block?.previewLimit ?? category.previewLimit ?? 4
|
||||
const items = source.slice(0, limit)
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="section-empty">{category.emptyText ?? '暂无内容'}</p>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ItemDetailModel } from '../../data/detail'
|
||||
import type { RelatedItem } from '../../data/types'
|
||||
import { ExternalIcon, EyeIcon, ShareIcon, ThumbUpIcon } from '../common/Icon'
|
||||
import { ArrowLeftIcon } from '../common/Icon'
|
||||
import { RelatedList } from './RelatedList'
|
||||
|
||||
/**
|
||||
@@ -30,7 +29,6 @@ export function ItemDetailView({
|
||||
<div className="detail-view item-view">
|
||||
<div className="detail-nav card-box">
|
||||
<button type="button" className="detail-back" onClick={onBack}>
|
||||
<ArrowLeftIcon />
|
||||
返回
|
||||
</button>
|
||||
<span className="detail-breadcrumb">{breadcrumb}</span>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -17,12 +17,14 @@ export type HostedProject = {
|
||||
/**
|
||||
* 把托管项目映射成应用条目。
|
||||
*
|
||||
* id 直接用路由地址,保证同一项目在前端各处(首页卡片、明细、详情)唯一。
|
||||
* id 用项目名(就是目录名):它在前端各处(首页卡片、明细、详情)唯一,
|
||||
* 又正好是可以直接放进地址栏的一段——条目详情地址是 `/c/local/<分组>/i/<项目名>`。
|
||||
* 访问地址仍然用 `route_path`(`/demo/`),两者不再混用。
|
||||
* 本地项目没有封面图,详情页会退化成字符头像。
|
||||
*/
|
||||
function toLocalItem(project: HostedProject): AppItem {
|
||||
return {
|
||||
id: project.route_path,
|
||||
id: project.name,
|
||||
name: project.name,
|
||||
desc: project.route_path,
|
||||
icon: project.name.slice(0, 1).toUpperCase(),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 侧栏分类导航的数据结构。
|
||||
* 导航目标。
|
||||
*
|
||||
* 导航数据存在内容库的 `nav_entry` 表里(自引用树),随 `/api/catalog` 一起下发;
|
||||
* 这里只保留类型定义和空模型兜底,不再有写死的导航常量。
|
||||
* 侧栏现在直接由内容树(分区 → 分组)派生,不再有独立的导航数据模型;
|
||||
* 这里只保留「点击后要去哪」的最小类型。
|
||||
*/
|
||||
|
||||
export type NavTarget = {
|
||||
@@ -10,26 +10,3 @@ export type NavTarget = {
|
||||
/** 指定后明细页只展示该分组 */
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
/** 一个可点击的导航叶子 */
|
||||
export type NavLeaf = {
|
||||
id: string
|
||||
label: string
|
||||
target: NavTarget
|
||||
}
|
||||
|
||||
/** 可折叠分组:本身不可点击,只负责收纳叶子 */
|
||||
export type NavGroupNode = {
|
||||
id: string
|
||||
label: string
|
||||
leaves: NavLeaf[]
|
||||
}
|
||||
|
||||
/** 侧栏的完整数据模型:顶层直达项 + 可折叠分组 */
|
||||
export type NavModel = {
|
||||
topLevel: NavLeaf[]
|
||||
groups: NavGroupNode[]
|
||||
}
|
||||
|
||||
/** 内容库还没有导航数据时的兜底,避免渲染时到处判空 */
|
||||
export const EMPTY_NAV: NavModel = { topLevel: [], groups: [] }
|
||||
|
||||
@@ -30,10 +30,12 @@ export type AppItem = {
|
||||
likes?: number
|
||||
}
|
||||
|
||||
/** 应用分组:明细页按组展示,首页取所有分组的前几项做预览 */
|
||||
/** 应用分组:明细页按组展示,首页按展示块取其中一组做预览 */
|
||||
export type AppGroup = {
|
||||
id: string
|
||||
name: string
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
items: AppItem[]
|
||||
}
|
||||
|
||||
@@ -63,12 +65,6 @@ export type RelatedItem = {
|
||||
icon?: string
|
||||
}
|
||||
|
||||
/** 文章正文块:段落 / 小标题 / 配图 */
|
||||
export type ArticleBlock =
|
||||
| { type: 'paragraph'; text: string }
|
||||
| { type: 'heading'; text: string }
|
||||
| { type: 'image'; src: string }
|
||||
|
||||
/** 图文条目(报道) */
|
||||
export type ArticleItem = {
|
||||
id: string
|
||||
@@ -77,8 +73,18 @@ export type ArticleItem = {
|
||||
cover: string
|
||||
/** 详情页标题下的元信息,如 ['2 年前', 'CES 现场报道'] */
|
||||
meta?: string[]
|
||||
/** 详情页正文 */
|
||||
blocks?: ArticleBlock[]
|
||||
/** 详情页正文:一整段 Markdown(一篇一段文本,不再拆块) */
|
||||
body?: string
|
||||
}
|
||||
|
||||
/** 首页展示块:分区卡片展示哪个节点、显示几条 */
|
||||
export type SectionBlock = {
|
||||
/** 要展示的节点业务键:通常是分组;电影/文章这类无分组的填分区自己 */
|
||||
nodeKey: string
|
||||
/** 块标题,缺省用节点标题 */
|
||||
title?: string
|
||||
/** 卡片显示几条,缺省用分类的 previewLimit 或默认值 */
|
||||
previewLimit?: number
|
||||
}
|
||||
|
||||
/** 应用类分类:内部按分组组织 */
|
||||
@@ -95,6 +101,10 @@ export type AppCategory = {
|
||||
previewLimit?: number
|
||||
/** 首页预览为空时的提示文案 */
|
||||
emptyText?: string
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
/** 首页卡片展示哪一块内容 */
|
||||
block?: SectionBlock
|
||||
}
|
||||
|
||||
/** 海报类分类:竖版海报网格 */
|
||||
@@ -107,6 +117,10 @@ export type PosterCategory = {
|
||||
items: PosterItem[]
|
||||
/** 首页预览条数,默认 6 */
|
||||
previewLimit?: number
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
/** 首页卡片展示哪一块内容 */
|
||||
block?: SectionBlock
|
||||
}
|
||||
|
||||
/** 图文类分类:上图下文卡片网格 */
|
||||
@@ -119,6 +133,10 @@ export type ArticleCategory = {
|
||||
items: ArticleItem[]
|
||||
/** 首页预览条数,默认 4 */
|
||||
previewLimit?: number
|
||||
/** 是否出现在侧栏;缺省视为 true */
|
||||
showInSidebar?: boolean
|
||||
/** 首页卡片展示哪一块内容 */
|
||||
block?: SectionBlock
|
||||
}
|
||||
|
||||
export type Category = AppCategory | PosterCategory | ArticleCategory
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 视图与地址的对应关系:地址栏是唯一的视图状态源。
|
||||
*
|
||||
* 控制台是单页应用,但「当前在哪一页」写在 URL 里,所以刷新、收藏、
|
||||
* 前进后退都不会丢上下文。服务端对应的规则在 `src/server.rs`:
|
||||
* `/c/**` 没有实体文件时回落到内嵌的 `index.html`,由前端按 pathname 重新出页面。
|
||||
*
|
||||
* 路由形状(各段都是业务键,也就是接口 JSON 里的 `id`):
|
||||
*
|
||||
* / 首页
|
||||
* /c/<分区> 分类明细
|
||||
* /c/<分区>/<分组> 只展示该分组
|
||||
* /c/<分区>/i/<条目> 条目详情,返回 → 分类明细
|
||||
* /c/<分区>/<分组>/i/<条目> 条目详情,返回 → 该分组
|
||||
*
|
||||
* 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」
|
||||
* 仍然落回条目所属的分组,而不是去猜一个返回目标。
|
||||
*
|
||||
* 这里只做「地址 ↔ 视图」的纯转换,读写 history 的部分在 `useViewRoute.ts`。
|
||||
*/
|
||||
|
||||
export type DetailRoute = {
|
||||
type: 'detail'
|
||||
categoryId: string
|
||||
/** 指定后明细页只展示该分组 */
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export type ItemRoute = {
|
||||
type: 'item'
|
||||
categoryId: string
|
||||
itemId: string
|
||||
/**
|
||||
* 条目所在的分组(应用才有)。
|
||||
*
|
||||
* 它只描述「条目在哪」,不描述「从哪来」:返回目标由它推导,
|
||||
* 这样同一个条目永远只有一个地址,刷新和分享都不会跑偏。
|
||||
*/
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export type View =
|
||||
| { type: 'home' }
|
||||
| DetailRoute
|
||||
| ItemRoute
|
||||
|
||||
/** 前端路由前缀;改动这里要同步 `src/server.rs` 的 `SPA_PREFIXES`。 */
|
||||
const CATEGORY_PREFIX = '/c/'
|
||||
/** 前缀去掉两侧斜杠后的那一段,拼地址时当第一个路径段用 */
|
||||
const CATEGORY_SEGMENT = CATEGORY_PREFIX.slice(1, -1)
|
||||
/** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */
|
||||
const ITEM_MARK = 'i'
|
||||
|
||||
/** 首页视图;未知地址一律归到这里 */
|
||||
export const HOME_VIEW: View = { type: 'home' }
|
||||
|
||||
/** 把视图转成地址。 */
|
||||
export function viewPath(view: View): string {
|
||||
if (view.type === 'home') return '/'
|
||||
|
||||
const segments = [CATEGORY_SEGMENT, encodeSegment(view.categoryId)]
|
||||
if (view.groupId !== undefined) segments.push(encodeSegment(view.groupId))
|
||||
if (view.type === 'item') segments.push(ITEM_MARK, encodeSegment(view.itemId))
|
||||
|
||||
return `/${segments.join('/')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 把地址解析成视图;看不懂的地址(段数不对、百分号编码非法)按首页处理。
|
||||
*
|
||||
* 这里只做形状校验,不判断分类 / 条目是否真的存在——那是数据层的事,
|
||||
* 而且首屏还没拿到内容库时,它们本来就「暂时不存在」。
|
||||
*/
|
||||
export function parseViewPath(pathname: string): View {
|
||||
if (!pathname.startsWith(CATEGORY_PREFIX)) return HOME_VIEW
|
||||
|
||||
const segments = pathname
|
||||
.slice(CATEGORY_PREFIX.length)
|
||||
.split('/')
|
||||
.filter((segment) => segment !== '')
|
||||
|
||||
const [categorySegment] = segments
|
||||
if (categorySegment === undefined) return HOME_VIEW
|
||||
|
||||
const categoryId = decodeSegment(categorySegment)
|
||||
if (categoryId === undefined) return HOME_VIEW
|
||||
|
||||
if (segments.length === 1) return { type: 'detail', categoryId }
|
||||
|
||||
if (segments.length === 2) {
|
||||
const groupId = decodeSegment(segments[1])
|
||||
if (groupId === undefined) return HOME_VIEW
|
||||
return { type: 'detail', categoryId, groupId }
|
||||
}
|
||||
|
||||
if (segments.length === 3 && segments[1] === ITEM_MARK) {
|
||||
const itemId = decodeSegment(segments[2])
|
||||
if (itemId === undefined) return HOME_VIEW
|
||||
return { type: 'item', categoryId, itemId }
|
||||
}
|
||||
|
||||
if (segments.length === 4 && segments[2] === ITEM_MARK) {
|
||||
const groupId = decodeSegment(segments[1])
|
||||
const itemId = decodeSegment(segments[3])
|
||||
if (groupId === undefined || itemId === undefined) return HOME_VIEW
|
||||
return { type: 'item', categoryId, itemId, groupId }
|
||||
}
|
||||
|
||||
return HOME_VIEW
|
||||
}
|
||||
|
||||
/** 单个路径段编码:id 里可能带斜杠、空格、中文 */
|
||||
function encodeSegment(value: string): string {
|
||||
return encodeURIComponent(value)
|
||||
}
|
||||
|
||||
/** 解码单个路径段;百分号编码非法时返回 undefined */
|
||||
function decodeSegment(segment: string): string | undefined {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { fetchCatalog, subscribeCatalog } from './api'
|
||||
import { EMPTY_NAV, type NavModel } from './data/nav'
|
||||
import type { Category } from './data/types'
|
||||
|
||||
export type CatalogStatus = 'loading' | 'ready' | 'error'
|
||||
@@ -8,8 +7,6 @@ export type CatalogStatus = 'loading' | 'ready' | 'error'
|
||||
export type CatalogState = {
|
||||
status: CatalogStatus
|
||||
categories: Category[]
|
||||
/** 侧栏导航;与分类同一次请求下发 */
|
||||
nav: NavModel
|
||||
error: string | null
|
||||
}
|
||||
|
||||
@@ -32,7 +29,6 @@ export function useCatalog(): CatalogController {
|
||||
const [state, setState] = useState<CatalogState>({
|
||||
status: 'loading',
|
||||
categories: [],
|
||||
nav: EMPTY_NAV,
|
||||
error: null,
|
||||
})
|
||||
const controllerRef = useRef<AbortController | null>(null)
|
||||
@@ -54,7 +50,6 @@ export function useCatalog(): CatalogController {
|
||||
setState({
|
||||
status: 'ready',
|
||||
categories: catalog.categories,
|
||||
nav: catalog.nav,
|
||||
error: null,
|
||||
})
|
||||
}
|
||||
@@ -65,7 +60,6 @@ export function useCatalog(): CatalogController {
|
||||
// 已经有内容时只提示错误,不清空列表
|
||||
status: previous.categories.length > 0 ? 'ready' : 'error',
|
||||
categories: previous.categories,
|
||||
nav: previous.nav,
|
||||
error: toErrorMessage(error),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { parseViewPath, viewPath, type View } from './data/view'
|
||||
|
||||
/**
|
||||
* 视图 ↔ 地址同步。
|
||||
*
|
||||
* 三个入口都要覆盖:首屏(解析地址)、点击(写地址)、浏览器前进后退(popstate)。
|
||||
* 视图本身仍然放在 React state 里,地址只是它的镜像,避免每次渲染都去读 URL;
|
||||
* 地址与视图的换算规则收在 `data/view.ts`,这里只管 history。
|
||||
*/
|
||||
|
||||
/** 存在 history.state 里的键:当前这条记录是应用内第几页(0 = 没有上一页) */
|
||||
const HISTORY_INDEX_KEY = 'stashIndex'
|
||||
|
||||
type StashHistoryState = { [HISTORY_INDEX_KEY]?: number }
|
||||
|
||||
/** 读取当前历史深度;不是本应用写的记录一律当作 0 */
|
||||
function historyIndex(): number {
|
||||
const state = window.history.state as StashHistoryState | null
|
||||
const index = state === null ? undefined : state[HISTORY_INDEX_KEY]
|
||||
return typeof index === 'number' ? index : 0
|
||||
}
|
||||
|
||||
/** 当前 history.state 的副本,写入前先保留别人(如浏览器扩展)放的字段 */
|
||||
function currentHistoryState(): StashHistoryState {
|
||||
const state = window.history.state as StashHistoryState | null
|
||||
return state === null ? {} : { ...state }
|
||||
}
|
||||
|
||||
export type ViewRoute = {
|
||||
/** 当前视图,初值来自地址栏 */
|
||||
view: View
|
||||
/** 打开一个视图:默认压一条新历史,`replace` 表示替换当前这条 */
|
||||
go: (view: View, options?: { replace?: boolean }) => void
|
||||
/**
|
||||
* 返回。
|
||||
*
|
||||
* 应用内有上一页时交给浏览器(`history.back()`),
|
||||
* 直接打开条目地址(收藏 / 刷新后没有上一页)时退到 `fallback`,
|
||||
* 这时它替换当前记录,避免在历史里留下一条「自己指向自己」的条目。
|
||||
*/
|
||||
back: (fallback: View) => void
|
||||
}
|
||||
|
||||
export function useViewRoute(): ViewRoute {
|
||||
const [view, setView] = useState<View>(() => parseViewPath(window.location.pathname))
|
||||
|
||||
// 首屏把地址规范化:去掉多余的斜杠 / 尾部斜杠,未知地址归到首页,
|
||||
// 并补上历史深度(直接打开时是 0,说明应用内没有上一页)
|
||||
useEffect(() => {
|
||||
const canonical = viewPath(parseViewPath(window.location.pathname))
|
||||
const state = currentHistoryState()
|
||||
const recorded = state[HISTORY_INDEX_KEY] !== undefined
|
||||
|
||||
if (canonical === window.location.pathname && recorded) return
|
||||
|
||||
window.history.replaceState({ ...state, [HISTORY_INDEX_KEY]: historyIndex() }, '', canonical)
|
||||
}, [])
|
||||
|
||||
// 前进后退:地址变了,视图跟着变
|
||||
useEffect(() => {
|
||||
const handlePopState = () => setView(parseViewPath(window.location.pathname))
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
return () => window.removeEventListener('popstate', handlePopState)
|
||||
}, [])
|
||||
|
||||
const go = useCallback((next: View, options?: { replace?: boolean }) => {
|
||||
const path = viewPath(next)
|
||||
const index = historyIndex()
|
||||
|
||||
// 已经在同一页(例如重复点同一个侧栏项):不写历史,也不改深度
|
||||
if (path === window.location.pathname) {
|
||||
setView(next)
|
||||
return
|
||||
}
|
||||
|
||||
const replace = options?.replace === true
|
||||
const state: StashHistoryState = {
|
||||
...currentHistoryState(),
|
||||
[HISTORY_INDEX_KEY]: replace ? index : index + 1,
|
||||
}
|
||||
|
||||
if (replace) {
|
||||
window.history.replaceState(state, '', path)
|
||||
} else {
|
||||
window.history.pushState(state, '', path)
|
||||
}
|
||||
|
||||
setView(next)
|
||||
}, [])
|
||||
|
||||
const back = useCallback(
|
||||
(fallback: View) => {
|
||||
if (historyIndex() > 0) {
|
||||
window.history.back()
|
||||
return
|
||||
}
|
||||
|
||||
go(fallback, { replace: true })
|
||||
},
|
||||
[go],
|
||||
)
|
||||
|
||||
return { view, go, back }
|
||||
}
|
||||
Reference in New Issue
Block a user