feat(app): 添加多模板关于我们页面及相关路由和404页面

- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
2026-09-08 12:13:44 +08:00
commit 2b69686795
381 changed files with 59891 additions and 0 deletions
+165
View File
@@ -0,0 +1,165 @@
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 }
}