import type { CmsPage, CmsNavigation, CmsBanner } from '~/types' /** * 导航标题前端映射(CMS 返回的标题无法直接修改时使用) */ const NAV_TITLE_MAP: Record = { '工厂实景': '产品中心' } /** * 递归重写导航标题 */ 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(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:个别上游/老接口可能直接透传的蛇形字段名。 * 仅当为非空字符串时才返回,否则返回 ''(调用方据此不渲染 ,避免空白可点区域)。 * * @param banner 轮播图数据 * @returns 跳转链接字符串;无效时返回 '' */ export function getBannerLink(banner?: Partial | null): string { if (!banner) return '' const raw = (banner as Record).link ?? (banner as Record).linkUrl ?? (banner as Record).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(//gi, ' ') .replace(/<[^>]*>/g, '') .replace(/ /g, ' ') .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/\s+/g, ' ') .trim() } // 图片压缩工具 export * from './image'