Mimingguang ee078b61bc feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 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>
2026-03-24 17:48:02 +08:00

226 行
5.2 KiB
Vue

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<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">&#9662;</span> 查看完整截图
</button>
<button
v-else
class="screenshot-collapse"
@click="expanded = false"
aria-label="收起截图"
>
<span class="expand-icon expand-icon--up">&#9652;</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>