Files
hjc-web/app/composables/useRecommend.ts
T
gxwebsoft 2b69686795 feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
2026-09-08 12:13:44 +08:00

166 lines
6.3 KiB
TypeScript
Raw 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.
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<RecommendType, string> = {
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<T extends Record<string, any>>(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<T> {
items: Ref<T[]>
loading: Ref<boolean>
error: Ref<string | null>
/** 占位:保持旧接口兼容;useFetch 自动处理数据获取,无需手动调用 */
load: () => Promise<void>
}
/**
* 读取推荐/置顶内容
* @example
* const { items } = useRecommend<Article>('article', { limit: 3 })
*/
export function useRecommend<T extends Record<string, any> = any>(
type: RecommendType,
options: UseRecommendOptions = {}
): UseRecommendReturn<T> {
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<string, any> = { 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<T[]>(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<T[]>(() => (data.value || []) as T[])
const loading = computed(() => pending.value)
const error = computed<string | null>(() => (fetchError.value ? String(fetchError.value) : null))
// 保持旧接口兼容:调用方仍可 await load(),但 useFetch 已经自动完成获取
async function load(): Promise<void> {
// no-opuseFetch 在 setup 中自动触发,客户端路由切换也会自动重新获取
}
return { items, loading, error, load }
}