import { computed, type Ref } from 'vue' import type { Article, Product, CaseItem } from '~/types' /** * 读取「推荐 / 置顶」精选内容的共享 composable * * 用途:首页文章 / 产品 / 案例区块读取后台勾选的推荐或置顶内容, * 抽成统一逻辑后供任意模板的推荐区块组件复用。 * * 数据源: * - 文章 article:上游 cms-article 返回 `recommend` 字段(0/1)→ 推荐资讯 * - 产品 product:上游 cms-product 返回 `top` 字段(>0 为置顶)→ 置顶/推荐产品 * - 案例 case: 上游 cms-case 当前表结构【无】recommend/top 字段, * 故默认回退为「最新案例」;代码已预留过滤,后台字段上线即自动切换为推荐 * * 过滤采用多字段兼容判定(recommend / top / isTop / isRecommend), * 兼容不同 CMS 版本与模型的命名差异。 * * 排序:推荐/置顶项优先,其次按 top 值、sortNumber、创建时间降序。 * * 实现要点(避免 SSR 水合问题): * - 使用 useFetch 而非 $fetch,让 Nuxt 在 SSR 时直接调用 server handler 并序列化到 payload, * 客户端水合时不再重新请求,确保 SSR/CSR 看到的数据完全一致。 * - SSR 时从 useRequestEvent().context.tenant 读取 tenantId 并透传给内部调用, * 防止 server/api 内部 loopback 调用因丢失 Host 上下文而无法解析租户。 */ export type RecommendType = 'article' | 'product' | 'case' const PATH_MAP: Record = { article: '/api/article/list', product: '/api/product/list', case: '/api/case/list' } /** 多字段兼容:判定单条记录是否属于「推荐 / 置顶」 */ function isRecommended(item: any): boolean { if (!item) return false const top = Number(item.top ?? 0) const isTop = Number(item.isTop ?? 0) const isRecommend = Number(item.isRecommend ?? 0) const recommend = item.recommend return ( top > 0 || isTop > 0 || isRecommend > 0 || recommend === 1 || recommend === true ) } /** 推荐/置顶优先排序 */ function sortRecommended>(list: T[]): T[] { return [...list].sort((a, b) => { const ra = isRecommended(a) ? 1 : 0 const rb = isRecommended(b) ? 1 : 0 if (ra !== rb) return rb - ra const ta = Number(a?.top ?? 0) const tb = Number(b?.top ?? 0) if (tb !== ta) return tb - ta const sa = Number(a?.sortNumber ?? 0) const sb = Number(b?.sortNumber ?? 0) if (sa !== sb) return sa - sb const ca = a?.createTime ? new Date(a.createTime).getTime() : 0 const cb = b?.createTime ? new Date(b.createTime).getTime() : 0 return cb - ca }) } /** * 统一解包列表:兼容 article(上游信封 {data:{list}})与 product/case({list}) * * 幂等设计:传入已经是数组时原样返回。useFetch 的 transform 在 payload 复用 / * 重新校验等场景下有被再次施加于「已转换结果」的可能,若此时返回空数组会导致 * 区块凭空清空,故此处必须容忍数组入参。 */ function unwrapList(res: any): any[] { if (!res) return [] if (Array.isArray(res)) return res const data = res?.data ?? res if (Array.isArray(data?.list)) return data.list if (Array.isArray(res?.list)) return res.list return [] } export interface UseRecommendOptions { /** 展示条数(过滤 + 排序后截取),默认 3 */ limit?: number /** 拉取池大小(用于覆盖全部推荐项,避免遗漏),默认 50 */ poolSize?: number /** * 无推荐项时是否回退最新内容。 * 默认:case=true(上游无推荐字段,展示最新案例);article/product=false(返回空,区块按后台开关显示为空) */ fallbackToLatest?: boolean /** 按栏目过滤(传 navigationId);不传则全站跨栏目精选 */ navigationId?: number } export interface UseRecommendReturn { items: Ref loading: Ref error: Ref /** 占位:保持旧接口兼容;useFetch 自动处理数据获取,无需手动调用 */ load: () => Promise } /** * 读取推荐/置顶内容 * @example * const { items } = useRecommend
('article', { limit: 3 }) */ export function useRecommend = any>( type: RecommendType, options: UseRecommendOptions = {} ): UseRecommendReturn { const { limit = 3, poolSize = 50, fallbackToLatest = type === 'case', navigationId } = options // SSR 时透传租户上下文,避免内部 loopback 调用因 Host 被改写为 localhost // 而无法解析租户。仅在原始请求未显式带 TenantId header 时才补充,防止 // useFetch 合并原始 header 与显式 header 导致重复值(上游 CMS 会报 SQL 错误)。 const event = useRequestEvent() const tenantId = event?.context?.tenant?.tenantId as string | undefined const originalTenantHeader = event?.node?.req?.headers?.tenantid as string | undefined const needsTenantHeader = tenantId && !originalTenantHeader const query: Record = { page: 1, limit: poolSize } if (navigationId) query.navigationId = navigationId const key = `recommend:${type}:${navigationId ?? 'all'}:${poolSize}:${limit}:${fallbackToLatest ? 1 : 0}` const { data, pending, error: fetchError } = useFetch(PATH_MAP[type], { key, query, headers: needsTenantHeader ? { TenantId: tenantId } : undefined, transform: (res: any): T[] => { const rawList = unwrapList(res) const recommended = rawList.filter(isRecommended) if (recommended.length > 0) { return sortRecommended(recommended).slice(0, limit) as T[] } if (fallbackToLatest) { return sortRecommended(rawList).slice(0, limit) as T[] } return [] as T[] }, // 服务端获取失败时静默降级为空数组,避免页面 500 default: () => [] as T[] }) const items = computed(() => (data.value || []) as T[]) const loading = computed(() => pending.value) const error = computed(() => (fetchError.value ? String(fetchError.value) : null)) // 保持旧接口兼容:调用方仍可 await load(),但 useFetch 已经自动完成获取 async function load(): Promise { // no-op:useFetch 在 setup 中自动触发,客户端路由切换也会自动重新获取 } return { items, loading, error, load } }