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
+111
View File
@@ -0,0 +1,111 @@
/**
* 图片压缩工具
*
* 与小程序端(src/utils/image.ts)保持相同 API
* 通过阿里云 OSS 的图片处理参数(x-oss-process)实现等比缩放 + 质量压缩,
* 适用于商品列表、卡片等缩略图场景。
*
* 与小程序端的区别:
* 小程序端图片直连 oss.wsdns.cnensureFullUrl 直接补全 OSS 域名;
* 网站端图片统一走 /api/file/ 代理(代理会透传 query 参数),
* 因此 ensureFullUrl 将相对路径补全为代理地址,压缩参数随之转发到后端 OSS 生效。
*/
/**
* 图片压缩参数配置
*/
export interface ImageCompressOptions {
/** 图片宽度(px),默认 300 */
width?: number
/** 图片质量 0-100,默认 90 */
quality?: number
/** 是否启用压缩,默认 true */
enabled?: boolean
}
/**
* 默认压缩参数
*/
const DEFAULT_OPTIONS: Required<ImageCompressOptions> = {
width: 300,
quality: 90,
enabled: true,
}
/**
* 确保图片 URL 为可访问路径
*
* 网站端约定:
* - 完整 URLhttp/https)原样返回(如直连 OSS 的 oss.wsdns.cn 地址)
* - 相对路径(如 /uploads/xxx.jpg)补全为 /api/file/ 代理地址
*
* @param url 原始图片 URL
* @returns 可访问的图片 URL
*
* @example
* ensureFullUrl('/uploads/xxx.jpg')
* // => '/api/file/uploads/xxx.jpg'
*
* @example
* ensureFullUrl('https://oss.wsdns.cn/xxx.jpg')
* // => 'https://oss.wsdns.cn/xxx.jpg'
*/
export function ensureFullUrl(url: string): string {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://')) {
return url
}
// 相对路径:走 /api/file/ 代理(与 useFileUrl 行为一致)
const cleanPath = url.startsWith('/') ? url.slice(1) : url
return `/api/file/${cleanPath}`
}
/**
* 获取 OSS 压缩后的图片 URL
*
* 给图片 URL 拼接阿里云 OSS 图片处理参数,实现等比缩放 + 质量压缩。
* 使用场景:商品列表、商品卡片等缩略图展示。
*
* 设计要点(前端优先):
* - 以「本次请求的 width/quality」为准重建参数,无论原始 URL 是否已带 x-oss-process。
* 这样即便上游(上传链路)已写入 x-oss-process,前端仍可按需向下压缩,
* 不会出现旧逻辑「已带参数就原样返回、压缩失效」的问题。
* - 仅剥离已有的 x-oss-process 段,保留 URL 上其它查询参数(如有,如 v=2)。
* - 注意:预览原图时不需要调用此函数,直接用原始 URL 即可。
*
* @param url 原始图片 URL
* @param options 压缩选项
* @returns 处理后的图片 URL
*
* @example
* // 默认压缩(宽300,质量90)
* getCompressedImageUrl('/uploads/xxx.jpg')
* // => '/api/file/uploads/xxx.jpg?x-oss-process=image/resize,w_300/quality,Q_90'
*
* @example
* // 已带 x-oss-process 的图,仍以本次请求为准向下压缩
* getCompressedImageUrl('https://oss.x/x.jpg?x-oss-process=image/resize,w_1600', { width: 800 })
* // => 'https://oss.x/x.jpg?x-oss-process=image/resize,w_800/quality,Q_90'
*
* @example
* // 关闭压缩
* getCompressedImageUrl('/uploads/xxx.jpg', { enabled: false })
* // => '/api/file/uploads/xxx.jpg'
*/
export function getCompressedImageUrl(url: string, options?: ImageCompressOptions): string {
const { width, quality, enabled } = { ...DEFAULT_OPTIONS, ...options }
// 先补全为可访问路径
const fullUrl = ensureFullUrl(url)
if (!fullUrl) return ''
// 未启用压缩,原样返回(仍走 ensureFullUrl 补全)
if (!enabled) return fullUrl
// 前端优先:剥离已有的 x-oss-process 段,用本次请求的宽/质重建
const base = fullUrl.split(/[?&]x-oss-process=/)[0]
// 保留 base 上其它查询参数,按需用 ? 或 & 拼接
const separator = base.includes('?') ? '&' : '?'
return `${base}${separator}x-oss-process=image/resize,w_${width}/quality,Q_${quality}`
}
+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'
+70
View File
@@ -0,0 +1,70 @@
/**
* 导航树聚合工具
*
* 场景:父级栏目(如「核心业务」)下挂多个子栏目,父栏目自身通常不放内容。
* 访问父级栏目时,需要聚合其下所有子栏目的数据。
*
* 做法:从完整导航树(allNavigations)中找到目标节点,递归收集
* 该节点自身 + 所有后代的 navigationId,交给后端按 category_id IN (...) 查询。
*/
interface NavNode {
navigationId?: number | null
children?: NavNode[] | null
}
/**
* 递归收集某导航节点自身 + 所有后代的 navigationId
*
* @param navId 目标栏目 navigationId(来自路由参数或导航查找)
* @param navigations 完整导航树(allNavigations
* @returns 包含自身及所有后代 navigationId 的数组;未命中时返回空数组
*/
export function collectDescendantNavIds(
navId: number | string | undefined,
navigations: NavNode[] = []
): number[] {
if (navId == null) return []
const target = Number(navId)
const result: number[] = []
const collectSelfAndChildren = (node: NavNode) => {
if (node?.navigationId != null) {
result.push(Number(node.navigationId))
}
node?.children?.forEach((child: NavNode) => collectSelfAndChildren(child))
}
const findNode = (items: NavNode[]): boolean => {
for (const it of items || []) {
if (Number(it?.navigationId) === target) {
collectSelfAndChildren(it)
return true
}
if (it?.children?.length && findNode(it.children)) return true
}
return false
}
findNode(navigations)
return result
}
/**
* 判断某 navigationId 是否为父栏目(拥有子栏目)
*/
export function isParentNavigation(
navId: number | string | undefined,
navigations: NavNode[] = []
): boolean {
if (navId == null) return false
const target = Number(navId)
const find = (items: NavNode[]): boolean => {
for (const it of items || []) {
if (Number(it?.navigationId) === target) return (it?.children?.length ?? 0) > 0
if (it?.children?.length && find(it.children)) return true
}
return false
}
return find(navigations)
}
+24
View File
@@ -0,0 +1,24 @@
/**
* 客户端表单校验(与 server/utils/validators.ts 保持一致)。
* 电话号段支持:中国大陆手机号 + 港澳台/海外(+区号 或 00 区号)。
*/
/** 归一化:去除空格、括号、连字符;00 开头视为国际号 */
export function normalizePhone(v: string): string {
return (v || '').replace(/[\s()-]/g, '').replace(/^00/, '+')
}
/**
* 校验电话是否有效。
* - 含 + 或 00 前缀:按国际号码处理,国家码+号码总长 8~15 位即可(覆盖港澳台/海外)。
* - 无前缀:按中国大陆手机号 1[3-9]\d{9} 处理。
*/
export function validatePhone(value: string): boolean {
const v = normalizePhone(value)
if (!v) return false
if (v.startsWith('+')) {
const digits = v.slice(1).replace(/[^\d]/g, '')
return /^\d{8,15}$/.test(digits)
}
return /^1[3-9]\d{9}$/.test(v)
}