Files
stash/stash-web/src/data/view.ts
CNWei 19ead7b716 feat(web): 地址栏驱动视图,刷新详情页不再回首页
- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退
- 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点
- 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404
- 明细页返回按钮去掉左侧箭头图标
2026-10-24 17:18:30 +08:00

125 lines
4.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 视图与地址的对应关系:地址栏是唯一的视图状态源。
*
* 控制台是单页应用,但「当前在哪一页」写在 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
}
}