/** * 视图与地址的对应关系:地址栏是唯一的视图状态源。 * * 控制台是单页应用,但「当前在哪一页」写在 URL 里,所以刷新、收藏、 * 前进后退都不会丢上下文。服务端对应的规则在 `src/server.rs`: * `/c/**` 没有实体文件时回落到内嵌的 `index.html`,由前端按 pathname 重新出页面。 * * 路由形状(各段都是业务键,也就是接口 JSON 里的 `id`): * * / 首页 * /c/<分区> 分类明细 * /c/<分区>/<分组> 只展示该分组 * /c/<分区>/i/<条目> 条目详情,返回 → 分类明细 * /c/<分区>/<分组>/i/<条目> 条目详情,返回 → 该分组 * * 条目地址带上它自己所在的分组,URL 才是自解释的:刷新之后点「返回」 * 仍然落回条目所属的分组,而不是去猜一个返回目标。 * * 这里只做「视图 → 地址」的纯转换,读写 history 的部分在 `AppNext.tsx`。 */ 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_SEGMENT = 'c' /** 条目段标记:`/c/<分区>[/<分组>]/i/<条目>` */ const ITEM_MARK = 'i' /** 把视图转成地址。 */ 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('/')}` } /** 单个路径段编码:id 里可能带斜杠、空格、中文 */ function encodeSegment(value: string): string { return encodeURIComponent(value) }