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,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 优先,主键兜底】
|
||||
*
|
||||
* templateCode(app_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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user