- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
125 lines
4.2 KiB
TypeScript
125 lines
4.2 KiB
TypeScript
/**
|
||
* 视图与地址的对应关系:地址栏是唯一的视图状态源。
|
||
*
|
||
* 控制台是单页应用,但「当前在哪一页」写在 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
|
||
}
|
||
}
|