import { useCallback, useEffect, useRef, useState } from 'react' import { fetchCatalog, subscribeCatalog } from './api' import type { Category } from './data/types' export type CatalogStatus = 'loading' | 'ready' | 'error' export type CatalogState = { status: CatalogStatus categories: Category[] error: string | null } export type CatalogController = CatalogState & { /** 手动重新拉取;内容库被外部修改后也会自动调用 */ reload: () => void } function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error) } /** * 内容目录:首屏拉取 + 监听服务端推送 + 手动重载。 * * 与 `useProjects` 的区别是失败策略:项目列表失败会保留旧数据, * 内容目录同样保留旧数据 —— 用户正在改库时偶尔读到坏 JSON 不应该把页面清空。 */ export function useCatalog(): CatalogController { const [state, setState] = useState({ status: 'loading', categories: [], error: null, }) const controllerRef = useRef(null) /** * 拉一次内容目录。 * * 用 ref 保存当前的 AbortController:外部改动可能连续触发多次刷新, * 只保留最后一次请求,避免旧响应把新数据覆盖回去。 */ const load = useCallback(() => { controllerRef.current?.abort() const controller = new AbortController() controllerRef.current = controller fetchCatalog(controller.signal) .then((catalog) => { if (!controller.signal.aborted) { setState({ status: 'ready', categories: catalog.categories, error: null, }) } }) .catch((error: unknown) => { if (!controller.signal.aborted) { setState((previous) => ({ // 已经有内容时只提示错误,不清空列表 status: previous.categories.length > 0 ? 'ready' : 'error', categories: previous.categories, error: toErrorMessage(error), })) } }) }, []) // 首屏加载:setState 只发生在 Promise 回调里,避免 effect 内同步 setState useEffect(() => { load() return () => controllerRef.current?.abort() }, [load]) // 内容库被外部修改时,服务端推送一次「变了」,这里重新拉取 useEffect(() => subscribeCatalog(load), [load]) return { ...state, reload: load } }