- 新增 data/view.ts 与 useViewRoute:视图与地址双向同步,支持前进后退 - 条目地址带上所属分组,刷新 / 收藏后「返回」有确定落点 - 服务端只把 /c/** 回落到 index.html,其余路径仍返回 404 - 明细页返回按钮去掉左侧箭头图标
131 lines
3.8 KiB
TypeScript
131 lines
3.8 KiB
TypeScript
import type { Category } from './data/types'
|
|
|
|
export type Project = {
|
|
name: string
|
|
route_path: string
|
|
}
|
|
|
|
type ProjectsPayload = {
|
|
projects?: Project[]
|
|
}
|
|
|
|
const PROJECTS_ENDPOINT = '/api/projects'
|
|
const EVENTS_ENDPOINT = '/api/projects/events'
|
|
/** 读取服务端扫描出的项目列表。 */
|
|
export async function fetchProjects(signal?: AbortSignal): Promise<Project[]> {
|
|
const response = await fetch(PROJECTS_ENDPOINT, {
|
|
signal,
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`请求 ${PROJECTS_ENDPOINT} 失败:HTTP ${response.status}`)
|
|
}
|
|
|
|
const payload = (await response.json()) as ProjectsPayload
|
|
return Array.isArray(payload.projects) ? payload.projects : []
|
|
}
|
|
|
|
export type ProjectEventsHandlers = {
|
|
onProjects: (projects: Project[]) => void
|
|
/** SSE 连接建立 / 断开时回调,浏览器的 EventSource 会自行重连。 */
|
|
onConnectionChange?: (connected: boolean) => void
|
|
}
|
|
|
|
/**
|
|
* 订阅服务端的项目快照推送。
|
|
* 浏览器不支持 SSE 时静默降级为空清理函数,页面仍可用手动刷新。
|
|
*/
|
|
export function subscribeProjects(handlers: ProjectEventsHandlers): () => void {
|
|
if (typeof EventSource === 'undefined') {
|
|
return () => {}
|
|
}
|
|
|
|
const source = new EventSource(EVENTS_ENDPOINT)
|
|
|
|
const handleProjects = (event: Event) => {
|
|
try {
|
|
const payload = JSON.parse((event as MessageEvent<string>).data) as ProjectsPayload
|
|
if (Array.isArray(payload.projects)) {
|
|
handlers.onProjects(payload.projects)
|
|
}
|
|
} catch {
|
|
// 单条事件格式异常时保留旧数据,等待下一次推送
|
|
}
|
|
}
|
|
|
|
const handleOpen = () => handlers.onConnectionChange?.(true)
|
|
const handleError = () => handlers.onConnectionChange?.(false)
|
|
|
|
source.addEventListener('projects', handleProjects)
|
|
source.addEventListener('open', handleOpen)
|
|
source.addEventListener('error', handleError)
|
|
|
|
return () => {
|
|
source.removeEventListener('projects', handleProjects)
|
|
source.removeEventListener('open', handleOpen)
|
|
source.removeEventListener('error', handleError)
|
|
source.close()
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 内容目录:数据来自后端的 SQLite 内容库(data/stash.db),不再是前端静态数组
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const CATALOG_ENDPOINT = '/api/catalog'
|
|
const CATALOG_EVENTS_ENDPOINT = '/api/catalog/events'
|
|
|
|
type CatalogPayload = {
|
|
categories?: Category[]
|
|
}
|
|
|
|
/** 内容目录的完整返回;侧栏由这份分类树派生 */
|
|
export type Catalog = {
|
|
categories: Category[]
|
|
}
|
|
|
|
/**
|
|
* 拉取完整内容目录。
|
|
*
|
|
* 数据量只有几十 KB,一次全量取回即可;搜索仍然在前端按关键字过滤。
|
|
*/
|
|
export async function fetchCatalog(signal?: AbortSignal): Promise<Catalog> {
|
|
const response = await fetch(CATALOG_ENDPOINT, {
|
|
signal,
|
|
headers: { Accept: 'application/json' },
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`请求 ${CATALOG_ENDPOINT} 失败:HTTP ${response.status}`)
|
|
}
|
|
|
|
const payload = (await response.json()) as CatalogPayload
|
|
|
|
return {
|
|
categories: Array.isArray(payload.categories) ? payload.categories : [],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 订阅内容库变更。
|
|
*
|
|
* 服务端用 SQLite 的 data_version 检测「外部工具改了库」,事件里不带数据,
|
|
* 只表示「该重新拉一次了」,避免两套数据结构需要保持同步。
|
|
*/
|
|
export function subscribeCatalog(onChange: () => void): () => void {
|
|
if (typeof EventSource === 'undefined') {
|
|
return () => {}
|
|
}
|
|
|
|
const source = new EventSource(CATALOG_EVENTS_ENDPOINT)
|
|
const handleChange = () => onChange()
|
|
|
|
source.addEventListener('catalog', handleChange)
|
|
|
|
return () => {
|
|
source.removeEventListener('catalog', handleChange)
|
|
source.close()
|
|
}
|
|
}
|