2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
166 lines
6.3 KiB
TypeScript
166 lines
6.3 KiB
TypeScript
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-op:useFetch 在 setup 中自动触发,客户端路由切换也会自动重新获取
|
||
}
|
||
|
||
return { items, loading, error, load }
|
||
}
|