- 后台全面引入 Naive UI 组件库,统一 UI 规范 - 前台 usePublicApi 切换到 API 调用(GET /api/content/[key]) - 前后台数据源统一(site_content 表) - 产品线统一管理(tour/camp/course 三套编辑器) - 产品数据归一化(itinerary/faq/pricing 格式统一) - 表单提交 API 改写入 submissions 表 - 新增 submissions 标记已读 API - site-content PUT 支持 upsert - 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb) - 消除 PageHero 类型警告(useSEO reactive 修复) - 25 个前台页面全部 HTTP 200,展示效果不变 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
226 行
5.2 KiB
Vue
226 行
5.2 KiB
Vue
<template>
|
||
<!--
|
||
双层设计:
|
||
上层:截图(视觉证明真实性)
|
||
下层:文字(SEO可抓取)
|
||
-->
|
||
<article class="review-item">
|
||
<!-- 截图层 -->
|
||
<div v-if="review.screenshot" class="review-screenshot" :class="{ 'is-expanded': expanded }">
|
||
<img
|
||
:src="review.screenshot"
|
||
:alt="`客户${review.nickname}对呼籁旅行${review.productVersion}的评价`"
|
||
loading="lazy"
|
||
class="review-img"
|
||
@click="expanded = !expanded"
|
||
/>
|
||
<button
|
||
v-if="!expanded"
|
||
class="screenshot-expand"
|
||
@click="expanded = true"
|
||
aria-label="查看完整截图"
|
||
>
|
||
<span class="expand-icon">▾</span> 查看完整截图
|
||
</button>
|
||
<button
|
||
v-else
|
||
class="screenshot-collapse"
|
||
@click="expanded = false"
|
||
aria-label="收起截图"
|
||
>
|
||
<span class="expand-icon expand-icon--up">▴</span> 收起截图
|
||
</button>
|
||
</div>
|
||
|
||
<!-- 文字层(SEO核心) -->
|
||
<div class="review-content">
|
||
<div class="review-meta">
|
||
<span class="review-name">{{ review.nickname }}</span>
|
||
<span class="review-sep">·</span>
|
||
<span class="review-date">{{ review.travelDate }}</span>
|
||
</div>
|
||
<p class="review-product">{{ review.productVersion }}</p>
|
||
<blockquote class="review-text" :class="{ 'is-clamped': !textExpanded && isLongText }">
|
||
{{ review.content }}
|
||
</blockquote>
|
||
<button
|
||
v-if="isLongText"
|
||
class="text-toggle"
|
||
@click="textExpanded = !textExpanded"
|
||
>
|
||
{{ textExpanded ? '收起' : '展开全文' }}
|
||
</button>
|
||
<div class="review-tags">
|
||
<span v-for="s in review.scenes" :key="s" class="tag tag--scene">{{ s }}</span>
|
||
<span v-for="c in review.concerns" :key="c" class="tag tag--concern">{{ c }}</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed } from 'vue'
|
||
|
||
const props = defineProps({ review: { type: Object, required: true } })
|
||
|
||
const expanded = ref(false)
|
||
const textExpanded = ref(false)
|
||
const isLongText = computed(() => props.review.content && props.review.content.length > 150)
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.review-item {
|
||
background: @color-bg-white;
|
||
border: 1px solid @color-border;
|
||
border-radius: @border-radius-lg;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: box-shadow @transition-base, transform @transition-base;
|
||
|
||
&:hover {
|
||
box-shadow: @shadow-md;
|
||
transform: translateY(-2px);
|
||
}
|
||
}
|
||
|
||
.review-screenshot {
|
||
position: relative;
|
||
background: @color-bg-gray;
|
||
max-height: 320px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
flex-shrink: 0;
|
||
|
||
/* 底部渐变遮罩,提示可展开 */
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
height: 60px;
|
||
background: linear-gradient(transparent, rgba(248, 248, 250, 0.95));
|
||
pointer-events: none;
|
||
transition: opacity @transition-fast;
|
||
}
|
||
|
||
&.is-expanded {
|
||
max-height: none;
|
||
|
||
&::after {
|
||
opacity: 0;
|
||
}
|
||
}
|
||
}
|
||
|
||
.review-img {
|
||
width: 100%;
|
||
height: auto;
|
||
display: block;
|
||
}
|
||
|
||
.screenshot-expand,
|
||
.screenshot-collapse {
|
||
position: absolute;
|
||
bottom: @space-sm;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
padding: @space-xs @space-lg;
|
||
font-size: @font-size-sm;
|
||
color: @color-primary;
|
||
background: @color-bg-white;
|
||
border: 1px solid @color-primary;
|
||
border-radius: @border-radius-full;
|
||
cursor: pointer;
|
||
z-index: 1;
|
||
white-space: nowrap;
|
||
transition: all @transition-fast;
|
||
font-weight: @font-weight-medium;
|
||
box-shadow: @shadow-sm;
|
||
|
||
&:hover {
|
||
background: @color-primary;
|
||
color: #fff;
|
||
}
|
||
}
|
||
|
||
.expand-icon {
|
||
font-size: @font-size-xs;
|
||
margin-right: @space-xs;
|
||
}
|
||
|
||
.screenshot-collapse {
|
||
position: relative;
|
||
bottom: auto;
|
||
left: auto;
|
||
transform: none;
|
||
display: block;
|
||
margin: @space-sm auto;
|
||
}
|
||
|
||
.review-content {
|
||
padding: @space-md @space-lg @space-lg;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
|
||
.review-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: @space-xs;
|
||
margin-bottom: @space-xs;
|
||
}
|
||
|
||
.review-name { font-weight: @font-weight-medium; color: @color-text-primary; }
|
||
.review-sep { color: @color-text-muted; }
|
||
.review-date { font-size: @font-size-sm; color: @color-text-muted; }
|
||
.review-product { font-size: @font-size-sm; color: @color-primary; margin-bottom: @space-sm; }
|
||
|
||
.review-text {
|
||
font-size: @font-size-sm;
|
||
line-height: 1.7;
|
||
color: @color-text-secondary;
|
||
margin-bottom: @space-sm;
|
||
|
||
&.is-clamped {
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 4;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
}
|
||
|
||
.text-toggle {
|
||
background: none;
|
||
border: none;
|
||
color: @color-primary;
|
||
font-size: @font-size-xs;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
margin-bottom: @space-sm;
|
||
align-self: flex-start;
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
|
||
.review-tags {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: @space-xs;
|
||
margin-top: auto;
|
||
}
|
||
|
||
.tag {
|
||
padding: @space-xs @space-sm;
|
||
font-size: @font-size-xs;
|
||
border-radius: @border-radius-full;
|
||
|
||
&--scene { background: @color-primary-bg; color: @color-primary; }
|
||
&--concern { background: @color-bg-warm; color: @color-accent; }
|
||
}
|
||
</style>
|