2b69686795
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
278 lines
9.9 KiB
TypeScript
278 lines
9.9 KiB
TypeScript
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
|
||
}
|
||
}
|