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
+123
View File
@@ -0,0 +1,123 @@
import { $fetch } from 'ofetch'
import type { ApiEnvelope } from '~/app/types'
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
/** app_template 表行(仅取解析目录名所需字段) */
interface AppTemplateRow {
id?: number
/** 模板标识,形如 'template-08',与前端 app/templates/<code>/ 目录一一对应 */
code?: string
name?: string
}
interface AppTemplatePage {
list?: AppTemplateRow[]
count?: number
}
/**
* 模板主键 → 模板标识(code)映射
*
* 【为什么需要这层映射】
* 上游 getSiteInfo 只返回 cms_website.template_idapp_template 自增主键),
* 而前端模板目录名与 app_template.code 对应。历史上主键出现过跳号(id=8 缺失),
* 导致「按主键补零推导目录名」整体错位一位(gxhrtc.shoplnk.cn 事故)。
*
* 因此 SSR 侧统一在此反查模板表,把权威的 code 回填进 siteInfo.templateCode
* 前端 resolveTemplateKey() 优先按 code 解析,主键仅兜底。
*
* 待上游 getSiteInfo 直接返回 templateCode 后,本模块会自动降级为「上游未返回时的兜底」,
* 无需改动前端解析逻辑(tenant.ts 中已判断 templateCode 为空才查表)。
*/
/** 缓存有效期:模板表极少变动,10 分钟足够,且避免每次 SSR 都打上游 */
const CACHE_TTL = 10 * 60 * 1000
let cache: { map: Map<number, string>; expireAt: number } | null = null
/** 并发去重:同一时刻多个 SSR 请求只发起一次上游查询 */
let inflight: Promise<Map<number, string>> | null = null
/**
* 拉取全量模板表并构建 id → code 映射
* 后端: GET {appApiBase}/api/app/template/page(无需鉴权)
*/
async function fetchTemplateMap(config: RuntimeConfig): Promise<Map<number, string>> {
const appApiBase = config.public.appApiBase as string
const map = new Map<number, string>()
const res = await $fetch<ApiEnvelope<AppTemplatePage> | AppTemplatePage>(
'/api/app/template/page',
{
baseURL: appApiBase,
query: { page: 1, limit: 200 },
timeout: 5000,
retry: 1
}
)
const data = (res as ApiEnvelope<AppTemplatePage>)?.data ?? (res as AppTemplatePage)
for (const row of data?.list || []) {
if (row?.id && typeof row.code === 'string' && row.code.trim()) {
map.set(Number(row.id), row.code.trim().toLowerCase())
}
}
return map
}
/**
* 获取 id → code 映射(带缓存 + 并发去重 + 失败降级)
* 上游异常时返回上一次的有效缓存(若有),否则返回空映射,由调用方回退主键推导。
*/
async function getTemplateMap(config: RuntimeConfig): Promise<Map<number, string>> {
const now = Date.now()
if (cache && cache.expireAt > now) return cache.map
if (inflight) return inflight
inflight = fetchTemplateMap(config)
.then((map) => {
// 空结果不覆盖有效缓存,避免上游偶发返回空表导致全站回退主键推导
if (map.size > 0) {
cache = { map, expireAt: Date.now() + CACHE_TTL }
}
return cache?.map ?? map
})
.catch(() => cache?.map ?? new Map<number, string>())
.finally(() => {
inflight = null
})
return inflight
}
/**
* 按模板主键反查模板标识(code)
*
* @returns 形如 'template-08';查不到返回 ''(调用方回退主键推导)
*/
export async function getTemplateCodeById(
templateId: number | string | null | undefined,
config: RuntimeConfig
): Promise<string> {
if (templateId === null || templateId === undefined || templateId === '') return ''
// 已经是目录名形式(上游直接返回 code),无需查表
if (typeof templateId === 'string' && templateId.startsWith('template-')) {
return templateId.trim().toLowerCase()
}
const id = Number(templateId)
if (!id || Number.isNaN(id) || id <= 0) return ''
try {
const map = await getTemplateMap(config)
return map.get(id) || ''
} catch {
return ''
}
}
/** 清空缓存(后台改动模板表后可通过重启或调用此方法立即生效,主要供测试使用) */
export function clearTemplateMapCache(): void {
cache = null
}