Files
gxwebsoft 2b69686795 feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引
- 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理
- 实现/article、/case、/product及/page动态路由兼容列表与详情展示
- 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置
- 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持
- 模板增强支持CMS单页内容加载及SEO信息动态设置
- 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
2026-09-08 12:13:44 +08:00

222 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'