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 { 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).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 { 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() } }