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
+323
View File
@@ -0,0 +1,323 @@
<template>
<!--
关于我们通用组件首页区块
数据源cms_page path=about 的单页记录
链路/api/page/detail?path=about server/api/page/detail.get.ts 上游 /cms/cms-page/getByPath/about
布局左侧标题 + 摘要 + 了解更多链接| 右侧配图
无数据时自动隐藏v-if="ready"不影响首页其他区块
-->
<section
v-if="ready"
class="about-section"
:class="[customClass, { 'about-section--reversed': reversed }]"
:style="rootStyle"
>
<div :class="containerClass">
<!-- 文字区 -->
<div class="about-section__text">
<h2 v-if="displayTitle" class="about-section__title" :style="{ color: titleColor }">
{{ displayTitle }}
</h2>
<div
v-if="titleUnderline"
class="about-section__underline"
:style="{ backgroundColor: accentColor }"
/>
<p v-if="summaryText" class="about-section__summary" :style="{ color: summaryColor }">
{{ summaryText }}
</p>
<NuxtLink
v-if="aboutLink"
:to="aboutLink"
class="about-section__more"
:style="{ color: linkColor }"
>
{{ moreText }}
<svg
class="about-section__arrow"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 5l7 7-7 7"
/>
</svg>
</NuxtLink>
</div>
<!-- 图片区 -->
<div class="about-section__image-wrap" :style="imageWrapStyle">
<img
v-if="imageUrl"
:src="imageUrl"
:alt="displayTitle"
class="about-section__image"
:style="imageStyle"
>
<div v-else class="about-section__image-placeholder" :style="placeholderStyle">
{{ placeholderText }}
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { PageDetail } from '~/types'
import { stripHtml } from '~/utils'
/**
* AboutSection — 首页「关于我们」通用组件
*
* 从 cms_page (path=about) 读取单页数据,左侧摘要 + 右侧配图。
* 各模板通过 props 定制颜色/圆角/容器等视觉细节。
*
* ## 使用方式
* ```vue
* <!-- 最简调用(使用全部默认值) -->
* <AboutSection />
*
* <!-- template-08 金棕风 -->
* <AboutSection
* title-color="var(--t8-text)"
* accent-color="var(--t8-primary)"
* summary-color="var(--t8-text-secondary)"
* link-color="var(--t8-primary)"
* container-class="container mx-auto px-4 sm:px-6 lg:px-8 grid lg:grid-cols-2 gap-12 items-center"
* />
* ```
*/
// ==================== Props ====================
const props = withDefaults(defineProps<{
/** 区块标题,空字符串时隐藏标题行 */
title?: string
/** 标题颜色 */
titleColor?: string
/** 装饰线 / 强调色(下划线、链接、占位背景) */
accentColor?: string
/** 摘要文字颜色 */
summaryColor?: string
/** "了解更多"链接文字颜色 */
linkColor?: string
/** "了解更多"文案 */
moreText?: string
/** 摘要最大字符数(stripHtml 后截断) */
maxChars?: number
/** 是否显示标题下方装饰线 */
titleUnderline?: boolean
/** 是否左右翻转(图片在左文字在右) */
reversed?: boolean
/** 外层容器额外 class */
customClass?: string
/** 内层网格容器 class(控制布局与间距) */
containerClass?: string
/** 区块背景色 */
bgColor?: string
/** 区块内边距 py / 上下 padding */
paddingY?: string
/** 图片容器圆角 */
imageRadius?: string
/** 图片容器背景色(无图时的占位背景) */
imageBgColor?: string
/** 无图时的占位文字 */
placeholderText?: string
/** 图片 object-fit */
imageObjectFit?: string
/** 图片高度 */
imageHeight?: string
}>(), {
title: '',
titleColor: '',
accentColor: '',
summaryColor: '',
linkColor: '',
moreText: '了解更多',
maxChars: 150,
titleUnderline: true,
reversed: false,
customClass: '',
containerClass: '',
bgColor: '#ffffff',
paddingY: 'py-16 lg:py-20',
imageRadius: 'rounded-2xl',
imageBgColor: '',
placeholderText: '',
imageObjectFit: 'object-contain',
imageHeight: '',
})
// ==================== 数据获取 ====================
const { siteInfo } = useSite()
const { fileUrl } = useFileUrl()
/** 单页详情(key 与 About.vue 一致,复用 SSR payload */
const { data: pageData } = await useFetch<PageDetail>('/api/page/detail', {
key: 'page-about',
query: { path: 'about' },
})
// ==================== 计算属性 ====================
/** 是否有足够数据渲染 */
const ready = computed(() => {
const p = pageData.value
if (!p) return false
// ok/empty 都展示(empty 时用 description 或站点简介兜底)
return p.status === 'ok' || p.status === 'empty'
})
/** 显示标题 */
const displayTitle = computed(() => {
if (props.title) return props.title
return pageData.value?.title?.trim() || '关于我们'
})
/**
* 摘要文本
* 优先级:pageData.description > stripHtml(pageData.content) > siteInfo.comments
*/
const summaryText = computed(() => {
const p = pageData.value
if (!p) return ''
// 1. 单页 SEO 描述
const desc = (p.description || '').trim()
if (desc) return desc.slice(0, props.maxChars)
// 2. 正文截取
const content = (p.content || '').trim()
if (content) {
const text = stripHtml(content).slice(0, props.maxChars)
if (text) return text
}
// 3. 站点简介兜底
const comments = (siteInfo.value?.comments || '').trim()
return comments.slice(0, props.maxChars) || ''
})
/** 配图 URL(经 fileUrl 转换) */
const imageUrl = computed(() => {
const photo = pageData.value?.photo
if (!photo) return ''
return fileUrl(photo)
})
/** "了解更多"链接目标 */
const aboutLink = computed(() => '/about')
// ==================== 内联样式(props → style ====================
const rootStyle = computed(() => ({
backgroundColor: props.bgColor || undefined,
paddingTop: undefined,
paddingBottom: undefined,
}))
const imageWrapStyle = computed(() => ({
borderRadius: undefined, // 由 class 控制
backgroundColor: props.imageBgColor || undefined,
minHeight: props.imageHeight || undefined,
}))
const imageStyle = computed(() => ({
objectFit: props.imageObjectFit as any || undefined,
maxHeight: props.imageHeight || undefined,
}))
const placeholderStyle = computed(() => ({
color: props.accentColor || undefined,
}))
</script>
<style scoped>
.about-section {
position: relative;
}
.about-section__text {
display: flex;
flex-direction: column;
justify-content: center;
}
.about-section__title {
font-size: 1.5rem;
font-weight: 700;
line-height: 1.3;
margin-bottom: 0.75rem;
}
.about-section__underline {
width: 4rem;
height: 4px;
border-radius: 9999px;
margin-bottom: 1.5rem;
}
.about-section__summary {
font-size: 1rem;
line-height: 1.75;
margin-bottom: 1.5rem;
}
.about-section__more {
display: inline-flex;
align-items: center;
font-weight: 600;
font-size: 0.95rem;
transition: opacity 0.2s;
width: fit-content;
}
.about-section__more:hover {
opacity: 0.75;
}
.about-section__arrow {
width: 1rem;
height: 1rem;
margin-left: 0.5rem;
flex-shrink: 0;
}
.about-section__image-wrap {
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
min-height: 16rem;
max-height: 20rem;
}
.about-section__image {
width: 100%;
height: 100%;
padding: 2rem;
}
.about-section__image-placeholder {
font-size: 0.9rem;
opacity: 0.45;
user-select: none;
}
/* 翻转布局 */
.about-section--reversed .about-section__text {
order: 2;
}
.about-section--reversed .about-section__image-wrap {
order: 1;
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div class="captcha">
<div class="relative" :style="{ height: H + 'px' }">
<canvas
ref="canvasRef"
class="block w-full rounded-lg ring-1 ring-gray-200"
:style="{ height: H + 'px' }"
/>
<!-- 滑块手柄 -->
<div
ref="handleRef"
class="absolute top-0 flex items-center justify-center rounded-lg bg-white shadow-md ring-1 ring-gray-200 cursor-grab select-none active:cursor-grabbing"
:class="passed ? '!bg-green-500 !text-white' : 'text-indigo-600'"
:style="{ left: sliderX + 'px', width: SIZE + 'px', height: H + 'px' }"
@pointerdown.prevent="onDown"
>
<svg v-if="!passed" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 6l6 6-6 6" />
</svg>
<svg v-else class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<div
v-if="!passed"
class="pointer-events-none absolute inset-0 flex items-center justify-center text-xs text-gray-400"
>
拖动滑块完成拼图验证
</div>
</div>
<div class="mt-1 flex items-center justify-end">
<button type="button" class="text-xs text-gray-400 hover:text-gray-600" @click="reload">看不清换一个</button>
</div>
</div>
</template>
<script setup lang="ts">
const emit = defineEmits<{
passed: [payload: { token: string; x: number }]
failed: []
}>()
const H = 44
const SIZE = 42
const TOLERANCE = 10
const canvasRef = ref<HTMLCanvasElement>()
const handleRef = ref<HTMLElement>()
const W = ref(320)
const puzzleX = ref(0)
const token = ref('')
const sliderX = ref(0)
const dragging = ref(false)
const passed = ref(false)
const loading = ref(false)
const result = ref<{ token: string; x: number } | null>(null)
let moveStartX = 0
let startLeft = 0
function clamp(v: number, min: number, max: number) {
return Math.max(min, Math.min(max, v))
}
async function reload() {
if (loading.value) return
loading.value = true
passed.value = false
sliderX.value = 0
result.value = null
await nextTick()
const width = canvasRef.value ? Math.round(canvasRef.value.clientWidth) || 320 : 320
try {
const res = await $fetch<{ token: string; puzzleX: number; width: number; size: number }>(
'/api/captcha/challenge',
{ method: 'POST', body: { width } }
)
token.value = res.token
puzzleX.value = res.puzzleX
W.value = res.width
draw()
} catch {
/* 失败可重试 */
} finally {
loading.value = false
}
}
function draw() {
const c = canvasRef.value
if (!c) return
const ctx = c.getContext('2d')
if (!ctx) return
c.width = W.value
c.height = H
const g = ctx.createLinearGradient(0, 0, W.value, H)
g.addColorStop(0, '#eef2ff')
g.addColorStop(1, '#e0e7ff')
ctx.fillStyle = g
ctx.fillRect(0, 0, W.value, H)
for (let i = 0; i < 36; i++) {
ctx.fillStyle = `rgba(99,102,241,${Math.random() * 0.15})`
ctx.beginPath()
ctx.arc(Math.random() * W.value, Math.random() * H, Math.random() * 2, 0, Math.PI * 2)
ctx.fill()
}
ctx.fillStyle = 'rgba(99,102,241,0.4)'
ctx.font = '13px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('拖动滑块完成拼图验证', W.value / 2, H / 2 + 4)
// 挖出缺口(露出底层)
ctx.globalCompositeOperation = 'destination-out'
ctx.fillRect(puzzleX.value, 0, SIZE, H)
ctx.globalCompositeOperation = 'source-over'
}
function onDown(e: PointerEvent) {
if (passed.value || loading.value) return
dragging.value = true
moveStartX = e.clientX
startLeft = sliderX.value
}
function onMove(e: PointerEvent) {
if (!dragging.value) return
sliderX.value = clamp(startLeft + (e.clientX - moveStartX), 0, W.value - SIZE)
}
function onUp() {
if (!dragging.value) return
dragging.value = false
if (Math.abs(sliderX.value - puzzleX.value) <= TOLERANCE) {
passed.value = true
result.value = { token: token.value, x: Math.round(sliderX.value) }
emit('passed', result.value)
} else {
sliderX.value = 0
emit('failed')
}
}
onMounted(async () => {
await reload()
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
})
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
})
defineExpose({ result, passed, reload })
</script>
+153
View File
@@ -0,0 +1,153 @@
<template>
<ClientOnly>
<Teleport to="body">
<Transition name="consult-fade">
<div
v-if="isOpen"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
@click.self="closeConsult"
>
<!-- 遮罩 -->
<div class="absolute inset-0 bg-black/50 backdrop-blur-sm" />
<!-- 弹窗卡片 -->
<Transition name="consult-pop" appear>
<div
v-if="isOpen"
class="relative bg-white rounded-2xl shadow-2xl w-full max-w-md p-6 sm:p-8 max-h-[90vh] overflow-y-auto"
role="dialog"
aria-modal="true"
aria-label="立即咨询"
>
<!-- 关闭按钮 -->
<button
type="button"
class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
aria-label="关闭"
@click="closeConsult"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- 成功态 -->
<div v-if="submitted" class="text-center py-6">
<div class="w-14 h-14 mx-auto mb-4 rounded-full bg-green-100 flex items-center justify-center">
<svg class="w-7 h-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<h3 class="text-lg font-bold text-gray-900 mb-2">提交成功</h3>
<p class="text-gray-600 text-sm">我们会尽快与您联系请保持电话畅通</p>
</div>
<!-- 表单态 -->
<div v-else>
<div class="mb-6 pr-8">
<h2 class="text-xl font-bold text-gray-900 mb-1">立即咨询</h2>
<p class="text-sm text-gray-500">留下您的联系方式我们将尽快与您联系</p>
</div>
<ContactForm
type="consult"
content-label="需求"
submit-text="立即提交"
:content-required="false"
:preset-content="presetNeed"
:source="source"
accent="#1a6dff"
@success="onSuccess"
/>
</div>
</div>
</Transition>
</div>
</Transition>
</Teleport>
</ClientOnly>
</template>
<script setup lang="ts">
/**
* 全站咨询弹窗
* 通过 useConsult() 控制开关,内部复用共享 ContactForm(与 /contact 功能对齐:
* 手机号段校验、蜜罐、滑块验证码(按后台配置)、频率限制、去重、提交)。
* 挂载在 layouts/default.vue,全站可用。
*/
import ContactForm from './ContactForm.vue'
const { isOpen, presetNeed, source, closeConsult } = useConsult()
const submitted = ref(false)
let successTimer: ReturnType<typeof setTimeout> | null = null
// 打开时锁定背景滚动;关闭时复位成功态
watch(isOpen, (open) => {
if (open) {
submitted.value = false
if (import.meta.client) {
document.body.style.overflow = 'hidden'
}
} else {
if (import.meta.client) {
document.body.style.overflow = ''
}
}
})
// ESC 关闭
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value) closeConsult()
}
// 提交成功后显示成功态,2.5s 自动关闭
function onSuccess() {
submitted.value = true
successTimer = setTimeout(() => {
if (submitted.value) {
submitted.value = false
closeConsult()
}
}, 2500)
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKeydown)
if (successTimer) clearTimeout(successTimer)
if (import.meta.client) {
document.body.style.overflow = ''
}
})
</script>
<style scoped>
.consult-fade-enter-active,
.consult-fade-leave-active {
transition: opacity 0.2s ease;
}
.consult-fade-enter-from,
.consult-fade-leave-to {
opacity: 0;
}
.consult-pop-enter-active {
transition: transform 0.25s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.25s ease;
}
.consult-pop-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.consult-pop-enter-from {
transform: scale(0.95) translateY(10px);
opacity: 0;
}
.consult-pop-leave-to {
transform: scale(0.95);
opacity: 0;
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<form class="space-y-5" novalidate @submit.prevent="handleSubmit">
<div>
<label :class="labelClass">姓名</label>
<input
v-model.trim="form.name"
type="text"
required
:class="inputClass"
placeholder="请输入您的姓名"
>
</div>
<div>
<label :class="labelClass">电话</label>
<input
v-model.trim="form.phone"
type="tel"
required
:class="inputClass"
placeholder="请输入联系电话"
>
<p v-if="phoneError" class="mt-1 text-xs text-red-500">{{ phoneError }}</p>
</div>
<div>
<label :class="labelClass">{{ contentLabel }}</label>
<textarea
v-model.trim="form.content"
rows="4"
:required="contentRequired"
:class="inputClass"
:placeholder="type === 'consult' ? '请简要描述您的需求(选填)' : '请输入您想咨询的内容'"
/>
</div>
<!-- 蜜罐对真人隐藏机器人常自动填写 -->
<div aria-hidden="true" style="position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden;opacity:0;">
<label>请勿填写此字段</label>
<input v-model="hp" type="text" tabindex="-1" autocomplete="off">
</div>
<!-- 滑块验证码仅当后台网站设置开启 requireCaptcha 时显示仅客户端渲染 -->
<ClientOnly v-if="showCaptcha">
<SliderCaptcha ref="sliderRef" :accent="accent" @verified="onVerified" />
<template #fallback>
<div class="h-[46px] animate-pulse rounded-lg bg-gray-100" />
</template>
</ClientOnly>
<button type="submit" :disabled="submitting" :class="submitClass">
{{ submitting ? '提交中...' : submitText }}
</button>
<p v-if="submitMessage" class="text-center text-sm" :class="submitSuccess ? 'text-green-600' : 'text-red-600'">
{{ submitMessage }}
</p>
</form>
</template>
<script setup lang="ts">
/**
* 共享「在线留言」表单(9 套模板统一复用)。
* 内置:手机号段校验(港澳台/海外)、蜜罐、滑块验证码、提交。
* 校验/防护逻辑集中于此,改一处即可全站生效。
*/
const props = withDefaults(
defineProps<{
inputClass?: string
submitClass?: string
labelClass?: string
accent?: string
/** 表单类型:contact(默认)或 consult */
type?: 'contact' | 'consult'
/** 内容/需求字段标签 */
contentLabel?: string
/** 提交按钮文案 */
submitText?: string
/** 内容/需求字段是否必填(默认 true) */
contentRequired?: boolean
/** 预填内容(如咨询弹窗带入的产品名/需求) */
presetContent?: string
/** 来源标记 */
source?: string
}>(),
{
inputClass: 'w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500',
submitClass:
'w-full px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed',
labelClass: 'block text-sm font-medium text-gray-700 mb-1',
accent: '#1a6dff',
type: 'contact',
contentLabel: '留言内容',
submitText: '提交留言',
contentRequired: true,
presetContent: '',
source: ''
}
)
const emit = defineEmits<{
success: []
error: [message: string]
}>()
const { submitForm } = useCms()
const sliderRef = ref<InstanceType<typeof SliderCaptcha> | null>(null)
const { siteSetting } = useSite()
const runtimeConfig = useRuntimeConfig()
const form = reactive({ name: '', phone: '', content: props.presetContent })
watch(
() => props.presetContent,
(v) => {
if (v && !form.content) form.content = v
}
)
// 弹窗场景:每次挂载(弹窗打开)时用最新预填内容重置
onMounted(() => {
if (props.presetContent) form.content = props.presetContent
})
const hp = ref('')
const submitting = ref(false)
const submitSuccess = ref(false)
const submitMessage = ref('')
const phoneError = ref('')
const captchaTicket = ref('')
/**
* 是否显示滑块验证码。
* 优先级:后台「网站设置」setting.requireCaptcha(权威)> 运行时配置 public.contactCaptcha(部署级覆盖)> 默认 false。
* 与后端 submit 的判定口径一致(见 server/utils/site.ts getContactCaptchaRequired)。
*/
const showCaptcha = computed<boolean>(() => {
const fromSetting = siteSetting.value?.requireCaptcha
if (typeof fromSetting === 'boolean') return fromSetting
const fromEnv = runtimeConfig.public.contactCaptcha
if (typeof fromEnv === 'boolean') return fromEnv
return false
})
function onVerified(ticket: string) {
captchaTicket.value = ticket
}
async function handleSubmit() {
phoneError.value = ''
submitMessage.value = ''
if (!form.name || !form.phone || (props.contentRequired && !form.content)) {
submitSuccess.value = false
submitMessage.value = props.type === 'consult'
? '请填写姓名和电话'
: '请填写完整的姓名、电话和留言内容'
return
}
if (!validatePhone(form.phone)) {
phoneError.value = '请输入有效的联系电话(支持 +区号 海外/港澳台,如 +852 61234567'
return
}
if (showCaptcha.value && !captchaTicket.value) {
submitSuccess.value = false
submitMessage.value = '请先完成滑块验证'
return
}
submitting.value = true
try {
await submitForm({
type: props.type,
name: form.name,
phone: form.phone,
content: form.content,
captchaTicket: captchaTicket.value,
website: hp.value,
source: props.source || undefined
})
submitSuccess.value = true
submitMessage.value = '提交成功,我们会尽快与您联系!'
form.name = ''
form.phone = ''
form.content = ''
captchaTicket.value = ''
sliderRef.value?.reset()
emit('success')
} catch (err: any) {
submitSuccess.value = false
submitMessage.value = err?.data?.statusMessage || err?.statusMessage || '提交失败,请稍后重试'
// 票据一次性消费,失败后需重新验证
captchaTicket.value = ''
sliderRef.value?.reset()
emit('error', submitMessage.value)
} finally {
submitting.value = false
}
}
</script>
+128
View File
@@ -0,0 +1,128 @@
<template>
<!-- 自定义图片图标后台上传值为 URL -->
<img
v-if="isCustom"
:src="name"
alt=""
loading="lazy"
style="object-fit: contain"
aria-hidden="true"
>
<!-- 内置线性图标值为图标名颜色跟随模板主题 -->
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
v-html="inner"
aria-hidden="true"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue'
/**
* 首页优势模块统一图标组件
*
* 双模式:
* 1. 内置图标 —— name 为图标名(如 building / box),渲染 36 个通用线性图标之一
* Feather 风格,MIT 许可,stroke 跟随 currentColor,自动适配各模板主题色);
* 名称不在映射表内时回退到 box。
* 2. 自定义图标 —— name 为图片 URL(http / // / 开头),渲染 <img>,按原色显示。
*
* 后台只存名字或 URL,不存 SVG 源码,避免样式耦合。
* 图标清单与后台 website-admin 的 app/components/IconPicker.vue 保持一致,
* 增删图标时务必同步两处。
*
* 使用:<FeatureIcon name="building" class="w-6 h-6 text-blue-600" />
* (class 等属性由单根元素自动透传,两个分支宽高一致)
*/
const props = defineProps<{ name?: string }>()
/** 值以 http / // / 开头视为自定义图片 URL */
const isCustom = computed(() => {
const v = props.name || ''
return v.startsWith('http') || v.startsWith('//') || v.startsWith('/')
})
const ICONS: Record<string, string> = {
building:
'<path d="M3 21h18"/><path d="M5 21V7l7-4 7 4v14"/><path d="M9 9h.01"/><path d="M15 9h.01"/><path d="M9 13h.01"/><path d="M15 13h.01"/><path d="M9 17h.01"/><path d="M15 17h.01"/>',
box:
'<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><path d="M3.27 6.96L12 12.01l8.73-5.05"/><path d="M12 22.08V12"/>',
case:
'<rect x="3" y="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M3 12h18"/>',
message:
'<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
shield:
'<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
rocket:
'<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/>',
chart:
'<path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/>',
trending:
'<path d="M23 6l-9.5 9.5-5-5L1 18"/><path d="M17 6h6v6"/>',
star:
'<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>',
users:
'<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
user:
'<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
phone:
'<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
mail:
'<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><path d="M22 6l-10 7L2 6"/>',
location:
'<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
trophy:
'<path d="M6 9H4.5a2.5 2.5 0 0 1 0-5H6"/><path d="M18 9h1.5a2.5 2.5 0 0 0 0-5H18"/><path d="M4 22h16"/><path d="M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22"/><path d="M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22"/><path d="M18 2H6v7a6 6 0 0 0 12 0V2Z"/>',
heart:
'<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>',
lightbulb:
'<path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.74c.64.46 1 1.2 1 1.99V18h6v-1.27c0-.79.36-1.53 1-1.99A7 7 0 0 0 12 2z"/>',
cog:
'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>',
globe:
'<circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
lock:
'<rect x="4" y="11" width="16" height="9" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/>',
check:
'<circle cx="12" cy="12" r="10"/><path d="M8 12l3 3 5-6"/>',
support:
'<circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><path d="M9 9h.01"/><path d="M15 9h.01"/>',
target:
'<circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/>',
clock:
'<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>',
document:
'<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
cube:
'<path d="M12 2l9 5v10l-9 5-9-5V7l9-5z"/><path d="M3 7l9 5 9-5"/><path d="M12 12v10"/>',
cloud:
'<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/>',
cpu:
'<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 14h3M1 9h3M1 14h3"/>',
money:
'<path d="M12 1v22"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>',
truck:
'<rect x="1" y="3" width="15" height="13" rx="1"/><path d="M16 8h4l3 3v5h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/>',
shopping:
'<path d="M6 2L3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
cart:
'<circle cx="9" cy="21" r="1"/><circle cx="20" cy="21" r="1"/><path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6"/>',
search:
'<circle cx="11" cy="11" r="8"/><path d="M21 21l-4.35-4.35"/>',
pencil:
'<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>',
layer:
'<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>',
fire:
'<path d="M12 2c0 0-6 6-6 12a6 6 0 0 0 12 0c0-6-6-12-6-12z"/><path d="M12 22a4 4 0 0 1-4-4c0-3 4-5 4-5s4 2 4 5a4 4 0 0 1-4 4z"/>'
}
const inner = computed(() => ICONS[props.name || ''] ?? ICONS.box)
</script>
+122
View File
@@ -0,0 +1,122 @@
<template>
<div v-if="list.length" class="mt-10 border-t border-gray-100 pt-8">
<h2 class="text-xl font-bold text-gray-900 mb-5">{{ title }}</h2>
<ul class="space-y-3">
<li
v-for="(file, idx) in sortedList"
:key="(file.url || '') + idx"
class="flex items-center gap-3 rounded-lg border border-gray-200 px-4 py-3 transition hover:border-blue-400 hover:shadow-sm"
>
<!-- 文件图标按扩展名换色统一用内联 SVG避免引入图标库依赖 -->
<span class="shrink-0" :style="{ color: iconColor(file.ext) }">
<svg
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M14 2v6h6" />
</svg>
</span>
<!-- 文件名可点击下载 -->
<a
:href="resolveUrl(file.url)"
:download="file.name"
target="_blank"
rel="noopener"
class="min-w-0 flex-1 truncate text-blue-600 hover:underline"
:title="file.name"
>
{{ file.name }}
</a>
<!-- 扩展名徽标 -->
<span
v-if="file.ext"
class="shrink-0 rounded bg-gray-100 px-2 py-0.5 text-xs font-medium uppercase text-gray-500"
>{{ file.ext }}</span>
<!-- 文件大小 -->
<span v-if="file.size" class="shrink-0 text-xs text-gray-400">{{ formatSize(file.size) }}</span>
<!-- 下载入口 -->
<a
:href="resolveUrl(file.url)"
:download="file.name"
target="_blank"
rel="noopener"
class="shrink-0 text-sm text-blue-600 hover:underline"
>下载</a>
</li>
</ul>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PageAttachment } from '~/types'
import { ensureFullUrl } from '~/utils/image'
const props = withDefaults(
defineProps<{
/** 附件列表(来自 pageData.attachments */
attachments?: PageAttachment[]
/** 区块标题,默认「相关附件」 */
title?: string
}>(),
{
attachments: () => [],
title: '相关附件'
}
)
const list = computed<PageAttachment[]>(() => props.attachments || [])
/** 按 sort 升序,sort 缺失的排后面 */
const sortedList = computed<PageAttachment[]>(() =>
[...list.value].sort((a, b) => (a.sort ?? 9999) - (b.sort ?? 9999))
)
function resolveUrl(url?: string) {
return url ? ensureFullUrl(url) : ''
}
/** 字节转可读大小 */
function formatSize(bytes?: number) {
if (!bytes || bytes <= 0) return ''
const units = ['B', 'KB', 'MB', 'GB']
let val = bytes
let i = 0
while (val >= 1024 && i < units.length - 1) {
val /= 1024
i++
}
return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
/** 不同扩展名用不同图标色,便于一眼区分文件类型 */
function iconColor(ext?: string) {
const map: Record<string, string> = {
pdf: '#e5484d',
doc: '#2b6cee',
docx: '#2b6cee',
xls: '#1f9d55',
xlsx: '#1f9d55',
csv: '#1f9d55',
ppt: '#e8833a',
pptx: '#e8833a',
zip: '#8a6df0',
rar: '#8a6df0',
'7z': '#8a6df0',
txt: '#6b7280'
}
return (ext && map[ext.toLowerCase()]) || '#6b7280'
}
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<!--
推荐资讯区块共享组件
数据useRecommend('article') 读取后台勾选 recommend=1 的文章全站跨栏目精选
显隐由父级首页通过后台首页区块开关 v-if 控制本组件只负责渲染
无数据时显示空状态占位符合允许空语义
复用其他模板 template-07只需 import 本组件并传入各自主题色即可
-->
<section class="py-16 lg:py-20 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-2xl sm:text-3xl font-bold mb-4" :style="{ color: titleColor }" data-reveal>{{ title }}</h2>
<p v-if="subtitle" class="max-w-2xl mx-auto" :style="{ color: subtitleColor }" data-reveal data-reveal-delay="100">{{ subtitle }}</p>
</div>
<div v-if="items.length > 0" :class="['grid gap-6', gridClass]">
<article
v-for="(item, index) in items"
:key="item.id || item.articleId"
class="bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow anim-card"
data-reveal
:data-reveal-delay="index * 100"
>
<NuxtLink :to="`/article/${item.id || item.articleId}`">
<div class="aspect-video bg-gray-100 flex items-center justify-center anim-card-media overflow-hidden">
<img
v-if="item.image || item.cover || item.photo"
:src="articleImage(item)"
:alt="item.title"
class="w-full h-full object-cover"
loading="lazy"
@error="onImgError($event)"
>
<span v-else class="text-gray-400">暂无图片</span>
</div>
</NuxtLink>
<div class="p-5">
<div class="text-xs text-gray-500 mb-2">
{{ formatDate(item.publishTime || item.createTime) }}
<span v-if="item.categoryName" class="ml-2" :style="{ color: accentColor }">{{ item.categoryName }}</span>
</div>
<h3 class="text-lg font-semibold mb-2 line-clamp-2 hover:text-blue-600 transition-colors" :style="{ color: titleColor }">
<NuxtLink :to="`/article/${item.id || item.articleId}`">{{ item.title }}</NuxtLink>
</h3>
<p class="text-sm line-clamp-2" :style="{ color: subtitleColor }">{{ stripHtml(item.summary) }}</p>
</div>
</article>
</div>
<!-- 空状态区块开关开启但无推荐内容时显示 -->
<div v-else class="text-center text-gray-400 py-12">
<p>暂无推荐内容</p>
</div>
<div v-if="moreLink" class="text-center mt-10">
<NuxtLink
:to="moreLink"
class="inline-flex items-center font-semibold hover:opacity-80 transition-opacity"
:style="{ color: accentColor }"
>
{{ moreText }}
<svg class="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import dayjs from 'dayjs'
import { computed } from 'vue'
import { useRecommend } from '~/composables/useRecommend'
import { useFileUrl } from '~/composables/useFileUrl'
import { stripHtml } from '~/utils'
import type { Article } from '~/types'
const props = withDefaults(
defineProps<{
title?: string
subtitle?: string
/** 主强调色:分类标签、链接、查看更多 */
accentColor?: string
/** 标题文字色 */
titleColor?: string
/** 副标题/摘要文字色 */
subtitleColor?: string
/** 展示列数(2/3/4 */
columns?: number
/** 展示条数 */
limit?: number
/** 无推荐文章时是否回退最新文章(默认 false:未勾推荐则显示空占位) */
fallbackToLatest?: boolean
/** 查看更多链接 */
moreLink?: string
/** 查看更多文案 */
moreText?: string
}>(),
{
title: '推荐资讯',
subtitle: '了解企业最新资讯与行业动态',
accentColor: '#3b82f6',
titleColor: '#111827',
subtitleColor: '#4b5563',
columns: 3,
limit: 3,
fallbackToLatest: false,
moreLink: '',
moreText: '查看更多'
}
)
const { fileUrl } = useFileUrl()
const { items } = useRecommend<Article>('article', { limit: props.limit, fallbackToLatest: props.fallbackToLatest })
const gridClass = computed(() => {
if (props.columns >= 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (props.columns === 2) return 'md:grid-cols-2'
return 'md:grid-cols-3'
})
function formatDate(date?: string) {
if (!date) return ''
return dayjs(date).format('YYYY-MM-DD')
}
function articleImage(item: Article) {
const raw = item.image || item.cover || item.photo || ''
return raw ? fileUrl(raw) : ''
}
function onImgError(e: Event) {
const el = e.target as HTMLImageElement
if (el) el.style.display = 'none'
}
</script>
+113
View File
@@ -0,0 +1,113 @@
<template>
<!--
案例区块共享组件
数据useRecommend('case') 上游 cms-case 当前无 recommend/top 字段故默认回退最新案例
代码已预留推荐过滤后台字段上线后自动切换为推荐案例
显隐由父级首页通过后台首页区块开关 v-if 控制无数据时显示空状态占位
复用其他模板 import 本组件并传入各自主题色即可
-->
<section class="py-16 lg:py-20 bg-gray-50">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-2xl sm:text-3xl font-bold mb-4" :style="{ color: titleColor }" data-reveal>{{ title }}</h2>
<p v-if="subtitle" class="max-w-2xl mx-auto" :style="{ color: subtitleColor }" data-reveal data-reveal-delay="100">{{ subtitle }}</p>
</div>
<div v-if="items.length > 0" :class="['grid gap-6', gridClass]">
<article
v-for="(item, index) in items"
:key="item.id"
class="group bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow border border-gray-100 anim-card"
data-reveal
:data-reveal-delay="index * 100"
>
<NuxtLink :to="`/case/${item.id}`" class="block">
<div class="aspect-[4/3] bg-gray-100 overflow-hidden">
<img
v-if="item.cover"
:src="caseImage(item)"
:alt="item.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
loading="lazy"
>
<span v-else class="flex items-center justify-center h-full text-gray-400">暂无图片</span>
</div>
<div class="p-5">
<h3 class="text-lg font-semibold mb-2 group-hover:opacity-80 transition-opacity" :style="{ color: titleColor }">
{{ item.title }}
</h3>
<p class="text-sm line-clamp-2" :style="{ color: subtitleColor }">{{ stripHtml(item.summary) }}</p>
<div v-if="item.clientName" class="mt-3 text-xs text-gray-500">
客户{{ item.clientName }}
</div>
</div>
</NuxtLink>
</article>
</div>
<div v-else class="text-center text-gray-400 py-12">
<p>暂无推荐内容</p>
</div>
<div v-if="moreLink" class="text-center mt-10">
<NuxtLink
:to="moreLink"
class="inline-flex items-center font-semibold hover:opacity-80 transition-opacity"
:style="{ color: accentColor }"
>
{{ moreText }}
<svg class="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRecommend } from '~/composables/useRecommend'
import { useFileUrl } from '~/composables/useFileUrl'
import { stripHtml } from '~/utils'
import type { CaseItem } from '~/types'
const props = withDefaults(
defineProps<{
title?: string
subtitle?: string
accentColor?: string
titleColor?: string
subtitleColor?: string
columns?: number
limit?: number
moreLink?: string
moreText?: string
}>(),
{
title: '案例展示',
subtitle: '真实案例呈现,见证客户成功',
accentColor: '#3b82f6',
titleColor: '#111827',
subtitleColor: '#4b5563',
columns: 3,
limit: 3,
moreLink: '',
moreText: '查看更多'
}
)
const { fileUrl } = useFileUrl()
const { items } = useRecommend<CaseItem>('case', { limit: props.limit })
const gridClass = computed(() => {
if (props.columns >= 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (props.columns === 2) return 'md:grid-cols-2'
return 'md:grid-cols-3'
})
function caseImage(item: CaseItem) {
const raw = item.cover || ''
return raw ? fileUrl(raw) : ''
}
</script>
+109
View File
@@ -0,0 +1,109 @@
<template>
<!--
推荐/置顶产品区块共享组件
数据useRecommend('product') 读取后台置顶 top>0 的产品全站跨栏目精选
显隐由父级首页通过后台首页区块开关 v-if 控制无数据时显示空状态占位
复用其他模板 import 本组件并传入各自主题色即可
-->
<section class="py-16 lg:py-20 bg-white">
<div class="container mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-12">
<h2 class="text-2xl sm:text-3xl font-bold mb-4" :style="{ color: titleColor }" data-reveal>{{ title }}</h2>
<p v-if="subtitle" class="max-w-2xl mx-auto" :style="{ color: subtitleColor }" data-reveal data-reveal-delay="100">{{ subtitle }}</p>
</div>
<div v-if="items.length > 0" :class="['grid gap-6', gridClass]">
<article
v-for="(item, index) in items"
:key="item.id || item.productId"
class="bg-white rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow anim-card"
data-reveal
:data-reveal-delay="index * 100"
>
<NuxtLink :to="`/product/${item.id || item.productId}`">
<div class="aspect-video bg-gray-100 flex items-center justify-center anim-card-media overflow-hidden">
<img
v-if="item.cover || item.photo"
:src="productImage(item)"
:alt="item.productName"
class="w-full h-full object-cover"
loading="lazy"
>
<span v-else class="text-gray-400">暂无图片</span>
</div>
</NuxtLink>
<div class="p-5">
<h3 class="text-lg font-semibold mb-2 line-clamp-2 transition-colors" :style="{ color: titleColor }">
<NuxtLink :to="`/product/${item.id || item.productId}`" class="hover:opacity-80">{{ item.productName }}</NuxtLink>
</h3>
<p class="text-sm line-clamp-2" :style="{ color: subtitleColor }">{{ stripHtml(item.description || item.subtitle) }}</p>
</div>
</article>
</div>
<div v-else class="text-center text-gray-400 py-12">
<p>暂无推荐内容</p>
</div>
<div v-if="moreLink" class="text-center mt-10">
<NuxtLink
:to="moreLink"
class="inline-flex items-center font-semibold hover:opacity-80 transition-opacity"
:style="{ color: accentColor }"
>
{{ moreText }}
<svg class="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</NuxtLink>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRecommend } from '~/composables/useRecommend'
import { useFileUrl } from '~/composables/useFileUrl'
import { stripHtml } from '~/utils'
import type { Product } from '~/types'
const props = withDefaults(
defineProps<{
title?: string
subtitle?: string
accentColor?: string
titleColor?: string
subtitleColor?: string
columns?: number
limit?: number
moreLink?: string
moreText?: string
}>(),
{
title: '推荐产品',
subtitle: '了解我们的核心产品与解决方案',
accentColor: '#3b82f6',
titleColor: '#111827',
subtitleColor: '#4b5563',
columns: 3,
limit: 3,
moreLink: '',
moreText: '查看更多'
}
)
const { fileUrl } = useFileUrl()
const { items } = useRecommend<Product>('product', { limit: props.limit })
const gridClass = computed(() => {
if (props.columns >= 4) return 'sm:grid-cols-2 lg:grid-cols-4'
if (props.columns === 2) return 'md:grid-cols-2'
return 'md:grid-cols-3'
})
function productImage(item: Product) {
const raw = item.cover || item.photo || ''
return raw ? fileUrl(raw) : ''
}
</script>
+193
View File
@@ -0,0 +1,193 @@
<template>
<div ref="contentRef" v-html="enhancedHtml" class="prose prose-slate max-w-none" />
</template>
<script setup lang="ts">
/**
* 富文本内容渲染组件
* 用于渲染 CMS 返回的 HTML 内容
*
* 设计要点:
* 1. 未安装 @tailwindcss/typography,因此 .prose 仅作为命名空间,
* 实际排版样式由本组件自身提供。
* 2. 后台编辑器(Quill / WangEditor / Tinymce 等)常输出 ql-align-center、
* w-e-text-align-center 等 class,或内联 style="text-align:center"
* 本组件会识别这些标记并让图片真正居中,实现「所见即所得」。
*/
interface Props {
content?: string | null
}
const props = defineProps<Props>()
const contentRef = ref<HTMLElement | null>(null)
const CENTER_CLASSES = ['ql-align-center', 'w-e-text-align-center']
const RIGHT_CLASSES = ['ql-align-right', 'w-e-text-align-right']
function hasCenterMarker(el: Element): boolean {
const className = el.className || ''
const style = (el.getAttribute('style') || '').toLowerCase()
return CENTER_CLASSES.some(c => className.includes(c)) || style.includes('text-align:center') || style.includes('text-align: center')
}
/** 把新的 style 声明追加到已有 style 字符串,避免双分号 */
function appendStyle(existing: string, addition: string): string {
const trimmed = existing.trim()
return trimmed ? `${trimmed};${addition}` : addition
}
/**
* 服务端/无 DOM 环境下的简单正则增强:
* - 移除 script 标签
* - 给 align="center" 的图片追加居中样式
* - 确保图片默认 display:inline-block,使父级 text-align:center 可生效
*/
function enhanceHtmlString(html: string): string {
if (!html) return ''
let result = html.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
// 给 align="center" 的图片追加 block + auto margin
result = result.replace(/<img\b([^>]*?)align=["']center["']([^>]*)>/gi, (match, before, after) => {
const styleMatch = /style=["']([^"']*)["']/i.exec(match)
let style = styleMatch ? styleMatch[1] : ''
if (!/display\s*:/i.test(style)) style = appendStyle(style, 'display:block')
if (!/margin\s*:/i.test(style) && !/margin-left\s*:/i.test(style)) {
style = appendStyle(style, 'margin-left:auto;margin-right:auto')
}
if (styleMatch) {
return match.replace(/style=["'][^"']*["']/i, `style="${style}"`)
}
return `<img${before}align="center"${after} style="${style}">`
})
// 默认所有图片 inline-block,方便响应父级 text-align
result = result.replace(/<img\b([^>]*)>/gi, (match, attrs) => {
let a = attrs
// 懒加载 + 异步解码(性能/CLS 优化),避免重复加载阻塞渲染
if (!/\bloading\s*=/i.test(a)) a = ` loading="lazy"${a}`
if (!/\bdecoding\s*=/i.test(a)) a = ` decoding="async"${a}`
if (/display\s*:/i.test(a)) return `<img${a}>`
const styleMatch = /style=["']([^"']*)["']/i.exec(a)
if (styleMatch) {
const style = appendStyle(styleMatch[1], 'display:inline-block')
return `<img${a}`.replace(/style=["'][^"']*["']/i, `style="${style}"`)
}
return `<img${a} style="display:inline-block">`
})
return result
}
/**
* 客户端增强:基于真实 DOM 做更精确的处理
*/
function enhanceDom(root: HTMLElement) {
// 1. 给常见编辑器居中/右对齐 class 追加内联 style(确保 class 样式生效)
CENTER_CLASSES.forEach(cls => {
root.querySelectorAll(`.${cls}`).forEach(el => {
(el as HTMLElement).style.textAlign = 'center'
})
})
RIGHT_CLASSES.forEach(cls => {
root.querySelectorAll(`.${cls}`).forEach(el => {
(el as HTMLElement).style.textAlign = 'right'
})
})
// 2. 处理图片:让被父级标记为居中的图片真正居中
root.querySelectorAll('img').forEach(img => {
const parent = img.parentElement
const isCentered =
img.getAttribute('align') === 'center' ||
(parent ? hasCenterMarker(parent) || hasCenterMarker(img) : hasCenterMarker(img))
if (isCentered) {
img.style.display = 'block'
img.style.marginLeft = 'auto'
img.style.marginRight = 'auto'
} else if (!img.style.display) {
// 默认 inline-block:父级 text-align:center 时仍可居中,
// 同时保留 vertical-align 避免行高异常
img.style.display = 'inline-block'
}
if (!img.style.maxWidth) img.style.maxWidth = '100%'
if (!img.style.height) img.style.height = 'auto'
})
}
const enhancedHtml = computed(() => enhanceHtmlString(props.content || ''))
onMounted(() => {
if (contentRef.value) {
enhanceDom(contentRef.value)
}
})
// 内容变化时重新增强(例如路由切换复用组件)
watch(() => props.content, () => {
nextTick(() => {
if (contentRef.value) {
enhanceDom(contentRef.value)
}
})
})
</script>
<style>
.prose img {
max-width: 100%;
height: auto;
display: inline-block;
vertical-align: middle;
}
.prose h2, .prose h3, .prose h4 {
margin-top: 1.5em;
margin-bottom: 0.75em;
}
.prose p {
margin-bottom: 1em;
line-height: 1.75;
}
.prose ul, .prose ol {
margin-bottom: 1em;
padding-left: 1.5em;
}
.prose ul {
list-style-type: disc;
}
.prose ol {
list-style-type: decimal;
}
/* 常见富文本编辑器居中/右对齐 class */
.prose .ql-align-center,
.prose .w-e-text-align-center,
.prose [style*="text-align: center"],
.prose [style*="text-align:center"] {
text-align: center;
}
.prose .ql-align-right,
.prose .w-e-text-align-right,
.prose [style*="text-align: right"],
.prose [style*="text-align:right"] {
text-align: right;
}
/* 父级被标记居中时,内部图片也居中 */
.prose .ql-align-center img,
.prose .w-e-text-align-center img,
.prose [style*="text-align: center"] img,
.prose [style*="text-align:center"] img,
.prose img[align="center"] {
display: block;
margin-left: auto;
margin-right: auto;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
<template>
<NuxtLink to="/" class="flex items-center gap-2" :class="linkClass">
<!-- 优先使用完整 Logo 图片 -->
<img
v-if="logo"
:src="logo"
:alt="name || defaultName"
:class="logoClass"
>
<!-- Logo 优先使用站点图标 + 网站名称 -->
<template v-else>
<img
v-if="icon"
:src="icon"
:alt="name || defaultName"
:class="iconClass || 'h-8 w-auto object-contain'"
>
<!-- 没有站点图标时模板可传入自定义默认图标 -->
<slot v-else name="fallback-icon" />
<span
v-if="showName !== false"
:class="nameClass"
>
{{ name || defaultName }}
</span>
</template>
</NuxtLink>
</template>
<script setup lang="ts">
/**
* 站点品牌展示组件
*
* 统一 Header / Footer 等位置的 Logo 回退逻辑:
* 1. 有站点 Logo(亮色 logo 图片)→ 直接显示图片
* 2. 无 Logo 但有站点图标(appIcon / websiteIcon)→ 显示「图标 + 网站名称」
* 3. 都没有 → 渲染模板自定义的 fallback-icon slot,并显示网站名称
*/
withDefaults(defineProps<{
/** 站点 Logo 图片地址(siteLogo */
logo?: string
/** 站点图标地址(siteIcon */
icon?: string
/** 站点名称(siteName */
name?: string
/** 名称兜底文案 */
defaultName?: string
/** 是否显示名称 */
showName?: boolean
/** 外层 NuxtLink 额外 class */
linkClass?: string
/** Logo 图片 class */
logoClass?: string
/** 图标 class */
iconClass?: string
/** 名称 class */
nameClass?: string
}>(), {
logo: '',
icon: '',
name: '',
defaultName: '企业官网',
showName: true,
linkClass: '',
logoClass: '',
iconClass: '',
nameClass: ''
})
</script>
+29
View File
@@ -0,0 +1,29 @@
<template>
<div class="rounded-md bg-red-50 p-4 my-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
/>
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">加载失败</h3>
<div class="mt-2 text-sm text-red-700">
<p>{{ message || '获取数据失败,请稍后重试' }}</p>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
message?: string
}
defineProps<Props>()
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<Component :is="footerComponent" v-if="footerComponent" />
</template>
<script setup lang="ts">
/**
* 站点 Footer 包装组件
* 根据当前模板 ID 动态加载对应的 Footer 组件
*/
const { loadTemplate } = useTemplate()
const footerComponent = shallowRef<Component | null>(null)
// 同步加载当前模板(SSR/客户端路由切换均直接 await,避免先闪 SiteLoading
const components = await loadTemplate()
footerComponent.value = components?.Footer || null
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<Component :is="headerComponent" v-if="headerComponent" />
</template>
<script setup lang="ts">
/**
* 站点 Header 包装组件
* 根据当前模板 ID 动态加载对应的 Header 组件
*/
const { loadTemplate } = useTemplate()
const headerComponent = shallowRef<Component | null>(null)
// 同步加载当前模板(SSR/客户端路由切换均直接 await,避免先闪 SiteLoading
const components = await loadTemplate()
headerComponent.value = components?.Header || null
</script>
+17
View File
@@ -0,0 +1,17 @@
<template>
<Component :is="homeComponent" v-if="homeComponent" />
</template>
<script setup lang="ts">
/**
* 站点首页包装组件
* 根据当前模板 ID 动态加载对应的首页组件
*/
const { loadTemplate } = useTemplate()
const homeComponent = shallowRef<Component | null>(null)
// 同步加载当前模板(SSR/客户端路由切换均直接 await,避免先闪 SiteLoading
const components = await loadTemplate()
homeComponent.value = components?.Home || null
</script>
+15
View File
@@ -0,0 +1,15 @@
<template>
<div class="min-h-[200px] flex items-center justify-center">
<div class="text-center">
<div
class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent align-[-0.125em] motion-reduce:animate-[spin_1.5s_linear_infinite]"
role="status"
/>
<p class="mt-2 text-sm text-gray-500">加载中...</p>
</div>
</div>
</template>
<script setup lang="ts">
// 全局 Loading 组件
</script>
+72
View File
@@ -0,0 +1,72 @@
<template>
<form
class="relative"
:style="accent ? { '--sb-accent': accent } : undefined"
@submit.prevent="onSearch"
>
<input
v-model="keyword"
type="text"
:placeholder="placeholder"
class="w-56 pl-4 pr-10 py-2 text-sm rounded-full border bg-[#F4F6FA] focus:outline-none focus:bg-white transition-colors"
:class="inputClass"
>
<button
type="submit"
class="absolute right-2 top-1/2 -translate-y-1/2 transition-colors"
:class="iconClass"
aria-label="搜索"
>
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-4.35-4.35M17 11a6 6 0 11-12 0 6 6 0 0112 0z" />
</svg>
</button>
</form>
</template>
<script setup lang="ts">
/**
* 共享头部搜索框(Nuxt 自动导入,全局可用)
* 由各模板 Header 在 logo 右侧以 <SiteSearchBox v-if="showHeaderSearch" /> 挂载。
* 是否显示由 useSite().showHeaderSearch(后台 setting.searchBtn)控制。
* 提交后带关键词跳转 /article?keywords=xxx(复用 fetchArticles 的 keywords 参数)。
*
* 定位(ml-8 / ml-auto 等)由父组件通过 class 透传(fallthrough 合并到根 <form>);
* 视觉主题通过 variant / accent 适配各模板配色。
*/
const props = withDefaults(
defineProps<{
/** 头部背景:light=浅色头部(默认),dark=深色头部 */
variant?: 'light' | 'dark'
/** 主题色(聚焦边框 / 图标 hover 色),支持 CSS 变量名如 'var(--t2-primary)' */
accent?: string
placeholder?: string
}>(),
{
variant: 'light',
placeholder: '请输入关键词...'
}
)
const router = useRouter()
const keyword = ref('')
const inputClass = computed(() => {
if (props.variant === 'dark') {
return 'border-[#333] bg-[#222] text-gray-100 placeholder-gray-500 focus:border-[color:var(--sb-accent,#d4af37)]'
}
return 'border-[#E5E7EB] bg-[#F4F6FA] text-gray-800 placeholder-gray-400 focus:border-[color:var(--sb-accent,#1E2A47)]'
})
const iconClass = computed(() => {
if (props.variant === 'dark') {
return 'text-gray-300 hover:text-[color:var(--sb-accent,#d4af37)]'
}
return 'text-gray-500 hover:text-[color:var(--sb-accent,#1E2A47)]'
})
function onSearch() {
const kw = keyword.value.trim()
router.push({ path: '/article', query: kw ? { keywords: kw } : {} })
}
</script>
+265
View File
@@ -0,0 +1,265 @@
<template>
<div class="slider-captcha">
<div
ref="trackRef"
class="relative w-full select-none overflow-hidden rounded-lg"
:class="shake ? 'shake' : ''"
:style="{
height: trackHeight + 'px',
background: '#eef2f6',
backgroundImage:
'repeating-linear-gradient(45deg, transparent, transparent 10px, rgba(0,0,0,0.03) 10px, rgba(0,0,0,0.03) 20px)',
touchAction: 'pan-y'
}"
>
<!-- 目标缺口拼图应放置的位置 -->
<div
class="target-gap absolute top-1/2 z-10 flex -translate-y-1/2 items-center justify-center rounded-md"
:style="{
left: (puzzleX - size / 2) + 'px',
width: size + 'px',
height: size + 'px'
}"
>
<svg
v-if="!verified && !dragging"
class="h-5 w-5 text-white drop-shadow"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
<!-- 已拖动填充 -->
<div
class="absolute top-0 left-0 h-full transition-[width] duration-75"
:style="{ width: knobX + 'px', background: accent, opacity: 0.16 }"
/>
<!-- 拼图块可拖动 -->
<div
class="absolute top-1/2 z-20 flex -translate-y-1/2 items-center justify-center rounded-md text-white shadow-md"
:class="dragging ? 'cursor-grabbing' : 'cursor-grab'"
:style="{
left: knobX + 'px',
width: size + 'px',
height: size + 'px',
background: verified ? '#16a34a' : accent,
transition: dragging ? 'none' : 'left 0.2s ease',
boxShadow: verified ? '0 4px 12px rgba(22,163,74,0.35)' : '0 4px 12px rgba(0,0,0,0.18)',
touchAction: 'none'
}"
@pointerdown.prevent="startDrag"
>
<!-- 拖动图标 -->
<svg v-if="!verified" class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 7L4 12l4 5M16 7l4 5-4 5" />
</svg>
<!-- 通过图标 -->
<svg v-else class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6L9 17l-5-5" />
</svg>
</div>
<!-- 操作提示 -->
<div
v-if="!verified"
class="pointer-events-none absolute inset-0 flex items-center justify-center text-xs"
:class="dragging ? 'text-transparent' : 'text-gray-500'"
>
{{ loading ? '加载中...' : '拖动左侧滑块到缺口位置' }}
</div>
<!-- 验证通过遮罩 -->
<div
v-if="verified"
class="absolute inset-0 z-30 flex items-center justify-center text-xs font-medium text-white"
:style="{ background: 'rgba(22,163,74,0.9)' }"
>
验证通过
</div>
</div>
<p v-if="error" class="mt-1 text-xs text-red-500">{{ error }}</p>
</div>
</template>
<script setup lang="ts">
/**
* 滑块拼图验证码(客户端)。
* 1) 挂载时向后端申请 challenge(含缺口位置 puzzleX
* 2) 用户拖动拼图块到缺口附近松手
* 3) 校验通过则 emit('verified', ticket),由父组件随留言一并提交
*/
withDefaults(defineProps<{ accent?: string }>(), {
accent: '#1a6dff'
})
const emit = defineEmits<{ (e: 'verified', ticket: string): void }>()
const size = 44
const trackHeight = size // 上下各留 1px,滑块几乎贴满轨道
const tolerance = 8
const trackRef = ref<HTMLElement | null>(null)
const challenge = ref<{ token: string; puzzleX: number; width: number; size: number } | null>(null)
const knobX = ref(0)
const dragging = ref(false)
const loading = ref(true)
const verified = ref(false)
const error = ref('')
const shake = ref(false)
let pointerStartX = 0
let knobStartX = 0
let resizeTimer: ReturnType<typeof setTimeout> | null = null
const puzzleX = computed(() => challenge.value?.puzzleX ?? size)
function loadChallenge() {
const width = Math.min(Math.max(trackRef.value?.clientWidth || 320, 240), 480)
loading.value = true
$fetch<{ token: string; puzzleX: number; width: number; size: number }>('/api/captcha/challenge', {
method: 'POST',
body: { width }
})
.then((data) => {
challenge.value = data
knobX.value = 0
verified.value = false
error.value = ''
})
.catch(() => {
error.value = '验证码加载失败,请刷新页面'
})
.finally(() => {
loading.value = false
})
}
function startDrag(e: PointerEvent) {
if (verified.value || loading.value || !challenge.value) return
dragging.value = true
pointerStartX = e.clientX
knobStartX = knobX.value
error.value = ''
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
window.addEventListener('pointercancel', onUp)
}
function onMove(e: PointerEvent) {
if (!dragging.value || !challenge.value) return
const max = challenge.value.width - size
const next = knobStartX + (e.clientX - pointerStartX)
knobX.value = Math.min(Math.max(next, 0), max)
}
function onUp() {
if (!dragging.value) return
dragging.value = false
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
if (!challenge.value) return
const userX = knobX.value + size / 2
const ok = Math.abs(userX - challenge.value.puzzleX) <= tolerance
if (!ok) {
error.value = '未对准缺口,请重试'
doShake()
knobX.value = 0
loadChallenge()
return
}
$fetch<{ pass: boolean; ticket?: string }>('/api/captcha/verify', {
method: 'POST',
body: { token: challenge.value.token, x: userX }
})
.then((res) => {
if (res.pass && res.ticket) {
verified.value = true
error.value = ''
emit('verified', res.ticket)
} else {
failAndReset('验证失败,请重试')
}
})
.catch(() => {
failAndReset('验证失败,请重试')
})
}
function failAndReset(msg: string) {
error.value = msg
doShake()
knobX.value = 0
loadChallenge()
}
function doShake() {
shake.value = true
setTimeout(() => {
shake.value = false
}, 350)
}
/** 供父组件在提交失败后重置(票据一次性,需重新验证) */
function reset() {
verified.value = false
knobX.value = 0
error.value = ''
loadChallenge()
}
defineExpose({ reset })
function onResize() {
if (verified.value) return
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(loadChallenge, 200)
}
onMounted(() => {
loadChallenge()
window.addEventListener('resize', onResize)
})
onBeforeUnmount(() => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
window.removeEventListener('pointercancel', onUp)
window.removeEventListener('resize', onResize)
if (resizeTimer) clearTimeout(resizeTimer)
})
</script>
<style scoped>
@keyframes slider-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-6px); }
40% { transform: translateX(6px); }
60% { transform: translateX(-4px); }
80% { transform: translateX(4px); }
}
.shake {
animation: slider-shake 0.35s ease-in-out;
}
@keyframes target-pulse {
0%, 100% { box-shadow: inset 0 0 0 2px rgba(255,255,255,0.95), 0 4px 10px rgba(0,0,0,0.14); }
50% { box-shadow: inset 0 0 0 2px rgba(255,255,255,1), 0 4px 14px rgba(0,0,0,0.22); }
}
.target-gap {
background: rgba(0, 0, 0, 0.22);
border: 2px dashed rgba(255, 255, 255, 0.9);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.95), 0 4px 10px rgba(0, 0, 0, 0.14);
animation: target-pulse 1.6s ease-in-out infinite;
}
</style>