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
+277
View File
@@ -0,0 +1,277 @@
import type { CmsSiteInfo, CmsNavigation, SiteConfig, SiteSetting, SocialLink, ApiEnvelope } from '~/types'
import { useApp } from './useApp'
import { useRuntimeConfig } from '#imports'
import { mapNavTitle } from '~/utils'
import { ensureFullUrl } from '~/utils/image'
/**
* 获取当前站点信息
* 调用 /api/site/info 代理接口
* 返回 CMS getSiteInfo 的完整数据,包括导航、配置等
*
* 站点名称 / Logo 优先使用应用信息(AppProduct)接口的数据,
* 回退到 CMS 站点信息(websiteName / websiteLogo)。
*/
export function useSite() {
// 应用产品信息(名称 / Logo 优先使用 AppProduct
const { appName, appLogo, appIcon } = useApp()
const siteInfo = useState<CmsSiteInfo | null>('site-info', () => null)
const loading = useState<boolean>('site-loading', () => false)
const error = useState<string | null>('site-error', () => null)
async function fetchSiteInfo() {
// 已加载(SSR 已注入)则直接返回,避免重复请求 CMS
if (siteInfo.value) return siteInfo.value
loading.value = true
error.value = null
try {
const res = await $fetch<ApiEnvelope<CmsSiteInfo> | CmsSiteInfo>('/api/site/info')
const envelope = res as ApiEnvelope<CmsSiteInfo>
const data = envelope?.data ?? (res as CmsSiteInfo)
siteInfo.value = data
return data
} catch (e: any) {
error.value = e?.message || '获取站点信息失败'
return null
} finally {
loading.value = false
}
}
/**
* 顶部导航
*
* 后端 getSiteInfo 的 setSafeWebsiteNavigation 把栏目分到 topNavs / bottomNavs 两个数组,
* 但该分组函数不可靠、且数组名与实际内容常常相反:
* - 典型租户(如汇吉采):真正置顶的菜单(首页/关于/核心业务…)被塞进 bottomNavs 且 top=1
* 而 topNavs 仅含一条底部链接(资料下载, top=0)。
* - 也有租户(如 cz-hro):topNavs 有数据但 top 标志不可靠(top=0),需直接信任后端分组。
*
* 因此以权威标志 `top === 1`(标记「应置顶」)合并两个数组来取顶部菜单,
* 不再依赖数组名 topNavs / bottomNavs
* 1) 合并 topNavs + bottomNavs,取 top===1 且未隐藏/未删除的项;
* 2) 若没有任何 top===1 项(top 标志整体不可靠,如 cz-hro)→ 兜底信任 topNavs。
*/
const navigations = computed<CmsNavigation[]>(() => {
const top = [
...(siteInfo.value?.topNavs || []),
...(siteInfo.value?.bottomNavs || [])
]
.filter((nav) => nav.top === 1 && !nav.hide && !nav.deleted)
.map(mapNavTitle)
if (top.length) return top
// 兜底:top 标志不可靠时,直接信任后端 topNavs 分组
return (siteInfo.value?.topNavs || [])
.filter((nav) => !nav.hide && !nav.deleted)
.map(mapNavTitle)
})
/**
* 底部导航(页脚链接):取 top!==1(含 top=0 或缺失)且未隐藏/未删除的项,
* 合并 topNavs + bottomNavs,与顶部菜单互斥、不重复。
*/
const bottomNavigations = computed<CmsNavigation[]>(() => {
return [
...(siteInfo.value?.topNavs || []),
...(siteInfo.value?.bottomNavs || [])
]
.filter((nav) => nav.top !== 1 && !nav.hide && !nav.deleted)
.map(mapNavTitle)
})
/**
* 完整导航树(合并 top + bottom,仅过滤 hide/deleted,不限 top/bottom)。
* 用于「栏目 navId 识别 / 栏目定位」等路由场景,覆盖 top!==1 的子栏目、页脚栏目。
* 注意:顶部菜单显示请仍用 `navigations`top===1 过滤),不要混用。
*/
const allNavigations = computed<CmsNavigation[]>(() => {
const all = [
...(siteInfo.value?.topNavs || []),
...(siteInfo.value?.bottomNavs || [])
]
return all.filter((nav) => !nav.hide && !nav.deleted).map(mapNavTitle)
})
/** 站点配置 */
const siteConfig = computed<SiteConfig | null>(() => {
return siteInfo.value?.config || null
})
/** 站点功能设置(后台「网站设置」下发的开关集合,含 searchBtn / search 等) */
const siteSetting = computed<SiteSetting | null>(() => {
return siteInfo.value?.setting || null
})
/**
* 是否显示头部搜索框
* 受后台 setting.searchBtn(搜索按钮开关)控制;兼容 setting.search。
* 未配置时默认 true(全站显示),避免存量站点突然丢失搜索能力。
*/
const showHeaderSearch = computed<boolean>(() => {
const s = siteInfo.value?.setting
if (typeof s?.searchBtn === 'boolean') return s.searchBtn
if (typeof s?.search === 'boolean') return s.search
return true
})
/** 站点名称(环境变量覆盖 > CMS 站点信息(企业名称) > 应用信息(产品名)) */
const siteName = computed(() => {
const override = (useRuntimeConfig().public.siteName as string) || ''
return override || siteInfo.value?.websiteName || appName.value || ''
})
/** 站点 Logo(优先应用信息,回退 CMS 站点信息) */
const siteLogo = computed(() => {
return appLogo.value || siteInfo.value?.websiteLogo || ''
})
/**
* 站点图标(无 Logo 时用于 Logo 区回退:图标 + 网站名称)
* 优先级:应用图标 > CMS websiteIcon;统一 ensureFullUrl 补全路径。
*/
const siteIcon = computed(() => {
const raw = appIcon.value || siteInfo.value?.websiteIcon || ''
return raw ? ensureFullUrl(raw) : ''
})
/** 站点关键词 */
const siteKeywords = computed(() => {
return siteInfo.value?.keywords || ''
})
/**
* 联系电话
* 优先级:1) siteInfo.phone(完整号码) 2) 环境变量兜底 NUXT_PUBLIC_PHONE 3) config.tel
* 说明:后端 CMS 的 phone 字段可能因敏感策略返回脱敏值(含 *),此时用环境变量兜底。
*/
const phone = computed(() => {
const rawPhone = siteInfo.value?.phone || ''
if (rawPhone && !rawPhone.includes('*')) return rawPhone
return (useRuntimeConfig().public.phone as string) || siteInfo.value?.config?.tel || rawPhone || ''
})
/** 邮箱 */
const email = computed(() => {
return siteInfo.value?.config?.email || siteInfo.value?.email || ''
})
/** 地址(优先 siteInfo.address,其次 config.address */
const address = computed(() => {
return siteInfo.value?.address || siteInfo.value?.config?.address || ''
})
/** ICP 备案号 */
const icpNo = computed(() => {
return siteInfo.value?.config?.icpNo || siteInfo.value?.icpNo || ''
})
/** 版权信息 */
const copyright = computed(() => {
return siteInfo.value?.config?.copyright || ''
})
/** 微信二维码(环境变量覆盖 > config.wxQrcode > 顶层 qrCode */
const wxQrcode = computed(() => {
const override = (useRuntimeConfig().public.wxQrcode as string) || ''
return override || siteInfo.value?.config?.wxQrcode || (siteInfo.value?.qrCode as string) || ''
})
/** 品牌标语 / Slogan(优先站点顶层 slogan,其次 config.slogan */
const slogan = computed(() => {
return siteInfo.value?.slogan || (siteInfo.value?.config as SiteConfig | undefined)?.slogan || ''
})
/** 官网域名(优先 siteInfo.domain,其次 config.Domain / config.SysDomain */
const officialWebsite = computed(() => {
const cfg = siteInfo.value?.config as SiteConfig | undefined
return siteInfo.value?.domain || cfg?.Domain || cfg?.SysDomain || ''
})
/** 官网域名(补全协议,用于 <a :href> */
const officialWebsiteUrl = computed(() => {
const raw = officialWebsite.value
if (!raw) return ''
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`
})
/** 社交外部链接(后台「网站设置」录入,标准字段名 socialLinks;兼容旧 links 别名),URL 统一补全协议 */
const socialLinks = computed<SocialLink[]>(() => {
const cfg = siteInfo.value?.config as SiteConfig | undefined
const raw =
(siteInfo.value?.socialLinks as SocialLink[] | unknown | undefined) ??
(siteInfo.value?.links as SocialLink[] | unknown | undefined) ??
cfg?.socialLinks ??
cfg?.links
let list: SocialLink[] = []
if (Array.isArray(raw)) list = raw as SocialLink[]
else if (typeof raw === 'string') {
try {
const parsed = JSON.parse(raw)
list = Array.isArray(parsed) ? (parsed as SocialLink[]) : []
} catch {
list = []
}
}
return list.map((l) => ({
...l,
url: l.url && !/^https?:\/\//i.test(l.url) ? `https://${l.url}` : l.url
}))
})
/** 到期时间 */
const expirationTime = computed(() => {
return siteInfo.value?.expirationTime || ''
})
/**
* 模板目录名(template-XX):【code 优先,主键兜底】
*
* templateCodeapp_template.code)与前端 app/templates/ 目录一一对应,是权威标识;
* templateId 主键存在跳号风险(历史上 id=8 缺失导致整体错位一位),仅在 code 缺失时兜底。
* 为空返回 '' 由上层回退默认模板。
*/
const templateId = computed(() => {
return resolveTemplateKey(siteInfo.value?.templateCode, siteInfo.value?.templateId)
})
/**
* 按主键推导的模板目录名(兜底候选)
* 供 useTemplate.loadTemplate 在「code 指向的目录不存在」时二次尝试,
* 避免直接跌回 template-01 造成风格完全错乱。
*/
const templateIdFallback = computed(() => {
const byId = toTemplateKey(siteInfo.value?.templateId)
return byId === templateId.value ? '' : byId
})
return {
siteInfo,
navigations,
bottomNavigations,
allNavigations,
siteConfig,
siteSetting,
showHeaderSearch,
siteName,
siteLogo,
siteIcon,
siteKeywords,
phone,
email,
address,
icpNo,
copyright,
wxQrcode,
slogan,
officialWebsite,
officialWebsiteUrl,
socialLinks,
expirationTime,
templateId,
templateIdFallback,
loading,
error,
fetchSiteInfo
}
}