Files
hjc-web/app/utils/index.ts
T
weicw-dshsa 4491613902 feat(template-07): 首页轮播图按后台配置的跳转类型跳转
轮播图原先只读 linkUrl 渲染 <a>,而后台「轮播图管理」的跳转是
linkType + linkUrl/linkTargetId 三元组:配「文章详情 / 产品详情」时
linkUrl 为空,前端拿不到地址,图片就渲染成不可点的纯 <img>,
运营配了也点不动。

- utils 新增 getBannerTarget():linkType 2 → /article/{id}、
  3 → /product/{id}、1 → linkUrl;类型缺省但地址非空时按外链兜底
  (兼容只存了地址的老数据);字段名兼容 link / linkUrl / link_url
- 新增 isExternalUrl():http(s)、//host、mailto 等视为站外
- getBannerLink() 保留为 getBannerTarget() 的薄封装,对外行为不变
- HeroSection:图片按目标渲染 NuxtLink(站内目标当前页跳转)/
  <a target="_blank">(外链)/ 纯 <img>(未配置跳转,保持不可点)
- CmsBanner 补 linkType / linkUrl / linkTargetId 字段声明

首页 4 张图(政府采购代理 / 投融资服务 / AI数智化 / 工程代理)
已在后台配置为「产品详情」跳转。
2026-10-10 19:56:45 +08:00

264 lines
11 KiB
TypeScript
Raw 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 模板 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
}
/**
* 判断链接是否需要「站外」打开(新标签页)。
*
* 站外判定:绝对地址(http://、https://)、协议相对地址(//host)
* 以及 mailto: / tel: 等非路由协议;其余(含以 / 开头的站内路由)视为站内。
*/
export function isExternalUrl(url?: string | null): boolean {
if (!url) return false
const v = url.trim()
if (!v) return false
return /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(v)
}
/** 轮播图点击目标 */
export interface BannerTarget {
/** 跳转地址 */
href: string
/** true=站外(新标签页打开);false=站内路由(当前页跳转) */
external: boolean
}
/**
* 解析轮播图(banner)点击目标。
*
* 对齐后台「轮播图管理」的跳转配置(linkType + linkTargetId/linkUrl),
* 同时兼容上游多种字段命名,避免「后台配了却点不动」:
* - linkType=1(外链):取 linkUrl;
* - linkType=2(文章详情):/article/{linkTargetId};
* - linkType=3(产品详情):/product/{linkTargetId};
* - linkType 缺省或为 0 但 linkUrl 非空:按外链兜底(兼容只存了地址的老数据)。
*
* 字段名兼容:link(本仓库 server/api/banner.get.ts 映射出的标准字段)、
* linkUrl(Java 把 link_url 序列化为驼峰)、link_url(个别上游透传的蛇形字段)。
*
* @returns 跳转目标;返回 null 表示未配置跳转(调用方据此不渲染可点区域,避免空白可点)
*/
export function getBannerTarget(banner?: Partial<CmsBanner> | null): BannerTarget | null {
if (!banner) return null
const type = Number(banner.linkType ?? 0) || 0
const targetId = Number(banner.linkTargetId ?? 0) || 0
if (type === 2 && targetId > 0) return { href: `/article/${targetId}`, external: false }
if (type === 3 && targetId > 0) return { href: `/product/${targetId}`, external: false }
const raw = banner.link
?? banner.linkUrl
?? (banner as Record<string, unknown>).link_url
?? ''
const href = typeof raw === 'string' ? raw.trim() : ''
if (!href) return null
return { href, external: isExternalUrl(href) }
}
/**
* 取轮播图(banner)点击跳转链接(需要字符串地址的场景使用)。
* 仅当后台配置了有效跳转(外链 / 文章 / 产品)时才返回非空字符串。
*
* @param banner 轮播图数据
* @returns 跳转链接字符串;无效时返回 ''
*/
export function getBannerLink(banner?: Partial<CmsBanner> | null): string {
return getBannerTarget(banner)?.href ?? ''
}
/**
* 剥离 HTML 标签,返回纯文本(用于列表/卡片摘要)
*
* 浏览器环境优先用 DOMParser + innerText(innerText 尊重块级/换行,更贴近视觉文本);
* 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'