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
+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)
}