feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 图片压缩工具
|
||||
*
|
||||
* 与小程序端(src/utils/image.ts)保持相同 API:
|
||||
* 通过阿里云 OSS 的图片处理参数(x-oss-process)实现等比缩放 + 质量压缩,
|
||||
* 适用于商品列表、卡片等缩略图场景。
|
||||
*
|
||||
* 与小程序端的区别:
|
||||
* 小程序端图片直连 oss.wsdns.cn,ensureFullUrl 直接补全 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 为可访问路径
|
||||
*
|
||||
* 网站端约定:
|
||||
* - 完整 URL(http/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}`
|
||||
}
|
||||
@@ -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 模板 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'
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user