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,78 @@
|
||||
import type { AppProduct, TenantContext } from '~/types'
|
||||
|
||||
/**
|
||||
* 获取当前应用产品信息(AppProduct)
|
||||
*
|
||||
* SSR:优先从 event.context.tenant.appProduct 读取(中间件已查询)
|
||||
* 客户端:从 SSR 注入的 useState 读取;若为空则调用 /api/app/info 补查
|
||||
*/
|
||||
export function useApp() {
|
||||
const appInfo = useState<AppProduct | null>('app-info', () => null)
|
||||
const loading = useState<boolean>('app-loading', () => false)
|
||||
const error = useState<string | null>('app-error', () => null)
|
||||
|
||||
// SSR 时直接从中间件识别结果注入,避免额外请求
|
||||
if (import.meta.server && !appInfo.value) {
|
||||
const event = useRequestEvent()
|
||||
const ctx = event?.context?.tenant as TenantContext | undefined
|
||||
if (ctx?.appProduct) {
|
||||
appInfo.value = ctx.appProduct
|
||||
}
|
||||
}
|
||||
|
||||
/** 主动获取应用信息(客户端兜底 / 手动刷新) */
|
||||
async function fetchAppInfo() {
|
||||
if (appInfo.value) return appInfo.value
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const res = await $fetch<AppProduct | null>('/api/app/info')
|
||||
appInfo.value = res
|
||||
return res
|
||||
} catch (e: any) {
|
||||
error.value = e?.message || '获取应用信息失败'
|
||||
return null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 应用名称 */
|
||||
const appName = computed(() => appInfo.value?.productName || '')
|
||||
/** 应用编码 */
|
||||
const appCode = computed(() => appInfo.value?.productCode || '')
|
||||
/** 绑定域名 */
|
||||
const appDomain = computed(() => appInfo.value?.domain || '')
|
||||
/** 应用 Logo */
|
||||
const appLogo = computed(() => appInfo.value?.logo || '')
|
||||
/** 应用图标(站点小图标,用于 Header/Footer Logo 回退) */
|
||||
const appIcon = computed(() => appInfo.value?.icon || '')
|
||||
/**
|
||||
* 关联模板目录名(template-XX):【code 优先,主键兜底】
|
||||
* templateCode 与前端模板目录一一对应;templateId 主键存在跳号错位风险,仅兜底。
|
||||
* 为空时返回 '' 由上层回退默认模板。
|
||||
*/
|
||||
const appTemplateId = computed(() =>
|
||||
resolveTemplateKey(appInfo.value?.templateCode, appInfo.value?.templateId)
|
||||
)
|
||||
/** 是否已过期 */
|
||||
const appExpired = computed(() => {
|
||||
const t = appInfo.value?.expirationTime
|
||||
if (!t) return false
|
||||
return new Date(t).getTime() < Date.now()
|
||||
})
|
||||
|
||||
return {
|
||||
appInfo,
|
||||
loading,
|
||||
error,
|
||||
fetchAppInfo,
|
||||
appName,
|
||||
appCode,
|
||||
appDomain,
|
||||
appLogo,
|
||||
appIcon,
|
||||
appTemplateId,
|
||||
appExpired
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Article, Product, CaseItem, ApiEnvelope, PageResult } from '~/types'
|
||||
|
||||
/**
|
||||
* CMS 数据请求
|
||||
* 封装文章、产品、案例等数据的获取逻辑
|
||||
* 代理接口统一在 server/api/ 下
|
||||
*/
|
||||
export function useCms() {
|
||||
/**
|
||||
* 文章列表
|
||||
* @param params.navigationId 导航栏目ID(从 getSiteInfo 的 topNavs 获取)
|
||||
* @param params.page 页码
|
||||
* @param params.limit 每页条数
|
||||
* @param params.keywords 搜索关键词
|
||||
*/
|
||||
async function fetchArticles(params?: {
|
||||
navigationId?: number
|
||||
categoryId?: number
|
||||
page?: number
|
||||
limit?: number
|
||||
keywords?: string
|
||||
}) {
|
||||
const res = await $fetch<ApiEnvelope<PageResult<Article>> | PageResult<Article>>(
|
||||
'/api/article/list',
|
||||
{ query: params }
|
||||
)
|
||||
|
||||
const envelope = res as ApiEnvelope<PageResult<Article>>
|
||||
if (envelope?.data) {
|
||||
return envelope.data
|
||||
}
|
||||
return res as PageResult<Article>
|
||||
}
|
||||
|
||||
/** 文章详情 */
|
||||
async function fetchArticleDetail(id: string | number) {
|
||||
const res = await $fetch<ApiEnvelope<Article> | Article>('/api/article/detail', {
|
||||
query: { id }
|
||||
})
|
||||
|
||||
const envelope = res as ApiEnvelope<Article>
|
||||
return envelope?.data || (res as Article)
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据导航模型获取文章列表
|
||||
* model=article 的导航直接用 navigationId 查询
|
||||
*/
|
||||
async function fetchArticlesByNav(navigationId: number, page = 1, limit = 10) {
|
||||
return fetchArticles({ navigationId, page, limit })
|
||||
}
|
||||
|
||||
/** 产品列表 */
|
||||
async function fetchProducts(params?: {
|
||||
categoryId?: number
|
||||
navigationId?: number
|
||||
page?: number
|
||||
limit?: number
|
||||
keywords?: string
|
||||
}) {
|
||||
const res = await $fetch<ApiEnvelope<PageResult<Product>> | PageResult<Product>>(
|
||||
'/api/product/list',
|
||||
{ query: params }
|
||||
)
|
||||
|
||||
const envelope = res as ApiEnvelope<PageResult<Product>>
|
||||
if (envelope?.data) {
|
||||
return envelope.data
|
||||
}
|
||||
return res as PageResult<Product>
|
||||
}
|
||||
|
||||
/** 产品详情 */
|
||||
async function fetchProductDetail(id: string | number) {
|
||||
const res = await $fetch<ApiEnvelope<Product> | Product>('/api/product/detail', {
|
||||
query: { id }
|
||||
})
|
||||
|
||||
const envelope = res as ApiEnvelope<Product>
|
||||
return envelope?.data || (res as Product)
|
||||
}
|
||||
|
||||
/** 案例列表 */
|
||||
async function fetchCases(params?: {
|
||||
categoryId?: number
|
||||
navigationId?: number
|
||||
page?: number
|
||||
limit?: number
|
||||
}) {
|
||||
const res = await $fetch<ApiEnvelope<PageResult<CaseItem>> | PageResult<CaseItem>>(
|
||||
'/api/case/list',
|
||||
{ query: params }
|
||||
)
|
||||
|
||||
const envelope = res as ApiEnvelope<PageResult<CaseItem>>
|
||||
if (envelope?.data) {
|
||||
return envelope.data
|
||||
}
|
||||
return res as PageResult<CaseItem>
|
||||
}
|
||||
|
||||
/** 案例详情 */
|
||||
async function fetchCaseDetail(id: string | number) {
|
||||
const res = await $fetch<ApiEnvelope<CaseItem> | CaseItem>('/api/case/detail', {
|
||||
query: { id }
|
||||
})
|
||||
|
||||
const envelope = res as ApiEnvelope<CaseItem>
|
||||
return envelope?.data || (res as CaseItem)
|
||||
}
|
||||
|
||||
/** 提交表单/留言 */
|
||||
async function submitForm(data: Record<string, unknown>) {
|
||||
const res = await $fetch<ApiEnvelope>('/api/form/submit', {
|
||||
method: 'POST',
|
||||
body: data
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
return {
|
||||
fetchArticles,
|
||||
fetchArticleDetail,
|
||||
fetchArticlesByNav,
|
||||
fetchProducts,
|
||||
fetchProductDetail,
|
||||
fetchCases,
|
||||
fetchCaseDetail,
|
||||
submitForm
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
export interface ConsultPreset {
|
||||
/** 预填需求内容(如产品名),便于后台识别客户意向 */
|
||||
need?: string
|
||||
/** 来源标记,便于后台统计转化来源 */
|
||||
source?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 全站咨询弹窗状态
|
||||
*
|
||||
* 任意页面/组件调用 openConsult() 即可弹出咨询表单弹窗,
|
||||
* 弹窗内提交到 useCms().submitForm()(POST /api/form/submit)。
|
||||
* 用 useState 保证 SSR 与客户端共享同一份状态。
|
||||
*/
|
||||
export function useConsult() {
|
||||
const isOpen = useState<boolean>('consult-open', () => false)
|
||||
const presetNeed = useState<string>('consult-preset-need', () => '')
|
||||
const source = useState<string>('consult-source', () => '')
|
||||
|
||||
function openConsult(preset?: ConsultPreset) {
|
||||
presetNeed.value = preset?.need || ''
|
||||
source.value = preset?.source || ''
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function closeConsult() {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
return { isOpen, presetNeed, source, openConsult, closeConsult }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
/** 后端 errcode → 前端友好文案 */
|
||||
const ERR_TEXT: Record<string, string> = {
|
||||
TOO_FREQUENT: '操作过于频繁,请稍后再试',
|
||||
DUPLICATE_PENDING: '您已有一条待处理的留言,请耐心等待我们与您联系',
|
||||
CAPTCHA_INVALID: '滑块验证已失效,请重新验证',
|
||||
CAPTCHA_EXPIRED: '滑块验证已过期,请重新验证',
|
||||
CAPTCHA_FAILED: '滑块验证未通过,请重试',
|
||||
INVALID_NAME: '请输入有效的姓名(1-20 字)',
|
||||
INVALID_PHONE: '联系电话格式不正确(支持港澳台及海外号码)',
|
||||
INVALID_CONTENT: '留言内容需 5-500 字',
|
||||
UPSTREAM_ERROR: '提交失败,请稍后重试'
|
||||
}
|
||||
|
||||
/**
|
||||
* 留言表单提交逻辑(前端)。
|
||||
* 负责:蜜罐静默、调 /api/form/submit、错误码映射到友好提示。
|
||||
* 真正的校验/限流/去重在服务端完成。
|
||||
*/
|
||||
export function useContactForm(type = 'contact') {
|
||||
const form = reactive({ name: '', phone: '', content: '' })
|
||||
const honeypot = ref('') // 蜜罐字段:机器人易填,正常人不可见
|
||||
const submitting = ref(false)
|
||||
const success = ref(false)
|
||||
const message = ref('')
|
||||
const messageType = ref<'success' | 'error'>('success')
|
||||
|
||||
async function submit(captcha: { token: string; x: number }) {
|
||||
// 蜜罐命中:假装成功,不真正提交(迷惑机器人)
|
||||
if (honeypot.value.trim()) {
|
||||
success.value = true
|
||||
messageType.value = 'success'
|
||||
message.value = '提交成功,我们会尽快与您联系!'
|
||||
form.name = form.phone = form.content = ''
|
||||
honeypot.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
message.value = ''
|
||||
success.value = false
|
||||
try {
|
||||
await $fetch('/api/form/submit', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
type,
|
||||
name: form.name,
|
||||
phone: form.phone,
|
||||
content: form.content,
|
||||
captchaToken: captcha.token,
|
||||
captchaX: captcha.x
|
||||
}
|
||||
})
|
||||
success.value = true
|
||||
messageType.value = 'success'
|
||||
message.value = '提交成功,我们会尽快与您联系!'
|
||||
form.name = ''
|
||||
form.phone = ''
|
||||
form.content = ''
|
||||
} catch (e: any) {
|
||||
const code = e?.data?.errcode || e?.statusMessage || 'UPSTREAM_ERROR'
|
||||
messageType.value = 'error'
|
||||
message.value = ERR_TEXT[code] || ERR_TEXT.UPSTREAM_ERROR
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { form, honeypot, submitting, success, message, messageType, submit }
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { computed } from 'vue'
|
||||
import { useSite } from './useSite'
|
||||
import type { FeatureItem, FeatureSectionSetting, HomeBlocks } from '~/types'
|
||||
|
||||
/**
|
||||
* 首页「优势模块」统一数据来源
|
||||
*
|
||||
* 配置来自后台 cms_website_setting.features(经 getSiteInfo 合并进 siteInfo.setting),
|
||||
* 为 JSON 字符串。未配置 / 解析失败时回退到各模板自己的默认数据(defaultFeatures)。
|
||||
*
|
||||
* 兼容策略:
|
||||
* - 完全未配置(siteSetting.features 不存在)→ 显示 defaultFeatures,标题走兜底
|
||||
* - 已配置且 enabled=false → enabled=false,上层 v-if 隐藏整块
|
||||
* - 已配置但 items 为空 → 回退到 defaultFeatures(避免空白区)
|
||||
* - items 超过 4 个 → 截断到 4 个(后台也限制最多 4 个)
|
||||
*
|
||||
* 2026-08-05 新增「首页区块总览」新结构:
|
||||
* { hero, advantages, products, cases, news, banner, cta }
|
||||
* 同时保留对旧结构(顶层 enabled/title/subtitle/items/heroFeatures)的兼容。
|
||||
*
|
||||
* @param defaultFeatures 模板默认卡片(各模板 FeatureSection 传入自己的硬编码项)
|
||||
*/
|
||||
export function useFeatures(defaultFeatures: FeatureItem[] = []) {
|
||||
const { siteSetting } = useSite()
|
||||
|
||||
const config = computed<FeatureSectionSetting | null>(() => {
|
||||
const raw = siteSetting.value?.features
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
|
||||
if (parsed && typeof parsed === 'object') return parsed as FeatureSectionSetting
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
/** 是否已配置(用于区分「后台显式关闭」与「未接入」) */
|
||||
const configured = computed(() => !!config.value)
|
||||
|
||||
/**
|
||||
* 判断是否为新结构(区块化)
|
||||
* 新结构至少包含 hero 或 advantages 字段。
|
||||
*/
|
||||
const isBlockMode = computed(() => {
|
||||
const c = config.value
|
||||
if (!c) return false
|
||||
return !!(c.hero || c.advantages)
|
||||
})
|
||||
|
||||
/** 是否显示优势模块:未配置默认显示;已配置以 enabled 为准(默认 true) */
|
||||
const enabled = computed(() => {
|
||||
const c = config.value
|
||||
if (!c) return true
|
||||
// 新结构:读 advantages.enabled;旧结构:读顶层 enabled
|
||||
if (isBlockMode.value) {
|
||||
return c.advantages?.enabled !== false
|
||||
}
|
||||
return c.enabled !== false
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
const c = config.value
|
||||
if (!c) return '我们的优势'
|
||||
if (isBlockMode.value) return c.advantages?.title || '我们的优势'
|
||||
return c.title || '我们的优势'
|
||||
})
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const c = config.value
|
||||
if (!c) return ''
|
||||
if (isBlockMode.value) return c.advantages?.subtitle || ''
|
||||
return c.subtitle || ''
|
||||
})
|
||||
|
||||
const items = computed<FeatureItem[]>(() => {
|
||||
const c = config.value
|
||||
const cfgItems = isBlockMode.value ? c?.advantages?.items : c?.items
|
||||
if (!cfgItems || !Array.isArray(cfgItems) || cfgItems.length === 0) {
|
||||
return defaultFeatures
|
||||
}
|
||||
return cfgItems.slice(0, 4).map((it) => ({
|
||||
title: it?.title || '',
|
||||
desc: it?.desc || '',
|
||||
icon: it?.icon || 'box'
|
||||
}))
|
||||
})
|
||||
|
||||
/** Hero 首屏右侧特性卡片文案(纯文本),空数组时上层回退模板默认值 */
|
||||
const heroFeatures = computed<string[]>(() => {
|
||||
const c = config.value
|
||||
const hf = isBlockMode.value ? c?.hero?.features : c?.heroFeatures
|
||||
if (!hf || !Array.isArray(hf) || hf.length === 0) return []
|
||||
return hf.slice(0, 4).map((s) => String(s ?? '').trim()).filter(Boolean)
|
||||
})
|
||||
|
||||
/**
|
||||
* 首页各区块显示开关。
|
||||
* 未配置(或旧结构)时,除预留位 products/cases/news/banner/cta 默认 false 外,
|
||||
* hero 与 advantages 默认 true;news/cta 在 template-01 有实际内容,旧结构下默认 true。
|
||||
*/
|
||||
const homeBlocks = computed<Required<Pick<HomeBlocks, 'hero' | 'advantages' | 'products' | 'cases' | 'news' | 'banner' | 'cta'>>>(() => {
|
||||
const c = config.value
|
||||
const defaults = {
|
||||
hero: true,
|
||||
advantages: true,
|
||||
products: false,
|
||||
cases: false,
|
||||
news: true,
|
||||
banner: true,
|
||||
cta: true
|
||||
}
|
||||
if (!c) return defaults
|
||||
|
||||
if (isBlockMode.value) {
|
||||
return {
|
||||
hero: c.hero?.enabled !== false,
|
||||
advantages: c.advantages?.enabled !== false,
|
||||
// 产品/案例为预留位,默认不显示,需后台显式开启
|
||||
products: c.products?.enabled === true,
|
||||
cases: c.cases?.enabled === true,
|
||||
// 新闻/CTA 为多数模板首页实际区块,默认显示,除非后台显式关闭
|
||||
news: c.news?.enabled !== false,
|
||||
banner: c.banner?.enabled !== false,
|
||||
cta: c.cta?.enabled !== false
|
||||
}
|
||||
}
|
||||
|
||||
// 旧结构:只有 advantages/hero 的语义;保留 news/cta 默认显示以兼容存量站点
|
||||
return {
|
||||
hero: c.enabled !== false,
|
||||
advantages: c.enabled !== false,
|
||||
products: false,
|
||||
cases: false,
|
||||
news: true,
|
||||
banner: true,
|
||||
cta: true
|
||||
}
|
||||
})
|
||||
|
||||
return { configured, enabled, title, subtitle, items, heroFeatures, homeBlocks, isBlockMode }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 文件 URL 处理
|
||||
* 将后端返回的相对路径转为通过代理访问的 URL
|
||||
*/
|
||||
|
||||
/** 文件代理基础路径 */
|
||||
export function useFileUrl() {
|
||||
const _config = useRuntimeConfig()
|
||||
|
||||
/**
|
||||
* 将文件路径转为可访问的 URL
|
||||
* - 完整 URL(http/https)直接返回
|
||||
* - 相对路径通过 /api/file/ 代理
|
||||
*/
|
||||
function fileUrl(path?: string | null): string {
|
||||
if (!path) return ''
|
||||
|
||||
// 完整 URL 直接返回
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path
|
||||
}
|
||||
|
||||
// 相对路径通过代理
|
||||
const cleanPath = path.startsWith('/') ? path.slice(1) : path
|
||||
return `/api/file/${cleanPath}`
|
||||
}
|
||||
|
||||
return { fileUrl }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { computed, shallowRef, type Component } from 'vue'
|
||||
import { useNuxtApp, useRoute, useRequestURL } from '#imports'
|
||||
import { useSite } from './useSite'
|
||||
import { useTemplate } from './useTemplate'
|
||||
import { usePageSeo, useJsonLd, useBreadcrumbSeo } from '~/composables/usePageSeo'
|
||||
|
||||
type ModuleKey = 'article' | 'product' | 'case'
|
||||
|
||||
const MODULE_COMPONENTS: Record<ModuleKey, { list: string; detail: string }> = {
|
||||
article: { list: 'NewsList', detail: 'NewsDetail' },
|
||||
product: { list: 'ProductList', detail: 'ProductDetail' },
|
||||
case: { list: 'CaseList', detail: 'CaseDetail' }
|
||||
}
|
||||
|
||||
const MODULE_TITLE: Record<ModuleKey, string> = {
|
||||
article: '新闻资讯',
|
||||
product: '产品中心',
|
||||
case: '案例展示'
|
||||
}
|
||||
|
||||
/** 取当前站点 origin(服务端/客户端通用),用于结构化数据图片绝对化 */
|
||||
function getOriginForSeo(): string {
|
||||
if (import.meta.client) return window.location.origin
|
||||
try {
|
||||
return useRequestURL().origin
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** 相对 URL 转绝对(结构化数据 image 必须为绝对地址) */
|
||||
function toAbsUrl(url: string, origin: string): string {
|
||||
if (/^https?:\/\//.test(url)) return url
|
||||
if (!origin) return url
|
||||
try {
|
||||
return new URL(url, origin).toString()
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情页 SEO 注入:拉取详情数据并设置 TDK + OG + 结构化数据 + 面包屑。
|
||||
* 与详情组件内部 useFetch 使用相同 key(article-${id} 等),Nuxt 自动去重,不重复请求。
|
||||
* 覆盖全部 9 套模板的 article/product/case 详情页,避免逐模板硬编码。
|
||||
*/
|
||||
function injectDetailSeo(
|
||||
module: ModuleKey,
|
||||
route: ReturnType<typeof useRoute>,
|
||||
id: string,
|
||||
siteInfo: ReturnType<typeof useSite>['siteInfo'],
|
||||
currentNav: ReturnType<typeof computed>,
|
||||
runWithContext: <T>(fn: () => T) => T
|
||||
) {
|
||||
return (async () => {
|
||||
const detailKey = `${module}-${id}`
|
||||
// useFetch 同样依赖 Nuxt 实例:本函数在 useModuleRoute 的多个 await 之后才执行,
|
||||
// 异步上下文已丢失,必须显式 runWithContext 包裹,否则 SSR 直接 500。
|
||||
const { data: detail } = await runWithContext(() =>
|
||||
useFetch<any>(`/api/${module}/detail?id=${id}`, { key: detailKey })
|
||||
)
|
||||
const d = detail.value
|
||||
const title = d?.title || d?.productName || MODULE_TITLE[module]
|
||||
const description = stripHtml(
|
||||
d?.summary || d?.description || d?.subtitle || ''
|
||||
).slice(0, 160) || undefined
|
||||
const image = d?.image || d?.cover || d?.photo || undefined
|
||||
const keywords = Array.isArray(d?.tags)
|
||||
? d.tags.join(',')
|
||||
: (typeof d?.tags === 'string' ? d.tags : undefined)
|
||||
const publishedTime = d?.publishTime || d?.createTime || undefined
|
||||
const modifiedTime = d?.updateTime || undefined
|
||||
|
||||
const origin = getOriginForSeo()
|
||||
const absImage = image ? toAbsUrl(image, origin) : undefined
|
||||
|
||||
runWithContext(() => {
|
||||
usePageSeo(
|
||||
{
|
||||
title,
|
||||
description,
|
||||
keywords,
|
||||
path: route.path,
|
||||
image: absImage,
|
||||
type: module === 'article' ? 'article' : module === 'product' ? 'product' : 'website',
|
||||
publishedTime,
|
||||
modifiedTime
|
||||
},
|
||||
siteInfo.value
|
||||
)
|
||||
|
||||
// 结构化数据
|
||||
if (module === 'article') {
|
||||
useJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: title,
|
||||
description: description || '',
|
||||
...(absImage ? { image: [absImage] } : {}),
|
||||
datePublished: publishedTime,
|
||||
dateModified: modifiedTime,
|
||||
author: { '@type': 'Organization', name: siteInfo.value?.websiteName || '' }
|
||||
})
|
||||
} else if (module === 'product') {
|
||||
useJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Product',
|
||||
name: title,
|
||||
description: description || '',
|
||||
...(absImage ? { image: [absImage] } : {}),
|
||||
...(d?.price ? { offers: { '@type': 'Offer', price: d.price, priceCurrency: 'CNY' } } : {})
|
||||
})
|
||||
} else {
|
||||
useJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'CreativeWork',
|
||||
name: title,
|
||||
description: description || '',
|
||||
...(absImage ? { image: [absImage] } : {})
|
||||
})
|
||||
}
|
||||
|
||||
// 面包屑
|
||||
useBreadcrumbSeo([
|
||||
{ name: '首页', url: '/' },
|
||||
{ name: currentNav.value?.title || MODULE_TITLE[module], url: `/${module}` },
|
||||
{ name: title, url: route.path }
|
||||
])
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
/**
|
||||
* 模块路由(栏目列表 / 详情 二合一)。
|
||||
*
|
||||
* URL 规范(按模块名单数 + navId 运行时判定):
|
||||
* 列表(栏目):/{module} 或 /{module}/{navigationId}
|
||||
* 详情(条目):/{module}/{id}
|
||||
* 同一 /{module}/{id} 下,靠「id 是否该模块已知栏目 navigationId」判定:
|
||||
* 命中 → 渲染列表组件(按 navigationId 过滤);否则 → 渲染详情组件。
|
||||
*
|
||||
* 旧链接(/news、/newss、/products、/cases)由
|
||||
* server/middleware/z-news-detail-redirect.ts 统一 301 到新模块名。
|
||||
*/
|
||||
export async function useModuleRoute(module: ModuleKey) {
|
||||
const route = useRoute()
|
||||
// 先捕获 Nuxt 实例:下面有 await,之后再直接调用依赖实例的 composable(如 usePageSeo →
|
||||
// useRuntimeConfig)会因失去异步上下文而报 "A composable that requires access to the
|
||||
// Nuxt instance was called outside of...",需用 runWithContext 显式恢复上下文。
|
||||
const nuxtApp = useNuxtApp()
|
||||
const { loadTemplate } = useTemplate()
|
||||
const { siteInfo, allNavigations, fetchSiteInfo } = useSite()
|
||||
|
||||
await fetchSiteInfo()
|
||||
const components = await loadTemplate()
|
||||
|
||||
// 当前模块下所有栏目 navigationId(用于区分「列表」与「详情」)
|
||||
// 关键:用完整导航树 allNavigations(top+bottom,不限 top===1),而非仅顶部导航;
|
||||
// 且以 path 首段 /{module}/ 为主判定(与 getNavLink 生成的链接一致),
|
||||
// model 仅作兜底——CMS 对 case 等模块 model 字段常缺失/不一致,纯靠 model 会漏判。
|
||||
const moduleNavIds = computed<Set<string>>(() => {
|
||||
const set = new Set<string>()
|
||||
const collect = (items: any[] = []) => {
|
||||
for (const it of items) {
|
||||
if (it.navigationId != null) {
|
||||
const path = it.path || it.categoryPath || ''
|
||||
const firstSeg = path.split('/').filter(Boolean)[0]
|
||||
const isModule =
|
||||
firstSeg === module || // path 前缀 /{module}/(与 getNavLink 一致)
|
||||
it.model === module // model 兜底
|
||||
if (isModule) set.add(String(it.navigationId))
|
||||
}
|
||||
if (it.children?.length) collect(it.children)
|
||||
}
|
||||
}
|
||||
collect(allNavigations.value)
|
||||
return set
|
||||
})
|
||||
|
||||
const id = String(route.params.id)
|
||||
const isColumn = moduleNavIds.value.has(id)
|
||||
|
||||
const currentNav = computed(() => {
|
||||
const navId = String(route.params.id)
|
||||
const find = (items: any[] = []): any => {
|
||||
for (const it of items) {
|
||||
if (String(it.navigationId) === navId) return it
|
||||
if (it.children?.length) {
|
||||
const f = find(it.children)
|
||||
if (f) return f
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
return find(allNavigations.value)
|
||||
})
|
||||
|
||||
const pageComponent = shallowRef<Component | null>(null)
|
||||
const comp = isColumn
|
||||
? components?.[MODULE_COMPONENTS[module].list as keyof typeof components]
|
||||
: components?.[MODULE_COMPONENTS[module].detail as keyof typeof components]
|
||||
pageComponent.value = (comp as Component | null) || null
|
||||
|
||||
// ===== SEO 注入 =====
|
||||
if (!isColumn) {
|
||||
// 详情页:通过统一入口注入 TDK + 结构化数据 + 面包屑(覆盖全部 9 套模板)。
|
||||
// 与详情组件内部 useFetch 使用相同 key,Nuxt 自动去重,不重复请求。
|
||||
await injectDetailSeo(module, route, id, siteInfo, currentNav, (fn) => nuxtApp.runWithContext(fn))
|
||||
} else {
|
||||
// 栏目(列表)页:用栏目标题 + 干净路径(去掉 ?navId 等查询参数)作为 canonical。
|
||||
nuxtApp.runWithContext(() => {
|
||||
usePageSeo(
|
||||
{
|
||||
title: currentNav.value?.title || MODULE_TITLE[module],
|
||||
path: route.path
|
||||
},
|
||||
siteInfo.value
|
||||
)
|
||||
|
||||
// 搜索结果页(带 ?keywords=)属于低质量/易重复页面,禁止被索引
|
||||
if (route.query.keywords) {
|
||||
useSeoMeta({ robots: 'noindex, follow' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { route, pageComponent, isColumn, currentNav }
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useHead, useRequestURL, useSeoMeta, useRuntimeConfig } from '#app'
|
||||
import type { CmsSiteInfo } from '~/types'
|
||||
|
||||
type SeoInput = {
|
||||
title: string
|
||||
description?: string
|
||||
keywords?: string
|
||||
path?: string
|
||||
image?: string
|
||||
type?: 'website' | 'article' | 'product'
|
||||
siteName?: string
|
||||
publishedTime?: string
|
||||
modifiedTime?: string
|
||||
}
|
||||
|
||||
function getSiteOrigin() {
|
||||
if (import.meta.client) return window.location.origin
|
||||
try {
|
||||
return useRequestURL().origin
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面 SEO 设置
|
||||
* 设置 TDK、Open Graph、Twitter Card、Canonical URL
|
||||
*/
|
||||
export function usePageSeo(input: SeoInput, site?: CmsSiteInfo | null) {
|
||||
const origin = getSiteOrigin()
|
||||
const url = input.path && origin ? new URL(input.path, origin).toString() : undefined
|
||||
const overrideSiteName = (useRuntimeConfig().public.siteName as string) || ''
|
||||
const siteName = input.siteName || overrideSiteName || site?.websiteName || ''
|
||||
const description = input.description || site?.comments || site?.content || ''
|
||||
const keywords = input.keywords || site?.keywords || ''
|
||||
const image = input.image || site?.websiteLogo || ''
|
||||
|
||||
const fullTitle = siteName ? `${input.title} - ${siteName}` : input.title
|
||||
|
||||
useSeoMeta({
|
||||
title: fullTitle,
|
||||
description,
|
||||
keywords,
|
||||
ogTitle: input.title,
|
||||
ogDescription: description,
|
||||
ogType: input.type || 'website',
|
||||
ogSiteName: siteName,
|
||||
...(image ? { ogImage: image } : {}),
|
||||
...(url ? { ogUrl: url } : {}),
|
||||
twitterCard: 'summary_large_image',
|
||||
twitterTitle: input.title,
|
||||
twitterDescription: description,
|
||||
...(image ? { twitterImage: image } : {}),
|
||||
...(input.publishedTime ? { articlePublishedTime: input.publishedTime } : {}),
|
||||
...(input.modifiedTime ? { articleModifiedTime: input.modifiedTime } : {})
|
||||
})
|
||||
|
||||
if (url) {
|
||||
useHead({
|
||||
link: [{ rel: 'canonical', href: url }]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入 JSON-LD 结构化数据
|
||||
*/
|
||||
export function useJsonLd(data: Record<string, unknown> | Record<string, unknown>[]) {
|
||||
const items = Array.isArray(data) ? data : [data]
|
||||
|
||||
useHead({
|
||||
script: items.map((item) => ({
|
||||
type: 'application/ld+json',
|
||||
innerHTML: JSON.stringify(item)
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入企业组织结构化数据
|
||||
*/
|
||||
export function useOrganizationSeo(site?: CmsSiteInfo | null) {
|
||||
if (!site) return
|
||||
|
||||
const origin = getSiteOrigin()
|
||||
const rawPhone = site.phone || ''
|
||||
const phone = rawPhone && !rawPhone.includes('*')
|
||||
? rawPhone
|
||||
: (useRuntimeConfig().public.phone as string) || site.config?.tel || rawPhone || ''
|
||||
const email = site.config?.email || site.email || ''
|
||||
const address = site.address || site.config?.address || ''
|
||||
const logo = site.websiteLogo || ''
|
||||
const overrideSiteName = (useRuntimeConfig().public.siteName as string) || ''
|
||||
|
||||
useJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: overrideSiteName || site.websiteName || '',
|
||||
url: origin,
|
||||
...(logo ? { logo: new URL(logo, origin).toString() } : {}),
|
||||
...(phone ? { telephone: phone } : {}),
|
||||
...(email ? { email: email } : {}),
|
||||
...(address ? { address: { '@type': 'PostalAddress', address } } : {})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入面包屑结构化数据
|
||||
*/
|
||||
export function useBreadcrumbSeo(items: { name: string; url: string }[]) {
|
||||
const origin = getSiteOrigin()
|
||||
|
||||
useJsonLd({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: items.map((item, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
name: item.name,
|
||||
item: new URL(item.url, origin).toString()
|
||||
}))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
import type { Article, Product, CaseItem } from '~/types'
|
||||
|
||||
/**
|
||||
* 读取「推荐 / 置顶」精选内容的共享 composable
|
||||
*
|
||||
* 用途:首页文章 / 产品 / 案例区块读取后台勾选的推荐或置顶内容,
|
||||
* 抽成统一逻辑后供任意模板的推荐区块组件复用。
|
||||
*
|
||||
* 数据源:
|
||||
* - 文章 article:上游 cms-article 返回 `recommend` 字段(0/1)→ 推荐资讯
|
||||
* - 产品 product:上游 cms-product 返回 `top` 字段(>0 为置顶)→ 置顶/推荐产品
|
||||
* - 案例 case: 上游 cms-case 当前表结构【无】recommend/top 字段,
|
||||
* 故默认回退为「最新案例」;代码已预留过滤,后台字段上线即自动切换为推荐
|
||||
*
|
||||
* 过滤采用多字段兼容判定(recommend / top / isTop / isRecommend),
|
||||
* 兼容不同 CMS 版本与模型的命名差异。
|
||||
*
|
||||
* 排序:推荐/置顶项优先,其次按 top 值、sortNumber、创建时间降序。
|
||||
*
|
||||
* 实现要点(避免 SSR 水合问题):
|
||||
* - 使用 useFetch 而非 $fetch,让 Nuxt 在 SSR 时直接调用 server handler 并序列化到 payload,
|
||||
* 客户端水合时不再重新请求,确保 SSR/CSR 看到的数据完全一致。
|
||||
* - SSR 时从 useRequestEvent().context.tenant 读取 tenantId 并透传给内部调用,
|
||||
* 防止 server/api 内部 loopback 调用因丢失 Host 上下文而无法解析租户。
|
||||
*/
|
||||
export type RecommendType = 'article' | 'product' | 'case'
|
||||
|
||||
const PATH_MAP: Record<RecommendType, string> = {
|
||||
article: '/api/article/list',
|
||||
product: '/api/product/list',
|
||||
case: '/api/case/list'
|
||||
}
|
||||
|
||||
/** 多字段兼容:判定单条记录是否属于「推荐 / 置顶」 */
|
||||
function isRecommended(item: any): boolean {
|
||||
if (!item) return false
|
||||
const top = Number(item.top ?? 0)
|
||||
const isTop = Number(item.isTop ?? 0)
|
||||
const isRecommend = Number(item.isRecommend ?? 0)
|
||||
const recommend = item.recommend
|
||||
return (
|
||||
top > 0 ||
|
||||
isTop > 0 ||
|
||||
isRecommend > 0 ||
|
||||
recommend === 1 ||
|
||||
recommend === true
|
||||
)
|
||||
}
|
||||
|
||||
/** 推荐/置顶优先排序 */
|
||||
function sortRecommended<T extends Record<string, any>>(list: T[]): T[] {
|
||||
return [...list].sort((a, b) => {
|
||||
const ra = isRecommended(a) ? 1 : 0
|
||||
const rb = isRecommended(b) ? 1 : 0
|
||||
if (ra !== rb) return rb - ra
|
||||
const ta = Number(a?.top ?? 0)
|
||||
const tb = Number(b?.top ?? 0)
|
||||
if (tb !== ta) return tb - ta
|
||||
const sa = Number(a?.sortNumber ?? 0)
|
||||
const sb = Number(b?.sortNumber ?? 0)
|
||||
if (sa !== sb) return sa - sb
|
||||
const ca = a?.createTime ? new Date(a.createTime).getTime() : 0
|
||||
const cb = b?.createTime ? new Date(b.createTime).getTime() : 0
|
||||
return cb - ca
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一解包列表:兼容 article(上游信封 {data:{list}})与 product/case({list})
|
||||
*
|
||||
* 幂等设计:传入已经是数组时原样返回。useFetch 的 transform 在 payload 复用 /
|
||||
* 重新校验等场景下有被再次施加于「已转换结果」的可能,若此时返回空数组会导致
|
||||
* 区块凭空清空,故此处必须容忍数组入参。
|
||||
*/
|
||||
function unwrapList(res: any): any[] {
|
||||
if (!res) return []
|
||||
if (Array.isArray(res)) return res
|
||||
const data = res?.data ?? res
|
||||
if (Array.isArray(data?.list)) return data.list
|
||||
if (Array.isArray(res?.list)) return res.list
|
||||
return []
|
||||
}
|
||||
|
||||
export interface UseRecommendOptions {
|
||||
/** 展示条数(过滤 + 排序后截取),默认 3 */
|
||||
limit?: number
|
||||
/** 拉取池大小(用于覆盖全部推荐项,避免遗漏),默认 50 */
|
||||
poolSize?: number
|
||||
/**
|
||||
* 无推荐项时是否回退最新内容。
|
||||
* 默认:case=true(上游无推荐字段,展示最新案例);article/product=false(返回空,区块按后台开关显示为空)
|
||||
*/
|
||||
fallbackToLatest?: boolean
|
||||
/** 按栏目过滤(传 navigationId);不传则全站跨栏目精选 */
|
||||
navigationId?: number
|
||||
}
|
||||
|
||||
export interface UseRecommendReturn<T> {
|
||||
items: Ref<T[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<string | null>
|
||||
/** 占位:保持旧接口兼容;useFetch 自动处理数据获取,无需手动调用 */
|
||||
load: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取推荐/置顶内容
|
||||
* @example
|
||||
* const { items } = useRecommend<Article>('article', { limit: 3 })
|
||||
*/
|
||||
export function useRecommend<T extends Record<string, any> = any>(
|
||||
type: RecommendType,
|
||||
options: UseRecommendOptions = {}
|
||||
): UseRecommendReturn<T> {
|
||||
const {
|
||||
limit = 3,
|
||||
poolSize = 50,
|
||||
fallbackToLatest = type === 'case',
|
||||
navigationId
|
||||
} = options
|
||||
|
||||
// SSR 时透传租户上下文,避免内部 loopback 调用因 Host 被改写为 localhost
|
||||
// 而无法解析租户。仅在原始请求未显式带 TenantId header 时才补充,防止
|
||||
// useFetch 合并原始 header 与显式 header 导致重复值(上游 CMS 会报 SQL 错误)。
|
||||
const event = useRequestEvent()
|
||||
const tenantId = event?.context?.tenant?.tenantId as string | undefined
|
||||
const originalTenantHeader = event?.node?.req?.headers?.tenantid as string | undefined
|
||||
const needsTenantHeader = tenantId && !originalTenantHeader
|
||||
|
||||
const query: Record<string, any> = { page: 1, limit: poolSize }
|
||||
if (navigationId) query.navigationId = navigationId
|
||||
|
||||
const key = `recommend:${type}:${navigationId ?? 'all'}:${poolSize}:${limit}:${fallbackToLatest ? 1 : 0}`
|
||||
|
||||
const { data, pending, error: fetchError } = useFetch<T[]>(PATH_MAP[type], {
|
||||
key,
|
||||
query,
|
||||
headers: needsTenantHeader ? { TenantId: tenantId } : undefined,
|
||||
transform: (res: any): T[] => {
|
||||
const rawList = unwrapList(res)
|
||||
const recommended = rawList.filter(isRecommended)
|
||||
if (recommended.length > 0) {
|
||||
return sortRecommended(recommended).slice(0, limit) as T[]
|
||||
}
|
||||
if (fallbackToLatest) {
|
||||
return sortRecommended(rawList).slice(0, limit) as T[]
|
||||
}
|
||||
return [] as T[]
|
||||
},
|
||||
// 服务端获取失败时静默降级为空数组,避免页面 500
|
||||
default: () => [] as T[]
|
||||
})
|
||||
|
||||
const items = computed<T[]>(() => (data.value || []) as T[])
|
||||
const loading = computed(() => pending.value)
|
||||
const error = computed<string | null>(() => (fetchError.value ? String(fetchError.value) : null))
|
||||
|
||||
// 保持旧接口兼容:调用方仍可 await load(),但 useFetch 已经自动完成获取
|
||||
async function load(): Promise<void> {
|
||||
// no-op:useFetch 在 setup 中自动触发,客户端路由切换也会自动重新获取
|
||||
}
|
||||
|
||||
return { items, loading, error, load }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { SubscriptionStatus } from '~/types'
|
||||
|
||||
/**
|
||||
* 订阅状态校验
|
||||
* 通过 /api/subscription/status 查询当前租户应用的订阅状态
|
||||
*/
|
||||
export function useSubscription() {
|
||||
const status = useState<SubscriptionStatus | null>('subscription-status', () => null)
|
||||
const loading = useState<boolean>('subscription-loading', () => false)
|
||||
const expired = computed(() => {
|
||||
if (!status.value) return false
|
||||
return status.value.expired === true || status.value.status === 'expired'
|
||||
})
|
||||
|
||||
async function fetchStatus() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await $fetch<SubscriptionStatus>('/api/subscription/status')
|
||||
status.value = res
|
||||
return res
|
||||
} catch {
|
||||
// 接口异常时默认允许访问
|
||||
status.value = { subscribed: true, status: 'active', expired: false }
|
||||
return status.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 检查是否过期,过期则跳转续费页 */
|
||||
async function checkAndRedirect() {
|
||||
await fetchStatus()
|
||||
if (expired.value) {
|
||||
await navigateTo('/renewal')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
loading,
|
||||
expired,
|
||||
fetchStatus,
|
||||
checkAndRedirect
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { Component } from 'vue'
|
||||
import { useApp } from './useApp'
|
||||
import { useSite } from './useSite'
|
||||
|
||||
/** 模板配置 */
|
||||
export interface TemplateConfig {
|
||||
/** 模板 ID */
|
||||
id: string
|
||||
/** 模板名称 */
|
||||
name: string
|
||||
/** 模板描述 */
|
||||
description: string
|
||||
/** 预览图路径 */
|
||||
preview: string
|
||||
/** 支持的模块 */
|
||||
supportedModules: string[]
|
||||
/** 主题配置 */
|
||||
themeConfig?: {
|
||||
primaryColor?: string
|
||||
secondaryColor?: string
|
||||
fontFamily?: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 模板组件映射 */
|
||||
export interface TemplateComponents {
|
||||
/** 首页布局 */
|
||||
Home: Component
|
||||
/** 通用 CMS 页面布局 */
|
||||
Page?: Component
|
||||
/** 文章列表页 */
|
||||
NewsList?: Component
|
||||
/** 文章详情页 */
|
||||
NewsDetail?: Component
|
||||
/** 产品列表页 */
|
||||
ProductList?: Component
|
||||
/** 产品详情页 */
|
||||
ProductDetail?: Component
|
||||
/** 案例列表页 */
|
||||
CaseList?: Component
|
||||
/** 案例详情页 */
|
||||
CaseDetail?: Component
|
||||
/** 联系我们页 */
|
||||
Contact?: Component
|
||||
/** 关于我们页 */
|
||||
About?: Component
|
||||
/** Header 组件 */
|
||||
Header: Component
|
||||
/** Footer 组件 */
|
||||
Footer: Component
|
||||
}
|
||||
|
||||
/**
|
||||
* 模板加载器
|
||||
* 根据模板 ID 动态加载对应模板组件
|
||||
*/
|
||||
export function useTemplate() {
|
||||
const runtimePublic = useRuntimeConfig().public
|
||||
const defaultTemplateId = runtimePublic.templateId as string
|
||||
// 强制模板 ID(本地调试用,最高优先级,覆盖应用/站点绑定的模板)
|
||||
const forceTemplateId = (runtimePublic.forceTemplateId as string) || ''
|
||||
|
||||
// 应用 / 站点绑定的模板 ID(应用库优先,站点库次之)
|
||||
const { appTemplateId } = useApp()
|
||||
const { templateId: siteTemplateId, templateIdFallback } = useSite()
|
||||
|
||||
/** 手动覆盖的模板 ID(保持可写,兼容原有行为;默认等于默认配置) */
|
||||
const templateId = useState<string>('template-id', () => defaultTemplateId)
|
||||
|
||||
/** 解析后的实际模板 ID:
|
||||
* 强制(本地 env 调试,最高优先级) > 站点(cms_website,按租户隔离的真相源) > 应用(app_product) > 默认配置
|
||||
* 站点优先于应用:同一产品被多租户共享时,应用级 template_id 不能盖掉租户各自在 cms_website 中的选择。
|
||||
* 注:forceTemplateId 仅本地调试设置,生产环境为空,因此不影响线上解析。 */
|
||||
const resolvedTemplateId = computed(() => {
|
||||
return forceTemplateId || siteTemplateId.value || appTemplateId.value || defaultTemplateId
|
||||
})
|
||||
|
||||
/** 所有已注册的模板 */
|
||||
const templates = useState<Record<string, TemplateConfig>>('templates-registry', () => ({}))
|
||||
|
||||
/** 模板组件缓存:放在模块级 Map 中,避免 devalue 序列化组件对象 */
|
||||
const templateComponentsCache = new Map<string, TemplateComponents>()
|
||||
|
||||
/** 注册模板 */
|
||||
function registerTemplate(config: TemplateConfig) {
|
||||
templates.value[config.id] = config
|
||||
}
|
||||
|
||||
/** 当前模板配置 */
|
||||
const currentTemplate = computed(() => {
|
||||
return templates.value[resolvedTemplateId.value] || null
|
||||
})
|
||||
|
||||
/**
|
||||
* 动态导入模板组件
|
||||
* Nuxt 自动导入 app/templates/[id]/ 下的组件(~ 别名指向 app/)
|
||||
*/
|
||||
async function loadTemplate(id?: string): Promise<TemplateComponents | null> {
|
||||
// [临时验证日志] 确认后台选用模板已通过 getSiteInfo → siteTemplateId 生效;验证通过后删除本行
|
||||
if (import.meta.dev) {
|
||||
console.log('[useTemplate] loadTemplate id=', id, '| resolvedTemplateId=', resolvedTemplateId.value)
|
||||
}
|
||||
const targetId = id || resolvedTemplateId.value
|
||||
templateId.value = targetId
|
||||
|
||||
// 命中缓存则直接返回(同一次页面生命周期内避免重复 import)
|
||||
const cached = templateComponentsCache.get(targetId)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
try {
|
||||
// 动态导入模板组件(~ 别名指向 app/,因此模板路径为 app/templates/[id]/)
|
||||
const header = (await import(`~/templates/${targetId}/components/Header.vue`)).default
|
||||
const footer = (await import(`~/templates/${targetId}/components/Footer.vue`)).default
|
||||
const home = (await import(`~/templates/${targetId}/pages/Home.vue`)).default
|
||||
|
||||
const components: TemplateComponents = { Header: header, Footer: footer, Home: home }
|
||||
|
||||
// 可选组件:尝试加载,不存在则忽略
|
||||
// 注意:模板字符串必须直接写在 import() 内(与上方 Header/Footer/Home 一致),
|
||||
// 否则 Vite 无法静态分析「变量路径」,运行时会 import 失败、
|
||||
// 被 try/catch 静默吞掉,导致 Page 等可选组件永远 undefined → 页面卡在 SiteLoading。
|
||||
const optionalComponents: { key: keyof TemplateComponents; file: string }[] = [
|
||||
{ key: 'Page', file: 'Page' },
|
||||
{ key: 'NewsList', file: 'NewsList' },
|
||||
{ key: 'NewsDetail', file: 'NewsDetail' },
|
||||
{ key: 'ProductList', file: 'ProductList' },
|
||||
{ key: 'ProductDetail', file: 'ProductDetail' },
|
||||
{ key: 'CaseList', file: 'CaseList' },
|
||||
{ key: 'CaseDetail', file: 'CaseDetail' },
|
||||
{ key: 'Contact', file: 'Contact' },
|
||||
{ key: 'About', file: 'About' }
|
||||
]
|
||||
|
||||
for (const { key, file } of optionalComponents) {
|
||||
try {
|
||||
const mod = await import(`~/templates/${targetId}/pages/${file}.vue`)
|
||||
if (mod.default) {
|
||||
components[key] = mod.default
|
||||
}
|
||||
} catch {
|
||||
// 可选组件不存在时跳过
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存并返回
|
||||
templateComponentsCache.set(targetId, components)
|
||||
return components
|
||||
} catch {
|
||||
// 目录不存在(如后台 code 指向前端尚未落地的模板):
|
||||
// 先尝试「按主键推导」的兜底目录,仍失败才跌回默认模板,避免直接错乱成 template-01。
|
||||
const fallbackById = templateIdFallback.value
|
||||
if (fallbackById && targetId !== fallbackById) {
|
||||
return loadTemplate(fallbackById)
|
||||
}
|
||||
if (targetId !== 'template-01') {
|
||||
return loadTemplate('template-01')
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
templateId,
|
||||
resolvedTemplateId,
|
||||
templates,
|
||||
currentTemplate,
|
||||
registerTemplate,
|
||||
loadTemplate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { TenantContext } from '~/types'
|
||||
|
||||
/**
|
||||
* 获取当前租户上下文
|
||||
* 服务端从 event.context.tenant 获取(由 Server Middleware 设置)
|
||||
* 客户端从 SSR 注入的 payload 获取
|
||||
*/
|
||||
export function useTenant() {
|
||||
const ctx = useState<TenantContext>('tenant-context', () => ({
|
||||
tenantId: useRuntimeConfig().public.tenantId as string,
|
||||
appId: useRuntimeConfig().public.appId as string,
|
||||
templateId: useRuntimeConfig().public.templateId as string,
|
||||
source: 'env'
|
||||
}))
|
||||
|
||||
// SSR 时从服务端请求头获取
|
||||
if (import.meta.server) {
|
||||
const event = useRequestEvent()
|
||||
if (event?.context?.tenant) {
|
||||
ctx.value = event.context.tenant as TenantContext
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
Reference in New Issue
Block a user