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
+78
View File
@@ -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
}
}
+132
View File
@@ -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
}
}
+31
View File
@@ -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 }
}
+71
View File
@@ -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 }
}
+142
View File
@@ -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 默认 truenews/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 }
}
+29
View File
@@ -0,0 +1,29 @@
/**
* 文件 URL 处理
* 将后端返回的相对路径转为通过代理访问的 URL
*/
/** 文件代理基础路径 */
export function useFileUrl() {
const _config = useRuntimeConfig()
/**
* 将文件路径转为可访问的 URL
* - 完整 URLhttp/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 }
}
+228
View File
@@ -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 使用相同 keyarticle-${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(用于区分「列表」与「详情」)
// 关键:用完整导航树 allNavigationstop+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 }
}
+123
View File
@@ -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()
}))
})
}
+165
View File
@@ -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-opuseFetch 在 setup 中自动触发,客户端路由切换也会自动重新获取
}
return { items, loading, error, load }
}
+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
}
}
+45
View File
@@ -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
}
}
+172
View File
@@ -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
}
}
+25
View File
@@ -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
}