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
+27
View File
@@ -0,0 +1,27 @@
import { defineEventHandler } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
import { getAppProductById } from '../../utils/app'
import type { TenantContext } from '~/app/types'
/**
* 获取当前应用信息
* GET /api/app/info
*
* 优先返回中间件已识别的 AppProductevent.context.tenant.appProduct
* 若中间件未查到(异常降级),则用 appId 实时补查
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const tenant = event.context?.tenant as TenantContext | undefined
// 中间件已查到直接返回
if (tenant?.appProduct) {
return tenant.appProduct
}
// 兜底:实时查询
const appProduct = await getAppProductById(ctx.appId, config)
return appProduct || null
})
+81
View File
@@ -0,0 +1,81 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 文章详情
* GET /api/article/detail?id=123
* 代理到 CMS: /cms/cms-article/{id}
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
if (!query.id) {
throw createError({ statusCode: 400, statusMessage: 'Missing id parameter' })
}
let res: any
try {
res = await $fetch(`/cms/cms-article/${query.id}`, {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
}
})
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch article detail'
})
}
// C 端不展示草稿(1)/下架(2)/已删除文章:status 非 0(已发布) 或 deleted=1 视为不存在
// 上游真实枚举:0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
// 上游详情接口不校验状态,需在此拦截,否则可通过 /article/:id 直接访问未发布内容。
// 上游对不存在/未发布文章常返回 {code:1, message:"文章ID不存在"}(无 data),
// 此时 res.code !== 0,必须 404,否则扁平后的对象为 truthy,前端会误判为「文章存在」渲染空页。
const detail = res?.data ?? res
if (typeof res?.code === 'number' && res.code !== 0) {
throw createError({ statusCode: 404, statusMessage: 'Article not found or unpublished' })
}
if (!detail || detail.status === undefined || detail.status !== 0 || detail.deleted === 1) {
throw createError({ statusCode: 404, statusMessage: 'Article not found or unpublished' })
}
// 文章附件:上游 files 可能是 JSON 字符串或数组,容错解析为数组后随扁平数据返回
let files: any[] = []
if (typeof detail.files === 'string' && detail.files.trim()) {
try {
const arr = JSON.parse(detail.files)
if (Array.isArray(arr)) files = arr
} catch {
// 解析失败则忽略,不阻断详情返回
}
} else if (Array.isArray(detail.files)) {
files = detail.files
}
// 字段兼容归一化:上游 CMS 不同版本字段名不一致,统一补齐,
// 避免前端模板的 <RichText :content> / 副标题取不到值而渲染空白。
// - content(富文本正文):部分版本放在 description,兜底回退(content 优先)
// - subtitle(副标题):上游多数版本返回 summary,兜底回退
if (detail && typeof detail === 'object') {
if (detail.content == null || detail.content === '') {
detail.content = detail.description || ''
}
if (detail.subtitle == null || detail.subtitle === '') {
detail.subtitle = detail.summary || ''
}
}
// 返回扁平数据,去掉上游 {code, message, data} 包装层
// 前端 useFetch<Article> 直接访问 article.title / article.content 等字段
return {
...detail,
files: files.length ? files : undefined,
}
})
+58
View File
@@ -0,0 +1,58 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 文章列表
* GET /api/article/list?navigationId=4273&page=1&limit=10
* 代理到 CMS: /cms/cms-article/page
*
* 参数说明:
* - navigationId: 导航栏目ID(从 getSiteInfo 的 topNavs 获取)
* - page / limit: 分页
* - keywords: 搜索关键词
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
try {
// 前端传 navigationId;上游 cms-article 实际按 categoryId 过滤(文章无 navigationId 字段,只有 categoryId)。
// 二者同值(文章分类 ID == 对应栏目 navigationId),透传 categoryId 以正确按栏目过滤;
// 保留 navigationId 兼容旧租户。
const categoryId = query.navigationId ?? query.categoryId
// 聚合场景:前端递归收集父栏目下所有子栏目的 navigationId,以逗号串传入。
// 上游 article 用 categoryIdsStr 字段接收(与现有 categoryIds Set 分支并存),有则走 IN 查询。
const categoryIds = query.categoryIds as string | undefined
const res = await $fetch('/cms/cms-article/page', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
query: {
page: 1,
limit: 10,
...query,
navigationId: categoryIds ? undefined : query.navigationId,
categoryId: categoryIds ? undefined : categoryId,
categoryIdsStr: categoryIds || undefined,
// C 端只展示已发布文章:status 0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
// 放在最后以覆盖前端可能传入的 status,避免泄露草稿/下架内容
status: 0,
// 同时排除软删除(deleted=1)的文章,兜底防止已删除内容出现在前台
deleted: 0
}
})
return res
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch article list'
})
}
})
+128
View File
@@ -0,0 +1,128 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../utils/tenant'
/**
* 获取轮播图列表
* GET /api/banner
*
* 真实数据结构为「分组(cms_banner_group) + 明细(cms_banner_item)」:
* - 明细(item)内嵌在分组的 items[] 中返回,单独 /cms/cms-banner-item/page 接口不存在(404)
* - 旧的扁平接口 /cms/cms-banner/page 对本仓库对接的租户无数据(已废弃)。
*
* 本接口代理 /cms/cms-banner-group/page,按「启用分组 + 未删除明细」扁平化后,
* 映射成前端 CmsBanner 结构返回。支持:
* - position 查询参数:只取对应位置的轮播(如 home_slider),缺省返回全部启用分组;
* - 时间与状态过滤:分组 status=1、未删除、且在 [startTime,endTime] 窗口内(若设置)才展示。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
// position 由本接口自行过滤,不往上游透传(避免 CMS 无此参数导致异常)
const position = (query.position as string | undefined) || undefined
const { position: _omit, ...upstreamQuery } = query
try {
const res: any = await $fetch('/cms/cms-banner-group/page', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
query: {
page: 1,
limit: 50,
...upstreamQuery
}
})
const groups: any[] = res?.data?.list ?? res?.list ?? []
const now = Date.now()
// 1) 筛选启用分组
const enabledGroups = groups.filter((g) => {
if (g.deleted === 1) return false
if (g.status !== 1) return false
if (position && g.position !== position) return false
// 时间窗口:startTime/endTime 任一为空则视为不限;两者都有时需在窗口内
// ⚠️ CMS 时间字符串为「中国时间(Asia/Shanghai, UTC+8)」,必须显式按 +08:00 解析,
// 否则 dev/server 在 UTC 时区下会把窗口整体算错(实测曾把在用分组误判为未生效)。
if (g.startTime && g.endTime) {
const start = parseCmsTime(g.startTime) // 显式按 UTC+8 解析(中国时间)
const end = parseCmsTime(g.endTime) // 显式按 UTC+8 解析(中国时间)
if (start !== null && end !== null && (now < start || now > end)) {
return false
}
}
return true
})
// 2) 扁平化明细并过滤未删除项
const list: any[] = []
for (const g of enabledGroups) {
const items: any[] = Array.isArray(g.items) ? g.items : []
for (const it of items) {
if (it.deleted === 1) continue
list.push({
// 兼容前端 id 字段
id: it.itemId ?? it.id,
itemId: it.itemId,
groupId: it.groupId ?? g.groupId,
// 图片地址(优先 image,兼容其它字段名)
image: it.image || it.imageUrl || it.pic || it.url || '',
imageUrl: it.imageUrl || it.image,
pic: it.pic,
url: it.url,
// 文案
title: it.title || g.title || '',
subtitle: it.subtitle || '',
// 跳转(linkType: 0=无 1=外链 2=站内)
link: it.linkUrl || '',
linkUrl: it.linkUrl || '',
linkType: it.linkType ?? 0,
linkTargetId: it.linkTargetId ?? null,
// 排序(组内 sortNum);分组 sortNum 作为兜底
sortNum: it.sortNum ?? g.sortNum ?? 0,
sortOrder: it.sortNum ?? g.sortNum ?? 0,
// 状态/删除标记:沿用分组状态,前端按「非 2 即显示、deleted!==1」处理
status: it.status ?? g.status ?? 1,
deleted: it.deleted ?? 0
})
}
}
// 3) 按 sortNum 升序
list.sort((a, b) => (a.sortNum ?? 0) - (b.sortNum ?? 0))
return {
code: res?.code ?? 0,
message: res?.message ?? '操作成功',
data: {
list,
count: list.length
}
}
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch banners'
})
}
})
/**
* 将 CMS 时间字符串(如 "2026-07-30 12:14:47",中国时间 UTC+8)解析为毫秒时间戳。
* 显式按 Asia/Shanghai 解析,避免 server 处于 UTC 时区时窗口计算错误。
* 解析失败返回 null。
*/
function parseCmsTime(s?: string | null): number | null {
if (!s) return null
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})/)
if (!m) return null
const [, Y, Mo, D, h, mi, sec] = m
// 视为 UTC+8:先按本地组件构造,再减 8 小时换算成 UTC 时间戳
return Date.UTC(+Y, +Mo - 1, +D, +h - 8, +mi, +sec)
}
+20
View File
@@ -0,0 +1,20 @@
import { defineEventHandler, readBody } from 'h3'
import { saveCaptcha } from '../../utils/guard'
const SLIDER_SIZE = 42
const MIN_W = 240
const MAX_W = 480
/**
* 生成滑块拼图验证码 challenge。
* 前端量取自身宽度后传入,后端在该宽度内随机生成缺口 X 坐标作为答案。
* 返回 token + puzzleX(缺口位置,前端用于渲染) + width + size。
*/
export default defineEventHandler(async (event) => {
const body = (await readBody(event).catch(() => ({}))) as Record<string, any>
const width = Math.min(Math.max(Number(body?.width) || 320, MIN_W), MAX_W)
const token = globalThis.crypto.randomUUID()
const answer = Math.floor(Math.random() * (width - SLIDER_SIZE * 2)) + SLIDER_SIZE
await saveCaptcha(token, answer)
return { token, puzzleX: answer, width, size: SLIDER_SIZE }
})
+22
View File
@@ -0,0 +1,22 @@
import { defineEventHandler, readBody, createError } from 'h3'
import { verifyCaptcha } from '../../utils/guard'
/**
* 校验滑块拼图位置。
* body: { token, x } x 为用户拖动后拼图块中心 X 坐标。
* 成功返回 { pass: true, ticket }ticket 为一次性提交票据);失败返回 { pass: false }。
*/
export default defineEventHandler(async (event) => {
const body = (await readBody(event).catch(() => ({}))) as Record<string, any>
const token = body?.token
const x = Number(body?.x)
if (!token || !Number.isFinite(x)) {
throw createError({ statusCode: 400, statusMessage: '参数缺失' })
}
const ticket = await verifyCaptcha(token, x)
if (!ticket) return { pass: false }
return { pass: true, ticket }
})
+56
View File
@@ -0,0 +1,56 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
import { normalizeCaseItem } from '../../utils/case'
/**
* 案例详情
* GET /api/case/detail?id=1
* 代理到 SaaS 后端 CMS 案例详情接口
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
if (!query.id) {
throw createError({ statusCode: 400, statusMessage: 'Missing id parameter' })
}
let res: any
try {
// 上游 cms-website 服务没有 caseDetail 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
// 正确的案例详情端点属于 cms-case 控制器,且 id 走路径参数:/cms/cms-case/{id}
res = await $fetch(`/cms/cms-case/${query.id}`, {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
// TenantId 同时放入 query 兜底(详情端点实测认 query 的 TenantId
query: { ...query, TenantId: ctx.tenantId }
})
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch case detail'
})
}
// C 端不展示草稿(1)/下架(2)/已删除案例:status 非 0(已发布) 或 deleted=1 视为不存在
// 上游真实枚举:0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
// 上游对不存在/未发布案例常返回 {code:1, message:"...不存在"}(无 data),
// 此时 res.code !== 0,必须 404,否则扁平后的对象为 truthy,前端会误判为「存在」渲染空页。
const detail = res?.data ?? res
if (typeof res?.code === 'number' && res.code !== 0) {
throw createError({ statusCode: 404, statusMessage: 'Case not found or unpublished' })
}
if (!detail || detail.status === undefined || detail.status !== 0 || detail.deleted === 1) {
throw createError({ statusCode: 404, statusMessage: 'Case not found or unpublished' })
}
// 返回扁平数据,去掉上游 {code, message, data} 包装层,
// 并归一化为前端模板约定的字段(id / title / clientName …)。
return normalizeCaseItem(detail)
})
+65
View File
@@ -0,0 +1,65 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
import { normalizeCaseItem } from '../../utils/case'
/**
* 案例列表
* GET /api/case/list?categoryId=1&page=1&limit=10
* 代理到 SaaS 后端 CMS 案例列表接口
* 注意:上游 cms-website 服务没有 caseList 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
* 正确的案例列表端点属于 cms-case 控制器:/cms/cms-case/page
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
try {
// 前端按导航模型约定传 navigationId;上游 cms-case 实际按 categoryId 过滤,
// 二者在 CMS 中同值(案例分类 ID == 对应栏目 navigationId),故透传 categoryId。
// 同时保留 navigationId 以兼容少数按 navigationId 过滤的租户。
const categoryId = query.navigationId ?? query.categoryId
// 聚合场景:前端递归收集父栏目下所有子栏目的 navigationId,以逗号串传入。
// 有 categoryIds 时优先走 IN 查询,忽略单值 categoryId,避免 AND 冲突。
const categoryIds = query.categoryIds as string | undefined
const res = await $fetch('/cms/cms-case/page', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
// TenantId 同时放入 query 兜底(page 端点实测认 query 的 TenantId
// status 0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正),C 端只展示已发布,放最后覆盖前端传入
query: {
...query,
navigationId: categoryIds ? undefined : query.navigationId,
categoryId: categoryIds ? undefined : categoryId,
categoryIds: categoryIds || undefined,
TenantId: ctx.tenantId,
status: 0,
// 同时排除软删除(deleted=1)的案例,兜底防止已删除内容出现在前台
deleted: 0
}
})
// 上游返回 {code, message, data: {list, count}} 包装层,
// 前端 CaseList 直接读 data.value?.list,故在此去掉包装并把每条记录
// 归一化为前端模板约定的字段(id / title / clientName …)。
const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
const rawList = Array.isArray(envelopeData.list) ? envelopeData.list : []
const count = envelopeData.count ?? envelopeData.total ?? rawList.length
return {
list: rawList.map(normalizeCaseItem),
count
}
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch case list'
})
}
})
+37
View File
@@ -0,0 +1,37 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 域名解析
* GET /api/domain/resolve?domain=www.example.com
* 查询自定义域名绑定的租户信息
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
if (!query.domain) {
throw createError({ statusCode: 400, statusMessage: 'Missing domain parameter' })
}
try {
const res = await $fetch('/cms/cms-website/resolveDomain', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
query
})
return res
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to resolve domain'
})
}
})
+31
View File
@@ -0,0 +1,31 @@
import { defineEventHandler, getHeader, getRequestURL, getRouterParam, proxyRequest } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
function joinURL(base: string, path: string) {
if (!path) return base
return base.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '')
}
/**
* 文件代理
* /api/file/[...path] → SaaS 后端文件服务器
* 隐藏真实文件服务器地址
*/
export default defineEventHandler((event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const fileServerBase = config.public.fileServerBase as string
const path = getRouterParam(event, 'path') || ''
const search = getRequestURL(event).search
const target = joinURL(fileServerBase, path) + search
const authorization = getHeader(event, 'authorization')
return proxyRequest(event, target, {
headers: {
TenantId: ctx.tenantId,
...(authorization ? { Authorization: authorization } : {})
}
})
})
+88
View File
@@ -0,0 +1,88 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getHeader, readBody } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
import { getRealClientIp } from '../../utils/ip'
import { rateLimitPass, dedupPass, clearDedup, checkCaptchaTicket } from '../../utils/guard'
import { validatePhone } from '../../utils/validators'
import { getContactCaptchaRequired } from '../../utils/site'
/**
* 表单/留言提交
* POST /api/form/submit
* 代理到 SaaS 后端留言/表单接口
*
* 防护链路(顺序即短路顺序):
* 1) 蜜罐:隐藏字段被填 → 直接拒绝(机器人)
* 2) 电话号段校验:支持港澳台/海外 +区号
* 3) 滑块验证码票据:仅当后台「网站设置」requireCaptcha=true 时校验(一次性,校验通过才消费)
* 4) 频率限制:同 IP+租户 60s 内仅 1 次
* 5) 未处理去重:7 天内同 IP+租户 仅 1 条(过期自动释放)
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const body = (await readBody(event)) as Record<string, any>
const modulesApiBase = config.public.modulesApiBase as string
// 1) 蜜罐
if (body?.website) {
throw createError({ statusCode: 400, statusMessage: '提交失败' })
}
// 2) 电话号段校验(支持港澳台/海外 +区号)
if (!validatePhone(body?.phone)) {
throw createError({ statusCode: 400, statusMessage: '请输入有效的联系电话(支持 +区号 海外/港澳台)' })
}
// 3) 滑块验证码票据(仅当后台「网站设置」开启 requireCaptcha 时校验)
const requireCaptcha = await getContactCaptchaRequired(ctx.tenantId, config)
if (requireCaptcha && !(await checkCaptchaTicket(body?.captchaTicket))) {
throw createError({ statusCode: 400, statusMessage: '验证码已失效,请重新完成验证' })
}
const ip = getRealClientIp(event)
// 4) 频率限制:同 IP+租户 60s 内仅 1 次
if (!(await rateLimitPass(ctx.tenantId, ip))) {
throw createError({ statusCode: 429, statusMessage: '操作过于频繁,请 60 秒后再试' })
}
// 5) 未处理去重:7 天内同 IP+租户 仅 1 条
if (!(await dedupPass(ctx.tenantId, ip))) {
throw createError({ statusCode: 429, statusMessage: '您已有待处理的留言,请耐心等待我们联系' })
}
try {
// 上游正确端点为 cms-contact-lead/submit。
// 上游 ContactLeadSubmitForm 期望字段:name, phone, company, need(必填), delivery?, source?
// 前端 ContactForm 发 { type, name, phone, content },这里统一做字段映射:
// content → need(留言/需求描述),company 未收集时留空,source 取请求 referer。
const submitBody = {
name: body?.name || '',
phone: body?.phone || '',
company: body?.company || '',
need: body?.need || body?.content || '',
source: body?.source || (getHeader(event, 'referer') as string) || '/contact'
}
const res = await $fetch('/cms/cms-contact-lead/submit', {
method: 'POST',
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId,
'Content-Type': 'application/json'
},
body: submitBody
})
return res
} catch (error: any) {
// 上游失败:释放去重锁,允许用户重试
await clearDedup(ctx.tenantId, ip)
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to submit form'
})
}
})
+33
View File
@@ -0,0 +1,33 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 获取页面列表
* GET /api/page/all
* 代理到 SaaS 后端 CMS 页面列表接口
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
try {
const res = await $fetch('/cms/cms-website/pageAll', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
query
})
return res
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch pages'
})
}
})
+60
View File
@@ -0,0 +1,60 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 父栏目聚合子栏目单页
* GET /api/page/children?navigationIds=1,2,3
*
* 场景:父级 page 栏目(model=page)自身通常无内容,其下挂多个子 page 栏目。
* 访问父栏目时,前端递归收集父栏目自身 + 所有后代 navigationId,以逗号串传入,
* 后端按 navigation_id IN (...) 一次性取回该父栏目下所有单页。
*
* 代理到 SaaS 后端:/cms/cms-page/page?navigationIdsStr=...&status=1
* 仅返回已发布(status=1)的单页,避免泄露草稿/已下线内容。
*
* 返回结构(与 /api/case/list 对齐,前端直接读 data.value?.list):
* { list: CmsPage[], count: number }
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
const navigationIds = query.navigationIds as string | undefined
if (!navigationIds) {
return { list: [], count: 0 }
}
try {
const res = await $fetch('/cms/cms-page/page', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
query: {
navigationIdsStr: navigationIds,
// C 端只展示已发布单页:status 0草稿 1已发布 2已下线
status: 1,
page: 1,
limit: 100,
TenantId: ctx.tenantId
}
})
// 上游返回 {code, message, data: {list, count}} 包装层,
// 去掉包装并把 list/count 透传给前端。
const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
const rawList = Array.isArray(envelopeData.list) ? envelopeData.list : []
const count = envelopeData.count ?? envelopeData.total ?? rawList.length
return { list: rawList, count }
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch page children'
})
}
})
+200
View File
@@ -0,0 +1,200 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 页面详情
*
* 支持两种入口:
* 1. GET /api/page/detail?navigationId=4296 → CMS 导航节点(model=page+ cms_design 内容(旧机制)
* 2. GET /api/page/detail?path=about → 新版「单页管理」cms_page(按 slug 取已发布单页)
*
* 与 website-admin 的「单页管理」模块对应:后端 mp-java CmsPageController.getByPath。
* cms-api 侧已按 status=1(已发布)过滤,未发布/不存在统一返回 data=null。
*
* 返回结构(始终为对象,前端用 status 区分):
* {
* status: 'ok' | 'empty' | 'error',
* pageId, title, path, model,
* content, layout, photo,
* keywords, description,
* hasContent, updateTime, message?
* }
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
const navigationId = query.navigationId || query.id
const path = query.path
const base = {
status: 'empty' as const,
pageId: 0,
title: '',
path: (typeof path === 'string' ? path : '') || '',
model: 'page',
parentId: 0,
content: '',
layout: null as any,
photo: null as string | null,
keywords: '',
description: '',
hasContent: false,
updateTime: null as string | null,
message: '该页面不存在或未发布'
}
// ===== 分支2:按 path 取新版单页(cms_page =====
if (typeof path === 'string' && path.trim()) {
try {
const res = await $fetch<{
code?: number
message?: string
data?: {
pageId?: number
title?: string
path?: string
content?: string | null
keywords?: string | null
description?: string | null
image?: string | null
status?: number
updateTime?: string | null
/** 附件列表:后端存 JSON 字符串(cms_page.attachments),也可能是已解析的数组 */
attachments?: unknown
} | null
}>(`/cms/cms-page/getByPath/${encodeURIComponent(path.trim())}`, {
baseURL: modulesApiBase,
headers: { TenantId: ctx.tenantId },
timeout: 10000,
retry: 1
})
const node = res?.data
if (!node) {
const code = res?.code
const isBizError = typeof code === 'number' && code !== 0 && code !== 200
if (isBizError) {
return { ...base, status: 'error' as const, message: res?.message || '上游接口返回异常' }
}
return { ...base, status: 'empty' as const, message: '该单页不存在或未发布' }
}
const content = typeof node.content === 'string' ? node.content : ''
const hasContent = content.trim().length > 0
// 解析附件列表(cms_page.attachments):后端存 JSON 字符串,容错解析
let attachments: Array<{ name: string; url: string; size?: number; ext?: string; sort?: number }> | undefined
try {
const raw = node.attachments
if (raw) {
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
if (Array.isArray(parsed)) {
attachments = parsed
.filter((it: any) => it && typeof it.url === 'string' && typeof it.name === 'string')
.map((it: any) => ({
name: String(it.name),
url: String(it.url),
size: typeof it.size === 'number' ? it.size : undefined,
ext: typeof it.ext === 'string' ? it.ext : undefined,
sort: typeof it.sort === 'number' ? it.sort : undefined
}))
}
}
} catch (e) {
console.error(`[page/detail] parse attachments failed: path=${path}`, (e as any)?.message || e)
}
return {
status: hasContent ? ('ok' as const) : ('empty' as const),
pageId: node.pageId ?? 0,
title: node.title || '',
path: node.path || path,
model: 'page',
parentId: 0,
content,
layout: null,
photo: node.image ?? null,
keywords: node.keywords || '',
description: node.description || '',
hasContent,
updateTime: node.updateTime || null,
attachments,
message: hasContent ? undefined : '该页面正文尚未在 CMS 管理后台录入'
}
} catch (error: any) {
console.error(`[page/detail] upstream(cms-page) failed: path=${path} tenant=${ctx.tenantId}`, error?.message || error)
return { ...base, status: 'error' as const, message: '内容接口暂时不可用,请稍后再试' }
}
}
// ===== 分支1:按 navigationId 取导航节点(旧机制,保持原逻辑) =====
if (!navigationId) {
throw createError({ statusCode: 400, statusMessage: 'Missing navigationId or path parameter' })
}
const navId = Number(navigationId)
try {
const res = await $fetch<{
code?: number
message?: string
data?: {
navigationId?: number
title?: string
model?: string
parentId?: number
updateTime?: string | null
design?: {
content?: string | null
layout?: any
photo?: string | null
updateTime?: string | null
createTime?: string | null
} | null
} | null
}>(`/cms/cms-navigation/${navId}`, {
baseURL: modulesApiBase,
headers: { TenantId: ctx.tenantId },
timeout: 10000,
retry: 1
})
const node = res?.data
if (!node) {
const code = res?.code
const isBizError = typeof code === 'number' && code !== 0 && code !== 200
if (isBizError) {
return { ...base, navigationId: navId, message: res?.message || '上游接口返回异常' }
}
return { ...base, status: 'empty' as const, navigationId: navId, message: '该导航节点在 CMS 中不存在或未发布' }
}
const design = node.design || {}
const content = typeof design.content === 'string' ? design.content : ''
const hasContent = content.trim().length > 0
const updateTime = design.updateTime || design.createTime || node.updateTime || null
return {
status: hasContent ? ('ok' as const) : ('empty' as const),
navigationId: node.navigationId ?? navId,
title: node.title || '',
model: node.model || 'page',
parentId: node.parentId || 0,
content,
layout: design.layout ?? null,
photo: design.photo ?? null,
keywords: '',
description: '',
hasContent,
updateTime,
message: hasContent ? undefined : '该页面正文尚未在 CMS 管理后台录入'
}
} catch (error: any) {
console.error(`[page/detail] upstream(navigation) failed: navigationId=${navigationId} tenant=${ctx.tenantId}`, error?.message || error)
return { ...base, status: 'error' as const, navigationId: navId, message: '内容接口暂时不可用,请稍后再试' }
}
})
+71
View File
@@ -0,0 +1,71 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 产品详情
* GET /api/product/detail?id=1
* 代理到 SaaS 后端 CMS 产品详情接口
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
if (!query.id) {
throw createError({ statusCode: 400, statusMessage: 'Missing id parameter' })
}
let res: any
try {
// 上游 cms-website 服务没有 productDetail 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
// 正确的产品详情端点属于 cms-product 控制器,且 id 走路径参数:/cms/cms-product/{id}
res = await $fetch(`/cms/cms-product/${query.id}`, {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
// TenantId 同时放入 query 兜底(详情端点实测认 query 的 TenantId
query: { ...query, TenantId: ctx.tenantId }
})
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch product detail'
})
}
// C 端不展示草稿(1)/下架(2)/已删除产品:status 非 0(已发布) 或 deleted=1 视为不存在
// 上游真实枚举:0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正)
// 上游对不存在/未发布产品常返回 {code:1, message:"...不存在"}(无 data),
// 此时 res.code !== 0,必须 404,否则扁平后的对象为 truthy,前端会误判为「存在」渲染空页。
const detail = res?.data ?? res
if (typeof res?.code === 'number' && res.code !== 0) {
throw createError({ statusCode: 404, statusMessage: 'Product not found or unpublished' })
}
if (!detail || detail.status === undefined || detail.status !== 0 || detail.deleted === 1) {
throw createError({ statusCode: 404, statusMessage: 'Product not found or unpublished' })
}
// 字段兼容归一化:上游 CMS 不同版本字段名不一致,统一补齐,
// 避免前端 10 套模板的 <RichText :content> / subtitle 取不到值而渲染空白。
// - content(富文本正文):部分版本放在 description,兜底回退(content 优先)
// - subtitle(副标题):上游多数版本返回 summary,兜底回退
// - navigationId:上游用 categoryId,组件用它拼「返回列表」链接
if (detail && typeof detail === 'object') {
if (detail.content == null || detail.content === '') {
detail.content = detail.description || ''
}
if (detail.subtitle == null || detail.subtitle === '') {
detail.subtitle = detail.summary || ''
}
if (detail.navigationId == null && detail.categoryId != null) {
detail.navigationId = detail.categoryId
}
}
// 返回扁平数据,去掉上游 {code, message, data} 包装层
return detail
})
+64
View File
@@ -0,0 +1,64 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
/**
* 产品列表
* GET /api/product/list?categoryId=1&page=1&limit=10
* 代理到 SaaS 后端 CMS 产品列表接口
* 注意:上游 cms-website 服务没有 productList 这类路由(会被 /cms/cms-website/{id} 兜底捕获并因 id 非整数报错),
* 正确的产品列表端点属于 cms-product 控制器:/cms/cms-product/page
*
* 返回结构:与 case/list 对齐,去掉上游包装层,归一化为 { list, count }
* 前端 ProductList 直接读 data.value?.list / data.value?.count,分页稳定可用。
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
try {
// 前端按导航模型约定传 navigationId;上游 cms-product 实际按 categoryId 过滤,
// 二者在 CMS 中同值(产品分类 ID == 对应栏目 navigationId),故透传 categoryId。
// 同时保留 navigationId 以兼容少数按 navigationId 过滤的租户。
const categoryId = query.navigationId ?? query.categoryId
// 聚合场景:前端递归收集父栏目下所有子栏目的 navigationId,以逗号串传入。
// 有 categoryIds 时优先走 IN 查询,忽略单值 categoryId,避免 AND 冲突。
const categoryIds = query.categoryIds as string | undefined
const res = await $fetch('/cms/cms-product/page', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
// TenantId 同时放入 query 兜底(page 端点实测认 query 的 TenantId
// status 0=已发布 1=草稿 2=下架(与早期假设相反,已于 2026-07-29 校正),C 端只展示已发布,放最后覆盖前端传入
query: {
...query,
navigationId: categoryIds ? undefined : query.navigationId,
categoryId: categoryIds ? undefined : categoryId,
categoryIds: categoryIds || undefined,
TenantId: ctx.tenantId,
status: 0,
// 同时排除软删除(deleted=1)的产品,兜底防止已删除内容出现在前台
deleted: 0
}
})
// 与 case/list 对齐:去掉上游包装层,归一化为 { list, count }
// 上游 /cms/cms-product/page 返回 { code, message, data: { list, count } } 或 { list, count }
// 字段名可能为 count 或 total,统一兜底。
const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
const rawList = Array.isArray(envelopeData.list) ? envelopeData.list : []
const count = envelopeData.count ?? envelopeData.total ?? rawList.length
return { list: rawList, count }
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch product list'
})
}
})
+38
View File
@@ -0,0 +1,38 @@
import { defineEventHandler, setHeader } from 'h3'
import { useRuntimeConfig } from '#imports'
/**
* robots.txt
* GET /api/robots.txt
* 根据环境动态生成 robots.txt
*/
export default defineEventHandler((event) => {
const config = useRuntimeConfig()
// 开发环境禁止抓取
const isDev = process.env.NODE_ENV === 'development' || !process.env.NODE_ENV
// canonical host 优先用真实访问域名;缺失时回退到「主域清单首个 + 默认租户号」,
// 不再硬编码 site- 前缀(支持多前缀 + 多主域)
const baseDomains = (config.public.baseDomains as string[]) || []
const fallbackDomain = baseDomains[0] || (config.public.baseDomain as string) || 'shoplnk.cn'
const host = event.node?.req?.headers?.host ||
`${config.public.tenantId ? `site-${config.public.tenantId}.` : ''}${fallbackDomain}`
const origin = `https://${host}`
let content: string
if (isDev) {
content = `User-agent: *
Disallow: /`
} else {
content = `User-agent: *
Allow: /
Disallow: /api/
Sitemap: ${origin}/api/sitemap.xml`
}
setHeader(event, 'Content-Type', 'text/plain')
return content
})
+43
View File
@@ -0,0 +1,43 @@
import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
import { getTemplateCodeById } from '../../utils/template-map'
/**
* 获取站点信息
* GET /api/site/info
* 代理到 CMS: /cms/cms-website/getSiteInfo
* 返回站点基础信息 + 导航(topNavs/bottomNavs) + config + setting
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
try {
const res = await $fetch('/cms/cms-website/getSiteInfo', {
baseURL: modulesApiBase,
headers: {
TenantId: ctx.tenantId
},
query
})
// 回填模板标识 templateCode(与 SSR 中间件 tenant.ts 保持一致的口径):
// 上游只返回 templateId 主键,而主键存在跳号错位风险,前端需按 code 解析模板目录。
const payload = (res as { data?: Record<string, unknown> })?.data ?? (res as Record<string, unknown>)
if (payload && typeof payload === 'object' && !payload.templateCode && payload.templateId) {
const code = await getTemplateCodeById(payload.templateId as number, config)
if (code) payload.templateCode = code
}
return res
} catch (error: any) {
throw createError({
statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to fetch site info'
})
}
})
+145
View File
@@ -0,0 +1,145 @@
import { defineEventHandler, getQuery, setHeader } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../utils/tenant'
import { $fetch } from 'ofetch'
/**
* 动态站点地图
* GET /api/sitemap.xml
* 根据当前租户的页面列表动态生成 sitemap.xml
*
* 覆盖范围:
* - 静态页:首页 + 三大模块列表根页(/article /product /case
* - CMS 单页(/page slug 对应的顶层路由,如 /about /contact
* - 文章 / 产品 / 案例 详情页(仅已发布 status=0、未删除 deleted=0
*/
interface SitemapUrl {
loc: string
priority: string
changefreq: string
lastmod: string
}
function escapeXml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/** 从列表返回中提取 list 数组(兼容 article 原始包装与 product/case 归一化结构) */
function extractList(res: any): any[] {
if (!res) return []
const data = res.data !== undefined ? res.data : res
if (data && Array.isArray(data.list)) return data.list
if (Array.isArray(res?.data?.list)) return res.data.list
if (Array.isArray(data)) return data
return []
}
/** 从列表项中提取唯一 id(各模块主键字段名不同) */
function pickId(item: any, keys: string[]): string | number | null {
for (const k of keys) {
if (item?.[k] != null) return item[k]
}
return null
}
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string
const origin = `https://${ctx.host || ''}`
const now = new Date().toISOString()
const urls: SitemapUrl[] = [
{ loc: '/', priority: '1.0', changefreq: 'daily', lastmod: now },
{ loc: '/article', priority: '0.9', changefreq: 'daily', lastmod: now },
{ loc: '/product', priority: '0.9', changefreq: 'daily', lastmod: now },
{ loc: '/case', priority: '0.9', changefreq: 'daily', lastmod: now }
]
// ===== 单页 slug/about /contact 等顶层路由) =====
try {
const res = await $fetch<{ data?: { list?: any[] } } | any[]>('/cms/cms-website/pageAll', {
baseURL: modulesApiBase,
headers: { TenantId: ctx.tenantId }
})
const list = Array.isArray(res) ? res : res?.data?.list || res?.data || []
for (const p of list) {
if (p?.slug && p.slug !== 'index') {
urls.push({
loc: `/${p.slug}`,
priority: '0.8',
changefreq: 'weekly',
lastmod: p.updateTime || now
})
}
}
} catch {
// 单页接口异常不影响详情页收录
}
// ===== 文章 / 产品 / 案例详情(分页拉取,仅已发布) =====
const fetchDetailUrls = async (
apiPath: string,
urlPrefix: string,
idKeys: string[]
) => {
const limit = 500
let page = 1
// 安全上限:单 sitemap 文件 URL 上限 50,000
while (urls.length < 50000) {
let list: any[] = []
try {
const res = await $fetch(apiPath, {
baseURL: modulesApiBase,
headers: { TenantId: ctx.tenantId },
query: { page, limit, status: 0, deleted: 0 }
})
list = extractList(res)
} catch {
break
}
for (const item of list) {
const idVal = pickId(item, idKeys)
if (idVal == null) continue
urls.push({
loc: `/${urlPrefix}/${idVal}`,
priority: '0.7',
changefreq: 'weekly',
lastmod: item.updateTime || item.publishTime || item.createTime || now
})
}
if (list.length < limit) break
page++
}
}
await fetchDetailUrls('/cms/cms-article/page', 'article', ['id', 'articleId'])
await fetchDetailUrls('/cms/cms-product/page', 'product', ['id', 'productId'])
await fetchDetailUrls('/cms/cms-case/page', 'case', ['id', 'caseId', 'caseItemId'])
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(u) => ` <url>
<loc>${escapeXml(origin + u.loc)}</loc>
<lastmod>${u.lastmod}</lastmod>
<changefreq>${u.changefreq}</changefreq>
<priority>${u.priority}</priority>
</url>`
)
.join('\n')}
</urlset>`
setHeader(event, 'Content-Type', 'application/xml')
return xml
})
+21
View File
@@ -0,0 +1,21 @@
import { defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant'
import { getSubscriptionStatus } from '../../utils/subscription'
/**
* 订阅状态查询
* GET /api/subscription/status
* 返回当前租户/应用的订阅状态(带缓存)
*/
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config)
const query = getQuery(event)
const appId = (query.appId as string) || ctx.appId
const status = await getSubscriptionStatus(ctx.tenantId, appId, config)
return status
})