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('site-info', () => null) const loading = useState('site-loading', () => false) const error = useState('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 | CmsSiteInfo>('/api/site/info') const envelope = res as ApiEnvelope 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(() => { 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(() => { 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(() => { const all = [ ...(siteInfo.value?.topNavs || []), ...(siteInfo.value?.bottomNavs || []) ] return all.filter((nav) => !nav.hide && !nav.deleted).map(mapNavTitle) }) /** 站点配置 */ const siteConfig = computed(() => { return siteInfo.value?.config || null }) /** 站点功能设置(后台「网站设置」下发的开关集合,含 searchBtn / search 等) */ const siteSetting = computed(() => { return siteInfo.value?.setting || null }) /** * 是否显示头部搜索框 * 受后台 setting.searchBtn(搜索按钮开关)控制;兼容 setting.search。 * 未配置时默认 true(全站显示),避免存量站点突然丢失搜索能力。 */ const showHeaderSearch = computed(() => { 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 || '' }) /** 官网域名(补全协议,用于 ) */ const officialWebsiteUrl = computed(() => { const raw = officialWebsite.value if (!raw) return '' return /^https?:\/\//i.test(raw) ? raw : `https://${raw}` }) /** 社交外部链接(后台「网站设置」录入,标准字段名 socialLinks;兼容旧 links 别名),URL 统一补全协议 */ const socialLinks = computed(() => { 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 } }