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
+221
View File
@@ -0,0 +1,221 @@
import type { CmsPage, CmsNavigation, CmsBanner } from '~/types'
/**
* 导航标题前端映射(CMS 返回的标题无法直接修改时使用)
*/
const NAV_TITLE_MAP: Record<string, string> = {
'工厂实景': '产品中心'
}
/**
* 递归重写导航标题
*/
export function mapNavTitle(nav: CmsNavigation): CmsNavigation {
const mapped: CmsNavigation = {
...nav,
title: NAV_TITLE_MAP[nav.title || ''] ?? nav.title
}
if (nav.children && nav.children.length > 0) {
mapped.children = nav.children.map(mapNavTitle)
}
return mapped
}
/**
* 根据 CMS 页面 slug 查找页面信息
*/
export function findPageBySlug(pages: CmsPage[], slug: string): CmsPage | undefined {
return pages.find((p) => p.slug === slug)
}
/**
* 安全的 JSON 解析
*/
export function safeJsonParse<T = unknown>(str?: string | null, defaultValue?: T): T | undefined {
if (!str) return defaultValue
try {
return JSON.parse(str) as T
} catch {
return defaultValue
}
}
/**
* 截断文本
*/
export function truncate(str?: string, maxLength = 100): string {
if (!str) return ''
if (str.length <= maxLength) return str
return str.slice(0, maxLength) + '...'
}
/** 合法模板目录名格式:template-01 ~ template-99(含更多位数,如 template-100 */
const TEMPLATE_KEY_RE = /^template-\d{2,}$/
/**
* 将模板 ID 归一化为模板目录名(template-XX)—— 【兜底通道,不要作为首选】
*
* 兼容三种输入:
* - 数字 / 数字字符串(app_template.id 自增主键):5 → 'template-05'7 → 'template-07'
* - 已是目录名字符串:'template-02' → 'template-02'(原样返回)
* - 空值(null/undefined/0/''):返回 ''(由调用方回退默认模板)
*
* ⚠️ 历史坑(2026-08-03 gxhrtc.shoplnk.cn 生产事故根因):
* 本函数把「自增主键」直接补零当目录名,这一假设依赖 app_template.id 与前端目录序号严格一致。
* 实际上后台曾出现主键跳号(id=8 被跳过),导致第 8 套模板起 id 与目录整体错位一位:
* 租户选了 name=「恒瑞天诚」(id=9) 的金棕模板,前端却按 9 加载了 template-09 墨绿出版风。
* 因此模板目录名的权威来源已改为 app_template.code 字符串(见 resolveTemplateKey),
* 主键仅在 code 缺失(老数据 / 上游未返回)时兜底。
*
* @param id 模板 IDint 或 string
* @param pad 数字补零位数,默认 2template-01 ~ template-99
*/
export function toTemplateKey(id?: number | string | null, pad = 2): string {
if (id === null || id === undefined || id === '') return ''
// 已经是目录名形式,直接用
if (typeof id === 'string' && id.startsWith('template-')) return id
const n = Number(id)
if (!n || Number.isNaN(n) || n <= 0) return ''
return `template-${String(n).padStart(pad, '0')}`
}
/**
* 解析模板目录名:【code 优先,id 兜底】
*
* 这是全站解析模板目录的唯一入口,取代直接调用 toTemplateKey()。
*
* 优先级:
* 1. codeapp_template.code,形如 'template-08')—— 与前端目录名一一对应的权威标识,
* 由 SSR 侧根据 templateId 反查模板表回填(server/utils/template-map.ts),
* 或由上游 getSiteInfo 直接返回 templateCode 字段。
* 2. idapp_template.id 自增主键)—— 仅当 code 缺失时按序号推导,存在跳号错位风险。
*
* @param code 模板标识(app_template.code
* @param id 模板主键(app_template.id / cms_website.template_id
* @returns 模板目录名(template-XX),无法解析时返回 ''(调用方回退默认模板)
*/
export function resolveTemplateKey(
code?: string | null,
id?: number | string | null
): string {
const normalized = typeof code === 'string' ? code.trim().toLowerCase() : ''
if (normalized && TEMPLATE_KEY_RE.test(normalized)) return normalized
return toTemplateKey(id)
}
/**
* 为导航项(含子导航)生成跳转链接。
*
* 规则:若路径最后一个 segment 已经是当前 navigationId(如 /page/4598、
* /product/1234、/article/1234),直接返回原路径,不再追加 ?navId= 查询参数,
* 避免产生 /page/4598?navId=4598 这类冗余 URL。
* 仅当路径本身无法定位到当前导航时才追加 navId(如 /article?navId=80、
* /product?navId=80,这类「列表根路径 + 子分类」场景需要 navId 做过滤)。
*
* 项目统一 URL 规范(按模块名单数命名,列表与详情同词根,靠 navId 运行时判定):
* 新闻 article:列表 /article · /article/80,详情 /article/1234
* 产品 product:列表 /product · /product/80,详情 /product/1235
* 案例 case 列表 /case · /case/80 详情 /case/1234
* 单页 /page/{slug}(单页管理 cms_page)· /about · /contact
* CMS 导航 path 已为 /{module}/{navigationId},与前端列表路由一致,无需重写。
*
* 单页关联约定(2026-07-30 定稿):
* - 单页内容统一由「单页管理(cms_page)」维护,按 slug 寻址,规范 URL 为 /page/{slug}
* - 导航中 page 类型节点通过 nav.path=/page/{slug} 关联(pageId 仅作后端对账键);
* - 独立富组件页 关于我们/联系我们 规范路由为 /about、/contact/page/about 等仅兼容,会 301)。
* 因此单页路由不再追加 ?navId,保持 URL 干净;navId 仅用于模块型栏目识别子分类。
*/
export function getNavLink(nav?: CmsNavigation | null): string {
if (!nav) return '/'
// 单页关联(新版):优先用绑定的单页 slug 构建链接,避免依赖手动维护的 url 字段
if (nav.pagePath) {
const slug = nav.pagePath
// 独立富组件规范路由(与单页 [id].vue 的 CANONICAL_PAGE_ROUTE 保持一致)
if (slug === 'about') return '/about'
if (slug === 'contact') return '/contact'
return `/page/${slug}`
}
// 兼容未绑定单页的旧逻辑:优先 url,回退 path/categoryPath
const base = nav.url || nav.path || nav.categoryPath || '/'
// 首页(path 为 "/")不追加 navId:首页路由不消费 navId,补上只是冗余 query(如 /?navId=4679
if (base === '/') return base
if (!nav.navigationId) return base
// 外链原样返回,不追加 navId
if (/^https?:\/\//i.test(base)) return base
// 单页路由:/page/{slug}(单页管理)或 /about、/contact(独立富组件)已含完整地址,
// 不再追加 ?navIdmodel=page 同样不再追加(navId 对单页无意义)。
if (nav.model === 'page' || base.startsWith('/page/') || base === '/about' || base === '/contact') return base
// 仅「模块型栏目列表根路径」(/article、/product、/case) 才需要追加 ?navId 用于子分类过滤;
// 其余顶层路由(功能页 /chat、/dowon 等)及详情页 (/article/1234) 均不消费 navId
// 保持 URL 干净,不再默认追加(旧逻辑会为所有非单页路由拼接 navId)。
const pathOnly = base.split('?')[0].replace(/\/$/, '')
if (['/article', '/product', '/case'].includes(pathOnly)) {
const lastSegment = pathOnly.split('/').pop() || ''
if (lastSegment !== String(nav.navigationId)) {
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}navId=${nav.navigationId}`
}
}
return base
}
/**
* 取轮播图(banner)点击跳转链接。
*
* 兼容上游多种字段命名,避免「后台配了 link_url 但前端读不到」这类错位:
* - link:本仓库 server/api/banner.get.ts 映射出的标准字段;
* - linkUrlCMS 原始返回字段(Java 把数据库列 link_url 序列化为驼峰 linkUrl);
* - link_url:个别上游/老接口可能直接透传的蛇形字段名。
* 仅当为非空字符串时才返回,否则返回 ''(调用方据此不渲染 <a>,避免空白可点区域)。
*
* @param banner 轮播图数据
* @returns 跳转链接字符串;无效时返回 ''
*/
export function getBannerLink(banner?: Partial<CmsBanner> | null): string {
if (!banner) return ''
const raw =
(banner as Record<string, unknown>).link
?? (banner as Record<string, unknown>).linkUrl
?? (banner as Record<string, unknown>).link_url
?? ''
return typeof raw === 'string' && raw.trim() ? raw.trim() : ''
}
/**
* 剥离 HTML 标签,返回纯文本(用于列表/卡片摘要)
*
* 浏览器环境优先用 DOMParser + innerTextinnerText 尊重块级/换行,更贴近视觉文本);
* SSR 环境(无 window)走正则兜底,且先在块级闭合标签后补空格,避免段落粘连。
* 同时解码常见 HTML 实体,避免 &nbsp; 等出现在摘要里。
*/
export function stripHtml(html?: string | null): string {
if (!html) return ''
if (typeof window !== 'undefined' && 'DOMParser' in window) {
const doc = new window.DOMParser().parseFromString(html, 'text/html')
const text = doc.body.innerText || doc.body.textContent || ''
return text.replace(/\s+/g, ' ').trim()
}
return html
.replace(/<\/(p|div|li|h[1-6]|tr|blockquote)>/gi, ' ') // 块级闭合后补空格,避免段间粘连
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, ' ')
.trim()
}
// 图片压缩工具
export * from './image'