feat(app): 添加多模板关于我们页面及相关路由和404页面
- 新增404页面,优化未找到页面体验,避免被搜索引擎索引 - 增加文件代理接口,隐藏真实文件服务器地址,支持文件请求代理 - 实现/article、/case、/product及/page动态路由兼容列表与详情展示 - 添加动态CMS页面兼容入口处理旧式路径,统一路由与SEO设置 - 新增模板1、模板7、模板2、模板3关于我们页面,实现多模板支持 - 模板增强支持CMS单页内容加载及SEO信息动态设置 - 配置环境变量及Git忽略文件规则辅助开发和构建环境管理
This commit is contained in:
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user