2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
222 lines
9.3 KiB
TypeScript
222 lines
9.3 KiB
TypeScript
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 模板 ID(int 或 string)
|
||
* @param pad 数字补零位数,默认 2(template-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. code(app_template.code,形如 'template-08')—— 与前端目录名一一对应的权威标识,
|
||
* 由 SSR 侧根据 templateId 反查模板表回填(server/utils/template-map.ts),
|
||
* 或由上游 getSiteInfo 直接返回 templateCode 字段。
|
||
* 2. id(app_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(独立富组件)已含完整地址,
|
||
// 不再追加 ?navId;model=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 映射出的标准字段;
|
||
* - linkUrl:CMS 原始返回字段(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 + innerText(innerText 尊重块级/换行,更贴近视觉文本);
|
||
* SSR 环境(无 window)走正则兜底,且先在块级闭合标签后补空格,避免段落粘连。
|
||
* 同时解码常见 HTML 实体,避免 等出现在摘要里。
|
||
*/
|
||
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(/ /g, ' ')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, "'")
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
}
|
||
|
||
// 图片压缩工具
|
||
export * from './image'
|