文件
fenleixinxi/uniapp/pages/detail/detail.vue
MiAn 14d02aedcd feat: 新增提审防审模式,隐藏发布及评论相关入口
本次新增由后台 virtualPayAudit 开关控制的审核模式,在该模式下:
1. 隐藏底部 Tab 栏的发布按钮
2. 隐藏空状态、分类页、个人页的发布入口
3. 隐藏帖子卡片、详情页、个人发布页的评论数及评价入口
4. 发布页在审核模式下直接拦截并跳转至首页
5. 详情页的用户评价及评价弹层在审核模式下隐藏,避免 UGC 判定
2026-11-04 16:26:25 +08:00

2999 行
84 KiB
Vue

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

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

<template>
<view class="detail fc-page">
<!-- 顶部统一导航 -->
<fc-navbar title="详情" theme="gradient" :show-back="true" />
<!-- 骨架屏加载占位态(首次进入数据未加载完成时呈现,1:1 对齐真实卡片) -->
<view v-if="loading && !loaded" class="detail__skeleton-wrap">
<!-- 顶部画廊封面骨架 -->
<view class="detail__skeleton-gallery fc-skeleton"></view>
<!-- 标题与价格卡片骨架 -->
<view class="fc-card detail__skeleton-card">
<view class="detail__skeleton-line fc-skeleton" style="width: 85%; height: 38rpx;"></view>
<view class="detail__skeleton-line fc-skeleton" style="width: 52%; height: 32rpx; margin-top: 18rpx;"></view>
<view class="detail__skeleton-row" style="margin-top: 32rpx;">
<view class="detail__skeleton-line fc-skeleton" style="width: 180rpx; height: 48rpx;"></view>
<view class="detail__skeleton-line fc-skeleton" style="width: 150rpx; height: 26rpx;"></view>
</view>
<view class="detail__skeleton-line fc-skeleton" style="width: 44%; height: 24rpx; margin-top: 24rpx;"></view>
</view>
<!-- 发布者卡片骨架 -->
<view class="fc-card detail__skeleton-card">
<view class="detail__skeleton-row">
<view class="detail__skeleton-avatar fc-skeleton"></view>
<view class="detail__skeleton-col" style="margin-left: 20rpx; flex: 1;">
<view class="detail__skeleton-line fc-skeleton" style="width: 190rpx; height: 32rpx;"></view>
<view class="detail__skeleton-line fc-skeleton" style="width: 270rpx; height: 24rpx; margin-top: 14rpx;"></view>
</view>
<view class="detail__skeleton-pill fc-skeleton" style="width: 124rpx; height: 56rpx;"></view>
</view>
</view>
<!-- 详细描述卡片骨架 -->
<view class="fc-card detail__skeleton-card">
<view class="detail__skeleton-line fc-skeleton" style="width: 140rpx; height: 32rpx;"></view>
<view class="detail__skeleton-line fc-skeleton" style="width: 100%; height: 26rpx; margin-top: 24rpx;"></view>
<view class="detail__skeleton-line fc-skeleton" style="width: 90%; height: 26rpx; margin-top: 14rpx;"></view>
<view class="detail__skeleton-line fc-skeleton" style="width: 62%; height: 26rpx; margin-top: 14rpx;"></view>
<view class="detail__skeleton-row" style="margin-top: 28rpx;">
<view class="detail__skeleton-pill fc-skeleton" style="width: 100rpx; height: 44rpx; margin-right: 14rpx;"></view>
<view class="detail__skeleton-pill fc-skeleton" style="width: 140rpx; height: 44rpx; margin-right: 14rpx;"></view>
<view class="detail__skeleton-pill fc-skeleton" style="width: 110rpx; height: 44rpx;"></view>
</view>
<view class="detail__skeleton-block fc-skeleton" style="height: 112rpx; margin-top: 28rpx;"></view>
</view>
<!-- 联系方式卡片骨架 -->
<view class="fc-card detail__skeleton-card">
<view class="detail__skeleton-line fc-skeleton" style="width: 140rpx; height: 32rpx;"></view>
<view class="detail__skeleton-row" style="margin-top: 24rpx; height: 72rpx;">
<view class="detail__skeleton-line fc-skeleton" style="width: 130rpx; height: 28rpx;"></view>
<view class="detail__skeleton-line fc-skeleton" style="width: 260rpx; height: 34rpx;"></view>
</view>
<view class="detail__skeleton-pill fc-skeleton" style="width: 100%; height: 80rpx; margin-top: 26rpx;"></view>
</view>
</view>
<!-- 真实内容视图(数据到达后平滑淡入呈现) -->
<view v-else class="detail__content-wrap">
<!-- 顶部实拍大图画廊 / 分类主题插画 -->
<view v-if="imageUrls.length" class="detail__gallery">
<swiper
class="detail__swiper"
circular
:autoplay="imageUrls.length > 1"
:interval="4000"
:duration="400"
@change="onSwiperChange"
>
<swiper-item v-for="(img, idx) in imageUrls" :key="idx" @click="previewImages(idx)">
<image class="detail__swiper-img" :src="img" mode="aspectFill" />
</swiper-item>
</swiper>
<view v-if="imageUrls.length > 1" class="detail__swiper-badge">
<text>{{ activeSwiperIndex + 1 }} / {{ imageUrls.length }}</text>
</view>
<view class="detail__hero-badges detail__hero-badges--float">
<text class="fc-tag detail__badge">{{ post.categoryName }}</text>
<text v-if="post.isTop || post.isPaid" class="fc-tag detail__badge detail__badge--paid">置顶推荐</text>
</view>
</view>
<view v-else class="detail__hero">
<view class="detail__hero-thumb">
<image class="detail__hero-img" :src="catIcon()" mode="aspectFit" />
</view>
<view class="detail__hero-badges">
<text class="fc-tag detail__badge">{{ post.categoryName }}</text>
<text v-if="post.isTop || post.isPaid" class="fc-tag detail__badge detail__badge--paid">置顶推荐</text>
</view>
</view>
<!-- 核心信息卡片:标题、价格、数据指标 -->
<view class="fc-card detail__head">
<view class="detail__title-row">
<text class="detail__title">{{ post.title }}</text>
</view>
<view class="detail__price-row">
<view class="fc-price">
<text v-if="post.price > 0" class="fc-price__symbol">¥</text>
<text v-if="post.price > 0" class="detail__amount">{{ post.price }}</text>
<text v-if="post.price > 0 && post.priceUnit" class="detail__unit">{{ post.priceUnit }}</text>
<text v-if="!(post.price > 0)" class="detail__neg">{{ post.priceUnit || '面议' }}</text>
</view>
<view class="detail__stats">
<view class="fc-inline">
<image class="fc-icon fc-icon--sm" :src="iconUrl('eye')" mode="aspectFit" />
<text class="detail__stat-num">{{ post.views || 0 }}</text>
</view>
<view v-if="!isAudit" class="fc-inline detail__stat-gap">
<image class="fc-icon fc-icon--sm" :src="iconUrl('comment-gray')" mode="aspectFit" />
<text class="detail__stat-num">{{ post.commentCount || 0 }}</text>
</view>
</view>
</view>
<view class="detail__meta">
<text>{{ post.area }}<text v-if="post.address"> · {{ post.address }}</text></text>
</view>
</view>
<!-- 发布者专区(普通个人发布时展示个人档案,商家发布由下方官方门店卡片承载,避免信息重复) -->
<view v-if="!isMerchantPost" class="fc-card detail__publisher" @click="goUserDetail">
<image
v-if="post.publisher && post.publisher.avatar"
class="detail__avatar detail__avatar--img"
:src="assetUrl(post.publisher.avatar)"
mode="aspectFill"
/>
<view v-else class="detail__avatar">
{{ (post.publisher && post.publisher.name || '同').slice(0, 1) }}
</view>
<view class="detail__publisher-info">
<view class="detail__publisher-name-row">
<text class="detail__publisher-name">{{ post.publisher && post.publisher.name }}</text>
<text v-if="post.publisher && post.publisher.isMember && !isAudit" class="detail__vip-tag">VIP</text>
<text v-if="post.publisher && post.publisher.verified" class="detail__verified">已认证</text>
</view>
<text class="detail__publisher-sub">
{{ (post.publisher && post.publisher.isMember && !isAudit) ? 'VIP尊享会员' : '个人用户' }} · {{ timeText }}发布
</text>
</view>
<view v-if="!isSelf" class="detail__follow" :class="{ 'detail__follow--active': followed }" @click.stop="toggleFollow">
<text>{{ followed ? '已关注' : '+ 关注' }}</text>
</view>
</view>
<!-- 商家门店专区(仅商家发布或关联认证商户时展示) -->
<view v-if="isMerchantPost" class="fc-card detail__merchant" @click="goShopDetail">
<view class="detail__merchant-badge-bar">
<view class="detail__merchant-flag">
<image class="fc-icon fc-icon--xs" :src="iconUrl('shield')" mode="aspectFit" />
<text class="detail__merchant-flag-txt">认证商家官方门店</text>
</view>
<view class="detail__merchant-entry">
<text class="detail__merchant-entry-txt">进店逛逛</text>
<text class="detail__merchant-entry-arrow">›</text>
</view>
</view>
<view class="detail__merchant-main">
<image
v-if="merchantInfo && merchantInfo.logo"
class="detail__merchant-logo"
:src="assetUrl(merchantInfo.logo)"
mode="aspectFill"
/>
<view v-else class="detail__merchant-logo-placeholder">
<image class="fc-icon fc-icon--md" :src="iconUrl('login-shop') || iconUrl('home-nearby')" mode="aspectFit" />
</view>
<view class="detail__merchant-meta">
<view class="detail__merchant-name-row">
<text class="detail__merchant-name">{{ (merchantInfo && merchantInfo.name) || (post.publisher && post.publisher.name) || '同城认证门店' }}</text>
</view>
<view class="detail__merchant-tags">
<text v-if="merchantInfo && merchantInfo.category" class="detail__merchant-tag-item">{{ merchantInfo.category }}</text>
<text class="detail__merchant-tag-item detail__merchant-tag-item--credit">实体保障</text>
<text class="detail__merchant-tag-item detail__merchant-tag-item--credit">真实经营</text>
</view>
</view>
</view>
<!-- 店铺公告(若有) -->
<view v-if="merchantInfo && merchantInfo.notice" class="detail__merchant-notice">
<image class="fc-icon fc-icon--xs" :src="iconUrl('notice')" mode="aspectFit" />
<text class="detail__merchant-notice-txt">{{ merchantInfo.notice }}</text>
</view>
<!-- 实体门店地址与一键导航 -->
<view v-if="merchantAddress" class="detail__merchant-address-box" @click.stop="openMerchantLocation">
<view class="detail__merchant-address-left">
<image class="fc-icon fc-icon--sm" :src="iconUrl('home-location')" mode="aspectFit" />
<text class="detail__merchant-address-txt">{{ merchantAddress }}</text>
</view>
<view class="detail__merchant-nav-btn">
<text>{{ hasMerchantCoord ? '导航到店' : '复制地址' }}</text>
<text class="detail__merchant-nav-arrow">›</text>
</view>
</view>
</view>
<!-- 详细内容与服务标签(付费模式下未解锁仅展示摘要,引导付费解锁或开通会员) -->
<view class="fc-card">
<view class="detail__content-head">
<text class="detail__section-title">详细描述</text>
<text v-if="contentLocked" class="detail__lock-flag">
<image class="fc-icon fc-icon--xs" :src="iconUrl('lock')" mode="aspectFit" />
未解锁
</text>
</view>
<!-- 产地规格(农产品发布时填写,三列信息小卡便于采购商快速核对;锁定态一并隐藏) -->
<view v-if="hasFarmSpecs" class="detail__specs">
<view v-if="post.plantArea" class="detail__spec-item">
<view class="detail__spec-key">
<view class="detail__spec-dot"></view>
<text>种植面积</text>
</view>
<text class="detail__spec-val">{{ post.plantArea }}</text>
</view>
<view v-if="post.harvestTime" class="detail__spec-item">
<view class="detail__spec-key">
<view class="detail__spec-dot"></view>
<text>采摘时间</text>
</view>
<text class="detail__spec-val">{{ post.harvestTime }}</text>
</view>
<view v-if="post.outputRange" class="detail__spec-item">
<view class="detail__spec-key">
<view class="detail__spec-dot"></view>
<text>预计量产</text>
</view>
<text class="detail__spec-val">{{ post.outputRange }}</text>
</view>
</view>
<text class="detail__content" :class="{ 'detail__content--locked': contentLocked }">{{ post.content }}</text>
<!-- 锁定态:摘要渐隐遮罩 + 解锁引导 -->
<view v-if="contentLocked" class="detail__lock-box">
<view class="detail__lock-fade"></view>
<view class="detail__lock-card">
<text class="detail__lock-title">完整信息已保护</text>
<text class="detail__lock-desc">开通会员每日免费浏览,或单条解锁本条信息</text>
<view class="detail__lock-actions">
<view class="detail__lock-btn detail__lock-btn--ghost" @click="goMember">
<text>开通会员</text>
</view>
<view class="detail__lock-btn detail__lock-btn--primary" @click="unlockContent">
<text>{{ payViewPrice > 0 ? `¥${payViewPrice} 解锁本条` : '解锁本条' }}</text>
</view>
</view>
</view>
</view>
<view v-if="post.tags && post.tags.length" class="detail__tags">
<text v-for="t in post.tags" :key="t" class="detail__tag">{{ t }}</text>
</view>
<!-- 地址与地图导航 -->
<view v-if="post.address" class="detail__map" @click="openMap">
<view class="detail__map-bg">
<image class="fc-icon fc-icon--md" :src="iconUrl('home-location')" mode="aspectFit" />
</view>
<view class="detail__map-info">
<text class="detail__map-name">{{ post.address }}</text>
<text class="detail__map-action">{{ hasLocation ? '地图导航' : '复制地址' }}</text>
</view>
<view class="detail__map-go">
<text class="detail__map-go-arrow">›</text>
</view>
</view>
<view class="detail__report" @click="openReport">
<text class="detail__report-text">举报该信息 ›</text>
</view>
</view>
<!-- 同城 VIP 会员特权引导卡片(非会员引导开通,会员展示专属特权) -->
<view v-if="!isMember && !isAudit" class="fc-card detail__vip-card" @click="goMember">
<view class="detail__vip-card-left">
<view class="detail__vip-card-badge">
<image class="fc-icon fc-icon--sm" :src="iconUrl('crown')" mode="aspectFit" />
</view>
<view class="detail__vip-card-info">
<view class="detail__vip-card-title-row">
<text class="detail__vip-card-title">开通同城 VIP 会员</text>
<text class="detail__vip-card-pill">尊享特权</text>
</view>
<text class="detail__vip-card-desc">每日免费浏览 · 无限拨打电话 · 专属尊贵标识</text>
</view>
</view>
<view class="detail__vip-card-btn">
<text class="detail__vip-card-btn-txt">立即开通</text>
<text class="detail__vip-card-btn-arrow">›</text>
</view>
</view>
<view v-else-if="isMember && !isAudit" class="fc-card detail__vip-card detail__vip-card--active" @click="goMember">
<view class="detail__vip-card-left">
<view class="detail__vip-card-badge detail__vip-card-badge--active">
<image class="fc-icon fc-icon--sm" :src="iconUrl('crown')" mode="aspectFit" />
</view>
<view class="detail__vip-card-info">
<view class="detail__vip-card-title-row">
<text class="detail__vip-card-title">VIP 尊享特权生效中</text>
<text class="detail__vip-card-pill detail__vip-card-pill--active">VIP 会员</text>
</view>
<text class="detail__vip-card-desc">您已享今日免费浏览与不限拨打电话特权</text>
</view>
</view>
<view class="detail__vip-card-btn detail__vip-card-btn--active">
<text class="detail__vip-card-btn-txt">会员中心</text>
<text class="detail__vip-card-btn-arrow">›</text>
</view>
</view>
<!-- 联系方式卡已移除:号码不展示,直接通过底部「拨打电话」按钮联系 -->
<!-- 基础档案信息(信息编号与安全提示) -->
<view class="detail__archive">
<text class="detail__archive-id">信息编号:#{{ post.id }}</text>
<text class="detail__archive-safe">提示:交易时请仔细核对,防范网络风险</text>
</view>
<!-- 用户评价(审核模式下隐藏,避免 UGC 判定) -->
<view v-if="!isAudit" class="fc-card">
<view class="detail__cmt-head">
<text class="detail__section-title">用户评价({{ comments.length }})</text>
<text v-if="!isSelf" class="detail__cmt-write" @click="onOpenComment">写评价 ›</text>
</view>
<view v-for="c in comments" :key="c.id" class="detail__cmt">
<view class="detail__cmt-top">
<text class="detail__cmt-user">{{ c.user }}</text>
<view class="detail__cmt-score">
<image
v-for="n in 5"
:key="n"
class="fc-icon fc-icon--xs"
:src="iconUrl(n <= c.score ? 'star' : 'star-o')"
mode="aspectFit"
/>
</view>
</view>
<text class="detail__cmt-content">{{ c.content }}</text>
<view v-if="c.reply" class="detail__cmt-reply-box">
<text class="detail__cmt-reply-tag">发布者回复</text>
<text class="detail__cmt-reply-text">{{ c.reply }}</text>
</view>
<view v-if="isSelf" class="detail__cmt-actions">
<view class="detail__cmt-action-btn" @click="onOpenReply(c)">
<image class="fc-icon fc-icon--xs" :src="iconUrl('edit')" mode="aspectFit" />
<text class="detail__cmt-action-text">{{ c.reply ? '修改回复' : '回复' }}</text>
</view>
</view>
</view>
<text v-if="!comments.length" class="detail__cmt-empty">暂无评价,快来抢沙发~</text>
</view>
</view>
<!-- 底部留白 -->
<view class="detail__bar-space"></view>
<!-- 底部吸底操作栏 -->
<view class="detail__bar-wrapper">
<!-- 拨打额度用尽吸底提示浮条 -->
<view v-if="isCallQuotaOut && !isAudit" class="detail__bar-quota-tip" @click="goMember">
<image class="fc-icon fc-icon--xs" :src="iconUrl('crown')" mode="aspectFit" />
<text class="detail__bar-quota-txt">今日拨打次数已用完,开通 VIP 尊享不限次拨打</text>
<text class="detail__bar-quota-go">去开通 ›</text>
</view>
<view class="detail__bar">
<view class="detail__bar-item" @click="toggleFav">
<image class="fc-icon fc-icon--md" :src="iconUrl(faved ? 'bar-fav-on' : 'bar-fav')" mode="aspectFit" />
<text class="detail__bar-text" :class="{ 'detail__bar-text--active': faved }">{{ faved ? '已收藏' : '收藏' }}</text>
</view>
<!-- 微信原生转发好友按钮 -->
<button class="detail__bar-item detail__bar-item--btn" open-type="share">
<image class="fc-icon fc-icon--md" :src="iconUrl('bar-share')" mode="aspectFit" />
<text class="detail__bar-text">分享</text>
</button>
<!-- 生成海报按钮 -->
<view class="detail__bar-item" @click="openPosterModal">
<image class="fc-icon fc-icon--md" :src="iconUrl('bar-poster')" mode="aspectFit" />
<text class="detail__bar-text">海报</text>
</view>
<view
class="detail__bar-main"
:class="{ 'detail__bar-main--vip': isCallQuotaOut }"
@click="onMainAction"
>
<image v-if="isCallQuotaOut" class="fc-icon fc-icon--xs detail__bar-vip-icon" :src="iconUrl('crown')" mode="aspectFit" />
<text>{{ bottomBarText }}</text>
</view>
</view>
</view>
<!-- 免责声明拨打确认弹窗(校验勾选 → 调用 /post/:id/dial 扣减当日额度 → 调起系统拨号) -->
<fc-call-modal
:show="showCallModal"
:contact-name="post.contactName"
:quota-total="callQuotaTotal"
:quota-left="callQuotaLeft"
:unlimited="callQuotaUnlimited"
:loading="dialSubmitting"
@close="showCallModal = false"
@confirm="confirmDial"
/>
<!-- 评价弹层 -->
<view v-if="showComment && !isAudit" class="detail__mask" @click="showComment = false" @touchmove.stop.prevent>
<view class="detail__sheet" @click.stop>
<text class="detail__sheet-title">发表评价</text>
<view class="detail__stars">
<image
v-for="n in 5"
:key="n"
class="detail__star-img"
:src="iconUrl(n <= score ? 'star' : 'star-o')"
mode="aspectFit"
@click="score = n"
/>
</view>
<textarea v-model="commentText" class="detail__sheet-input" placeholder="输入您的评价…" maxlength="200" />
<view class="detail__sheet-btn" @click="submitComment"><text>提交评价</text></view>
</view>
</view>
<!-- 发布者回复评价弹层 -->
<view v-if="showReplyModal && !isAudit" class="detail__mask" @click="showReplyModal = false" @touchmove.stop.prevent>
<view class="detail__sheet" @click.stop>
<view class="detail__sheet-header">
<text class="detail__sheet-title">回复用户评价</text>
<text class="detail__sheet-close" @click="showReplyModal = false">✕</text>
</view>
<view v-if="activeReplyComment" class="detail__reply-target">
<text class="detail__reply-target-user">@{{ activeReplyComment.user }}:</text>
<text class="detail__reply-target-content">{{ activeReplyComment.content }}</text>
</view>
<textarea
v-model="replyText"
class="detail__sheet-input"
placeholder="输入您的回复内容(200字以内)…"
maxlength="200"
/>
<view
class="detail__sheet-btn"
:class="{ 'detail__sheet-btn--disabled': replySubmitting }"
@click="submitReply"
>
<text>{{ replySubmitting ? '提交中…' : '提交回复' }}</text>
</view>
</view>
</view>
<!-- 完善重构的真实海报弹层 -->
<view v-if="showPoster" class="detail__mask" @click="showPoster = false" @touchmove.stop.prevent>
<view class="detail__poster" @click.stop>
<view class="detail__poster-header">
<text class="detail__poster-top-title">分享海报</text>
<text class="detail__poster-close" @click="showPoster = false">✕</text>
</view>
<!-- 海报正在生成态 -->
<view v-if="posterLoading" class="detail__poster-loading">
<view class="detail__poster-spinner"></view>
<text class="detail__poster-loading-text">海报绘制生成中…</text>
</view>
<!-- 真实海报预览图 -->
<view v-else class="detail__poster-preview">
<image
v-if="posterImagePath"
class="detail__poster-img"
:src="posterImagePath"
mode="widthFix"
@click="previewPoster"
/>
<text class="detail__poster-tip">点击大图可全屏预览,支持长按直接转发</text>
</view>
<!-- 底部快捷分享操作 -->
<view class="detail__poster-actions">
<view class="detail__poster-btn detail__poster-btn--primary" @click="savePoster">
<image class="fc-icon fc-icon--sm" :src="iconUrl('image-white')" mode="aspectFit" />
<text class="detail__poster-btn-text">保存到手机相册</text>
</view>
<button class="detail__poster-btn detail__poster-btn--share" @click="sharePosterImage">
<image class="fc-icon fc-icon--sm" :src="iconUrl('bar-share')" mode="aspectFit" />
<text class="detail__poster-btn-text">发给微信好友</text>
</button>
</view>
</view>
</view>
<!-- 离屏高质量 Canvas(640 x 1000 逻辑分辨率,导出 2x 超清海报) -->
<canvas
canvas-id="postPosterCanvas"
id="postPosterCanvas"
class="detail__poster-canvas"
style="width: 640px; height: 1000px; position: fixed; left: 2000px; top: 0; z-index: -999; opacity: 0; pointer-events: none;"
/>
</view>
</template>
<script>
import postApi from '@/common/api/post.js'
import favoriteApi from '@/common/api/favorite.js'
import reportApi from '@/common/api/report.js'
import distributionApi from '@/common/api/distribution.js'
import { REPORT_REASONS } from '@/common/constants/report.js'
import { iconUrl, categoryIconUrl, loadUiConfig, uiState } from '@/common/store/ui.js'
import { fromNow, assetUrl } from '@/common/utils/format.js'
import { useUserStore } from '@/common/store/index.js'
import { pay } from '@/common/utils/pay.js'
import FcNavbar from '@/components/fc-navbar/fc-navbar.vue'
import FcCallModal from '@/components/fc-call-modal/fc-call-modal.vue'
import QRCode from '@/common/utils/qrcode-generator.js'
export default {
components: { FcNavbar, FcCallModal },
data() {
return {
id: 0,
post: { publisher: {}, tags: [] },
hasPhone: true,
callQuotaTotal: 5,
callQuotaUsed: 0,
callQuotaLeft: 5,
// 完整信息访问状态(付费模式下未解锁仅展示摘要)
contentLocked: false,
unlocked: true,
viewMode: 'free',
payViewPrice: 0,
isMember: false,
viewQuotaTotal: 0,
viewQuotaLeft: 0,
viewQuotaUnlimited: false,
showCallModal: false,
dialSubmitting: false,
unlockSubmitting: false,
comments: [],
followed: false,
faved: false,
favoriteCount: 0,
reportReasons: REPORT_REASONS,
showComment: false,
showReplyModal: false,
activeReplyComment: null,
replyText: '',
replySubmitting: false,
showPoster: false,
score: 5,
commentText: '',
activeSwiperIndex: 0,
// 数据加载中与首次加载完成标识(用于骨架屏与静默刷新判定)
loading: true,
loaded: false,
// 海报生成状态与临时图片路径
posterLoading: false,
posterImagePath: '',
}
},
computed: {
/**
* 相对发布时间
*/
/**
* 是否为商家发布的信息
* @returns {boolean}
*/
isMerchantPost() {
return !!(
this.post.identityType === 'merchant' ||
(this.post.merchant && this.post.merchant.name) ||
(this.post.publisher && this.post.publisher.isMerchant)
)
},
/**
* 商家门店详细信息
* @returns {Object|null}
*/
merchantInfo() {
if (this.post.merchant && typeof this.post.merchant === 'object') {
return this.post.merchant
}
if (this.post.publisher && this.post.publisher.isMerchant) {
return {
name: this.post.publisher.name || '同城认证商户',
logo: this.post.publisher.avatar || '',
address: this.post.address || '',
lat: this.post.lat,
lng: this.post.lng,
verified: true,
}
}
return null
},
/**
* 门店实体地址
* @returns {string}
*/
merchantAddress() {
return (this.merchantInfo && this.merchantInfo.address) || this.post.address || ''
},
/**
* 门店是否具备坐标
* @returns {boolean}
*/
hasMerchantCoord() {
const m = this.merchantInfo
if (m && m.lat != null && m.lng != null && Number(m.lat) !== 0 && Number(m.lng) !== 0) {
return true
}
return this.hasLocation
},
/**
* 今日拨打额度是否不限次数(后台配置 0 = 不限)
* @returns {boolean}
*/
callQuotaUnlimited() {
return Number(this.callQuotaTotal) === 0
},
/**
* 今日拨打额度是否已用完(非不限次数且总额度>0,剩余额度<=0)
* @returns {boolean}
*/
isCallQuotaOut() {
if (this.callQuotaUnlimited) return false
return Number(this.callQuotaTotal) > 0 && Number(this.callQuotaLeft) <= 0
},
/**
* 底部主操作栏文案
* @returns {string}
*/
bottomBarText() {
if (this.loading && !this.loaded) return '加载中…'
if (this.isCallQuotaOut) {
return this.isAudit ? '今日拨打已达上限' : '开通会员立即拨打'
}
return '拨打电话'
},
isAudit() {
// #ifdef MP-WEIXIN
return uiState.virtualPayAudit !== false
// #endif
return false
},
timeText() {
return fromNow(this.post.createdAt)
},
/** 是否填写了产地规格(种植面积/采摘时间/预计量产任一非空即展示规格块) */
hasFarmSpecs() {
return !!(this.post.plantArea || this.post.harvestTime || this.post.outputRange)
},
/**
* 媒体列表图片
*/
imageUrls() {
const list = []
if (this.post.media && Array.isArray(this.post.media)) {
this.post.media.forEach((m) => {
if (typeof m === 'string' && !m.match(/\.(mp4|mov|avi)$/i)) {
list.push(assetUrl(m))
}
})
}
if (!list.length && this.post.cover) {
list.push(assetUrl(this.post.cover))
}
return list
},
/**
* 是否有坐标
*/
hasLocation() {
return this.post.lat != null && this.post.lng != null
},
/**
* 当前登录用户
*/
currentUser() {
try {
const store = useUserStore()
const user = (store.userInfo && store.userInfo.value) || store.userInfo || (store.state && store.state.userInfo)
return user || null
} catch (e) {
return null
}
},
/**
* 是否为本人发布
*/
isSelf() {
try {
const user = this.currentUser
const curId = user && (user.id || user.userId)
const pubId = (this.post && this.post.publisher && this.post.publisher.id) || (this.post && (this.post.userId || this.post.user_id))
return !!(curId && pubId && Number(curId) === Number(pubId))
} catch (e) {
return false
}
},
},
/**
* 页面加载:提取帖子ID与分销裂变参数
* @param {Object} query 路由入参
*/
onLoad(query) {
let postIdFromScene = 0
if (query) {
const ic = query.ic || query.inviteCode || query.invite_code
let inviterId = query.inviterId || query.inviter_id
if (query.scene) {
try {
const s = decodeURIComponent(query.scene)
const m = s.match(/^id_(\d+)(?:_u_(\d+))?$/)
if (m) {
postIdFromScene = Number(m[1] || 0)
if (m[2]) inviterId = m[2]
}
} catch (e) {}
}
if (!uni.getStorageSync('fc_token')) {
if (ic) uni.setStorageSync('fc_invite_code', String(ic))
if (inviterId) uni.setStorageSync('fc_inviter_id', Number(inviterId))
}
}
this.id = Number((query && query.id) || postIdFromScene || 101)
this.posterImagePath = ''
loadUiConfig()
this.fetch()
},
onShow() {
if (this.loaded) this.refreshAccess()
},
/**
* 转发给微信好友(携带分销邀请码/邀请人与精美封面图)
*/
onShareAppMessage() {
let inviterId = 0
let inviteCode = ''
try {
const user = this.currentUser
if (user && user.id) inviterId = user.id
if (user && user.inviteCode) inviteCode = user.inviteCode
} catch (e) {}
const shareTitle = this.post.title
? `【${this.post.categoryName || '同城信息'}】${this.post.title}`
: (uiState.shareTitle || uiState.siteName || '同城便民信息')
const sharePath = `/pages/detail/detail?id=${this.id}${inviteCode ? '&ic=' + encodeURIComponent(inviteCode) : ''}${inviterId ? '&inviterId=' + inviterId : ''}`
const shareImg = this.posterImagePath || (this.imageUrls && this.imageUrls.length ? this.imageUrls[0] : (this.post.cover ? assetUrl(this.post.cover) : ''))
return {
title: shareTitle,
path: sharePath,
imageUrl: shareImg || undefined,
}
},
/**
* 转发到微信朋友圈(携带分销邀请码/邀请人)
*/
onShareTimeline() {
let inviterId = 0
let inviteCode = ''
try {
const user = this.currentUser
if (user && user.id) inviterId = user.id
if (user && user.inviteCode) inviteCode = user.inviteCode
} catch (e) {}
const shareTitle = this.post.title
? `【${this.post.categoryName || '同城信息'}】${this.post.title}`
: (uiState.shareTitle || uiState.siteName || '同城便民信息')
const shareImg = this.posterImagePath || (this.imageUrls && this.imageUrls.length ? this.imageUrls[0] : (this.post.cover ? assetUrl(this.post.cover) : ''))
return {
title: shareTitle,
query: `id=${this.id}${inviteCode ? '&ic=' + encodeURIComponent(inviteCode) : ''}${inviterId ? '&inviterId=' + inviterId : ''}`,
imageUrl: shareImg || undefined,
}
},
methods: {
iconUrl,
assetUrl,
/**
* 获取分类插画
*/
catIcon() {
return categoryIconUrl(this.post.categoryId)
},
/**
* 轮播切换同步
* @param {Object} e 事件
*/
onSwiperChange(e) {
this.activeSwiperIndex = (e && e.detail && e.detail.current) || 0
},
/**
* 全屏图片预览
* @param {Number} idx 索引
*/
previewImages(idx) {
if (!this.imageUrls || !this.imageUrls.length) return
uni.previewImage({
current: idx,
urls: this.imageUrls,
})
},
/**
* 拉取详情数据(首次激活骨架屏态)
*/
async fetch() {
this.loading = true
try {
const res = await postApi.detail(this.id)
this.post = res
try {
const cs = await postApi.comments(this.id)
this.comments = Array.isArray(cs) ? cs : []
} catch (e) {
this.comments = []
}
this.applyAccess(res)
this.loaded = true
this.faved = !!res.faved
this.followed = !!res.followed
this.favoriteCount = Number(res.favoriteCount || 0)
if (this.isLogin()) await this.loadInteractStatus()
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
this.loading = false
}
},
/**
* 检查登录状态
*/
isLogin() {
try {
return !!useUserStore().isLogin.value
} catch (e) {
return false
}
},
/**
* 登录拦截引导
*/
ensureLogin() {
if (this.isLogin()) return true
uni.showModal({
title: '提示',
content: '该功能需要先登录账号,是否前往?',
confirmText: '去登录',
cancelText: '取消',
confirmColor: '#00E070',
success: (r) => {
if (r.confirm) {
uni.navigateTo({ url: '/pages/auth/login' })
}
},
})
return false
},
/**
* 回显访问状态(拨打额度 + 完整信息锁定态;手机号任何模式不返回)
* @param {Object} res 接口返回数据
*/
applyAccess(res) {
if (!res) return
if (res.hasPhone != null) this.hasPhone = !!res.hasPhone
if (res.callQuotaTotal != null) this.callQuotaTotal = Number(res.callQuotaTotal)
if (res.callQuotaUsed != null) this.callQuotaUsed = Number(res.callQuotaUsed)
if (res.callQuotaLeft != null) this.callQuotaLeft = Number(res.callQuotaLeft)
if (this.isAudit) {
// 微信提审合规保护:强制全量展示完整信息,不锁死内容、不引导付费解锁
this.contentLocked = false
this.unlocked = true
} else {
if (res.contentLocked != null) this.contentLocked = !!res.contentLocked
if (res.unlocked != null) this.unlocked = !!res.unlocked
}
if (res.viewMode != null) this.viewMode = String(res.viewMode)
if (res.payViewPrice != null) this.payViewPrice = Number(res.payViewPrice) || 0
if (res.isMember != null) this.isMember = !!res.isMember
if (res.viewQuotaTotal != null) this.viewQuotaTotal = Number(res.viewQuotaTotal) || 0
if (res.viewQuotaLeft != null) this.viewQuotaLeft = Number(res.viewQuotaLeft) || 0
if (res.viewQuotaUnlimited != null) this.viewQuotaUnlimited = !!res.viewQuotaUnlimited
},
/**
* 页面回显静默刷新拨打额度
*/
async refreshAccess() {
try {
const res = await postApi.detail(this.id, { accessOnly: 1 })
this.applyAccess(res)
} catch (e) {}
},
/**
* 加载互动状态
*/
async loadInteractStatus() {
const publisherId = this.post.publisher && this.post.publisher.id
try {
const res = await favoriteApi.status({ postIds: [this.id], userIds: publisherId ? [publisherId] : [] })
if (res && res.faved) this.faved = !!res.faved[this.id]
if (res && res.followed && publisherId) this.followed = !!res.followed[publisherId]
} catch (e) {}
},
/**
* 底部主按钮响应:拨打电话或引导开通会员
*/
onMainAction() {
if (this.loading && !this.loaded) return
if (!this.hasPhone) {
uni.showToast({ title: '发布者未预留联系电话', icon: 'none' })
return
}
if (!this.ensureLogin()) return
// 当额度用尽时直接引导开通会员,杜绝无效点击
if (this.isCallQuotaOut) {
uni.showModal({
title: '今日拨打次数已用完',
content: this.isMember
? '您今日的 VIP 拨打次数已达上限,明日将自动恢复,或可升级更高规格会员套餐'
: '您今日的免费拨打次数已用完,开通 VIP 会员尊享无限次拨打与每日免费浏览特权',
confirmText: '去开通会员',
cancelText: '知道了',
confirmColor: '#00E070',
success: (r) => {
if (r.confirm) uni.navigateTo({ url: '/pages/member/index' })
},
})
return
}
this.openCallModal()
},
/**
* 打开免责声明拨打弹窗(游客先引导登录;额度用完直接本地拦截,不发无效请求)
*/
openCallModal() {
if (!this.hasPhone) {
uni.showToast({ title: '发布者未预留联系电话', icon: 'none' })
return
}
if (!this.ensureLogin()) return
// 前置额度拦截:今日拨打次数用完时直接引导,不再等后端 403
if (!this.callQuotaUnlimited && this.callQuotaLeft <= 0) {
uni.showModal({
title: '今日拨打次数已用完',
content: this.isMember
? '您今日的拨打次数已达上限,明日再来或升级更高套餐'
: '开通会员可享更多每日拨打次数与免费浏览特权',
confirmText: '去开通会员',
cancelText: '知道了',
confirmColor: '#00E070',
success: (r) => {
if (r.confirm) uni.navigateTo({ url: '/pages/member/index' })
},
})
return
}
this.showCallModal = true
},
/**
* 解锁完整信息:选择支付方式 → POST /post/:id/pay-view → 成功后刷新详情
*/
unlockContent() {
if (!this.ensureLogin()) return
if (this.unlockSubmitting) return
uni.showActionSheet({
itemList: [`余额支付(¥${this.payViewPrice})`, '微信支付'],
success: (r) => {
const payType = r.tapIndex === 0 ? 'balance' : 'wxpay'
this.doUnlock(payType)
},
})
},
/**
* 执行单条解锁支付
* @param {string} payType balance | wxpay
*/
async doUnlock(payType) {
this.unlockSubmitting = true
uni.showLoading({ title: '处理中…' })
try {
const res = await postApi.payView(this.id, { payType })
uni.hideLoading()
if (res && res.unlocked) {
this.contentLocked = false
this.unlocked = true
uni.showToast({ title: '解锁成功', icon: 'success' })
await this.fetch()
return
}
// 微信支付:需要调起收银台
if (res && res.order) {
try {
await pay(res.order)
this.contentLocked = false
this.unlocked = true
uni.showToast({ title: '解锁成功', icon: 'success' })
await this.fetch()
} catch (payErr) {
uni.showToast({ title: (payErr && payErr.message) || '支付未完成', icon: 'none' })
}
}
} catch (e) {
uni.hideLoading()
uni.showToast({ title: (e && e.message) || '解锁失败,请稍后重试', icon: 'none' })
} finally {
this.unlockSubmitting = false
}
},
/**
* 跳转会员中心
*/
goMember() {
uni.navigateTo({ url: '/pages/member/index' })
},
/**
* 确认拨打:调用 POST /post/:id/dial 扣减当日额度并调起系统拨号盘
*/
async confirmDial() {
if (this.dialSubmitting) return
this.dialSubmitting = true
try {
const res = await postApi.dial(this.id)
this.showCallModal = false
if (res && res.callQuotaUsed != null) this.callQuotaUsed = Number(res.callQuotaUsed)
if (res && res.callQuotaLeft != null) this.callQuotaLeft = Number(res.callQuotaLeft)
if (res && res.phoneNumber) {
uni.makePhoneCall({
phoneNumber: String(res.phoneNumber),
fail: () => {},
})
}
} catch (e) {
const msg = (e && e.message) || '拨打失败,请稍后重试'
// 当日拨打次数超限(后端 403):同步本地额度并引导开通 VIP 会员提升每日拨打次数
if (/上限|额度|次数/.test(msg)) {
this.callQuotaLeft = 0
this.showCallModal = false
uni.showModal({
title: '今日拨打次数已用完',
content: msg,
confirmText: '去开通会员',
cancelText: '知道了',
confirmColor: '#00E070',
success: (r) => {
if (r.confirm) uni.navigateTo({ url: '/pages/member/index' })
},
})
return
}
uni.showToast({ title: msg, icon: 'none' })
} finally {
this.dialSubmitting = false
}
},
/**
* 写评价点击拦截(禁止发布者自评)
*/
onOpenComment() {
if (this.isAudit) return
if (!this.ensureLogin()) return
if (this.isSelf) {
uni.showToast({ title: '不能评价自己发布的信息', icon: 'none' })
return
}
this.showComment = true
},
/**
* 打开回复评价弹窗(仅发布者可操作)
* @param {Object} comment 被回复的评价对象
*/
onOpenReply(comment) {
if (!this.ensureLogin()) return
if (!this.isSelf) {
uni.showToast({ title: '只有发布者可以回复评价', icon: 'none' })
return
}
this.activeReplyComment = comment
this.replyText = (comment && comment.reply) || ''
this.showReplyModal = true
},
/**
* 提交发布者回复
*/
async submitReply() {
if (!this.replyText.trim()) {
uni.showToast({ title: '请输入回复内容', icon: 'none' })
return
}
if (!this.activeReplyComment || !this.activeReplyComment.id) {
uni.showToast({ title: '评价信息失效,请重试', icon: 'none' })
return
}
if (this.replySubmitting) return
this.replySubmitting = true
try {
const res = await postApi.replyComment(this.activeReplyComment.id, {
reply: this.replyText.trim()
})
// 同步更新页面中的评价回复
if (this.activeReplyComment) {
this.activeReplyComment.reply = (res && res.reply) ? res.reply : this.replyText.trim()
}
this.showReplyModal = false
this.replyText = ''
this.activeReplyComment = null
uni.showToast({ title: '回复成功', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message || '回复失败,请重试', icon: 'none' })
} finally {
this.replySubmitting = false
}
},
/**
* 提交评价
*/
async submitComment() {
if (!this.commentText.trim()) {
uni.showToast({ title: '请输入评价内容', icon: 'none' })
return
}
try {
await postApi.comment(this.id, { score: this.score, content: this.commentText })
this.commentText = ''
try {
const cs = await postApi.comments(this.id)
this.comments = Array.isArray(cs) ? cs : this.comments
} catch (e) {}
this.showComment = false
uni.showToast({ title: '评价成功', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message || '评价失败', icon: 'none' })
}
},
/**
* 关注发布者
*/
async toggleFollow() {
if (!this.ensureLogin()) return
const publisherId = this.post.publisher && this.post.publisher.id
if (!publisherId) return uni.showToast({ title: '发布者信息缺失', icon: 'none' })
const prev = this.followed
this.followed = !prev
try {
const res = await favoriteApi.follow({ userId: publisherId })
this.followed = !!(res && res.followed)
uni.showToast({ title: this.followed ? '已关注' : '已取消关注', icon: 'none' })
} catch (e) {
this.followed = prev
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
},
/**
* 收藏信息
*/
async toggleFav() {
if (!this.ensureLogin()) return
const prev = this.faved
this.faved = !prev
try {
const res = await favoriteApi.toggle({ postId: this.id })
this.faved = !!(res && res.faved)
if (res && typeof res.count === 'number') this.favoriteCount = res.count
uni.showToast({ title: this.faved ? '已收藏' : '已取消收藏', icon: 'none' })
} catch (e) {
this.faved = prev
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
}
},
/**
* 举报
*/
openReport() {
if (!this.ensureLogin()) return
uni.showActionSheet({
itemList: this.reportReasons,
success: async (r) => {
const reason = this.reportReasons[r.tapIndex]
if (!reason) return
try {
await reportApi.create({ targetType: 'post', targetId: this.id, reason })
uni.showToast({ title: '举报已提交核实', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message || '提交失败', icon: 'none' })
}
},
})
},
/**
* 跳转至个人用户公开主页
*/
goUserDetail() {
const uid = (this.post.publisher && this.post.publisher.id) || this.post.userId || this.post.user_id
if (!uid) return
uni.navigateTo({
url: '/pages/user/detail?id=' + uid,
})
},
/**
* 跳转至商家店铺详情主页
*/
goShopDetail() {
const m = this.merchantInfo
const shopId = (m && (m.id || m.merchantId)) || (this.post.publisher && this.post.publisher.id) || (this.post.userId || this.post.user_id)
if (!shopId) {
uni.showToast({ title: '暂未获取到店铺信息', icon: 'none' })
return
}
uni.navigateTo({
url: `/pages/merchant/detail?id=${shopId}&postId=${this.id}`,
fail: (err) => {
console.warn('跳转商家详情失败:', err)
}
})
},
/**
* 导航前往商家门店或复制实体地址
*/
openMerchantLocation() {
const m = this.merchantInfo
const addr = this.merchantAddress
const lat = m && m.lat != null ? Number(m.lat) : (this.post.lat != null ? Number(this.post.lat) : null)
const lng = m && m.lng != null ? Number(m.lng) : (this.post.lng != null ? Number(this.post.lng) : null)
const name = (m && m.name) || this.post.title || '商家门店'
if (lat != null && lng != null && lat !== 0 && lng !== 0) {
uni.openLocation({
latitude: lat,
longitude: lng,
name: name,
address: addr || '商家实体门店',
fail: () => {
if (addr) {
uni.setClipboardData({
data: `${addr}`.trim(),
success: () => uni.showToast({ title: '门店地址已复制', icon: 'none' })
})
}
}
})
} else if (addr) {
uni.setClipboardData({
data: `${addr}`.trim(),
success: () => uni.showToast({ title: '门店地址已复制', icon: 'none' })
})
}
},
openMap() {
if (!this.hasLocation) {
uni.setClipboardData({
data: `${this.post.address || ''} ${this.post.area || ''}`.trim(),
success: () => uni.showToast({ title: '地址已复制', icon: 'none' }),
})
return
}
uni.openLocation({
latitude: Number(this.post.lat),
longitude: Number(this.post.lng),
name: this.post.address || this.post.title,
address: this.post.area || '',
fail: () => uni.showToast({ title: '请在微信内打开导航', icon: 'none' }),
})
},
/**
* 打开海报弹窗并按需异步生成海报
*/
openPosterModal() {
this.showPoster = true
if (!this.posterImagePath) {
this.generatePoster()
}
},
/**
* 辅助函数:异步获取图片本地可用路径(网络图片自动缓存到临时路径)
* @param {String} src 图片 URL
* @returns {Promise<String>}
*/
getImageLocalPath(src) {
return new Promise((resolve) => {
if (!src) return resolve('')
if (src.startsWith('/static/') || src.startsWith('static/')) {
return resolve(src.startsWith('/') ? src : '/' + src)
}
uni.getImageInfo({
src: src,
success: (res) => resolve(res.path || ''),
fail: () => resolve(''),
})
})
},
/**
* 辅助函数:绘制规范圆角矩形路径与填边
* @param {Object} ctx Canvas 上下文
* @param {Number} x 起始 X 坐标
* @param {Number} y 起始 Y 坐标
* @param {Number} width 矩形宽度
* @param {Number} height 矩形高度
* @param {Number} radius 圆角半径
* @param {String|CanvasGradient} fillColor 填充颜色
* @param {String} strokeColor 描边颜色
* @param {Number} lineWidth 描边线宽
*/
drawRoundRect(ctx, x, y, width, height, radius, fillColor, strokeColor, lineWidth = 1) {
ctx.save()
ctx.beginPath()
ctx.moveTo(x + radius, y)
ctx.lineTo(x + width - radius, y)
ctx.arc(x + width - radius, y + radius, radius, 1.5 * Math.PI, 2 * Math.PI)
ctx.lineTo(x + width, y + height - radius)
ctx.arc(x + width - radius, y + height - radius, radius, 0, 0.5 * Math.PI)
ctx.lineTo(x + radius, y + height)
ctx.arc(x + radius, y + height - radius, radius, 0.5 * Math.PI, Math.PI)
ctx.lineTo(x, y + radius)
ctx.arc(x + radius, y + radius, radius, Math.PI, 1.5 * Math.PI)
ctx.closePath()
if (fillColor) {
ctx.setFillStyle(fillColor)
ctx.fill()
}
if (strokeColor) {
ctx.setStrokeStyle(strokeColor)
ctx.setLineWidth(lineWidth)
ctx.stroke()
}
ctx.restore()
},
/**
* 辅助函数:计算单字符视觉预估宽度(规避小程序 CanvasContext measureText 仅按 10px 测算的平台级缺陷)
* @param {String} char 单个字符
* @param {Number} fontSize 字体大小(px)
* @returns {Number} 预估像素宽度
*/
calcCharWidth(char, fontSize) {
const code = char.charCodeAt(0)
if (code > 255) return fontSize // 中文汉字、全角标点符号
if (code >= 65 && code <= 90) return fontSize * 0.72 // 大写英文字母
if (code === 32) return fontSize * 0.32 // 空格
return fontSize * 0.56 // 小写字母、半角数字、半角标点
},
/**
* 辅助函数:计算整段文字的真实渲染宽度
* @param {String} text 文字内容
* @param {Number} fontSize 字体大小(px)
* @returns {Number} 总像素宽度
*/
calcTextWidth(text, fontSize) {
if (!text) return 0
const str = String(text)
let total = 0
for (let i = 0; i < str.length; i++) {
total += this.calcCharWidth(str[i], fontSize)
}
return total
},
/**
* 辅助函数:安全绘制单行文本(显式设定对齐状态,彻底杜绝全局状态污染导致错乱)
* @param {Object} ctx Canvas 上下文
* @param {String} text 文本内容
* @param {Number} x 绘制起点 X
* @param {Number} y 绘制起点 Y
* @param {Object} options 样式参数配置
*/
drawSafeText(ctx, text, x, y, options = {}) {
if (text === undefined || text === null || text === '') return
const {
fontSize = 20,
color = '#1E293B',
align = 'left',
baseline = 'top',
} = options
ctx.save()
ctx.setFontSize(fontSize)
ctx.setFillStyle(color)
ctx.setTextAlign(align)
ctx.setTextBaseline(baseline)
ctx.fillText(String(text), x, y)
ctx.restore()
},
/**
* 辅助函数:自适应多行文本换行绘制并带省略号(返回绘制结束后的下一行起始 Y,实现自适应流式排版)
* @param {Object} ctx Canvas 上下文
* @param {String} text 文本内容
* @param {Number} x 起始 X
* @param {Number} y 起始 Y
* @param {Number} maxWidth 最大宽度
* @param {Number} lineHeight 每行高度
* @param {Number} maxLines 最大行数限制
* @param {Object} options 字体与颜色参数
* @returns {Number} 实际占用的 Y 轴高度终点
*/
drawTextFlow(ctx, text, x, y, maxWidth, lineHeight, maxLines = 2, options = {}) {
if (!text) return y
const {
fontSize = 24,
color = '#334155',
} = options
const chars = String(text).split('')
let line = ''
let lineW = 0
let lineCount = 0
let curY = y
for (let i = 0; i < chars.length; i++) {
const char = chars[i]
const charW = this.calcCharWidth(char, fontSize)
if (lineW + charW > maxWidth && line.length > 0) {
lineCount++
if (lineCount >= maxLines) {
let truncated = line
while (this.calcTextWidth(truncated + '…', fontSize) > maxWidth && truncated.length > 0) {
truncated = truncated.slice(0, -1)
}
this.drawSafeText(ctx, truncated + '…', x, curY, { fontSize, color, align: 'left', baseline: 'top' })
return curY + lineHeight
}
this.drawSafeText(ctx, line, x, curY, { fontSize, color, align: 'left', baseline: 'top' })
line = char
lineW = charW
curY += lineHeight
} else {
line += char
lineW += charW
}
}
if (line) {
this.drawSafeText(ctx, line, x, curY, { fontSize, color, align: 'left', baseline: 'top' })
curY += lineHeight
}
return curY
},
/**
* 辅助函数:绘制高兼容精致虚线
* @param {Object} ctx Canvas 上下文
* @param {Number} x1 起始 X
* @param {Number} y1 起始 Y
* @param {Number} x2 结束 X
* @param {Number} y2 结束 Y
* @param {Number} dashLen 虚线段长度
* @param {Number} gapLen 虚线间隔
* @param {String} color 虚线颜色
*/
drawDashedLine(ctx, x1, y1, x2, y2, dashLen = 6, gapLen = 4, color = '#E2E8F0') {
ctx.save()
ctx.setStrokeStyle(color)
ctx.setLineWidth(1)
let curX = x1
while (curX < x2) {
ctx.beginPath()
ctx.moveTo(curX, y1)
ctx.lineTo(Math.min(curX + dashLen, x2), y1)
ctx.stroke()
curX += dashLen + gapLen
}
ctx.restore()
},
/**
* 辅助函数:在 Canvas 上绘制高识别度合规二维码
* @param {Object} ctx Canvas 上下文
* @param {String} text 二维码目标 URL
* @param {Number} x 起始 X
* @param {Number} y 起始 Y
* @param {Number} size 二维码外框总尺寸
*/
drawRealQRCode(ctx, text, x, y, size) {
try {
const qr = QRCode.create(text, { errorCorrectionLevel: 'M' })
const modules = qr.modules
const count = modules.size
const padding = 8
const qrRealSize = size - padding * 2
const cellSize = qrRealSize / count
// 1. 纯白底色(高对比度保障扫描识别率)
this.drawRoundRect(ctx, x, y, size, size, 10, '#FFFFFF', '#DFF2E8', 1)
// 2. 绘制深墨绿码元(#04301E),符合轻氧浅绿规范且微信识别率 100%
ctx.save()
ctx.setFillStyle('#04301E')
for (let r = 0; r < count; r++) {
for (let c = 0; c < count; c++) {
if (modules.get(r, c) === 1) {
ctx.fillRect(
x + padding + c * cellSize,
y + padding + r * cellSize,
cellSize + 0.35,
cellSize + 0.35
)
}
}
}
ctx.restore()
} catch (e) {
console.error('QR draw error', e)
}
},
/**
* 重构核心:高保真商业级 Canvas 流式自适应绘制同城分享海报
*/
async generatePoster() {
this.posterLoading = true
const W = 640
const H = 1000
const ctx = uni.createCanvasContext('postPosterCanvas', this)
// 1. 底板背景(全局轻氧浅绿 #F2FEF7)
ctx.setFillStyle('#F2FEF7')
ctx.fillRect(0, 0, W, H)
// 2. 主体纯白高光卡片(圆角 24px,微立体边框 #E2F4EB)
this.drawRoundRect(ctx, 32, 32, 576, 936, 24, '#FFFFFF', '#E2F4EB', 1)
// 3. 头部顶栏(品牌胶囊 + 区域胶囊)
const badgeText = '同城生活 · 便民严选'
const badgeW = this.calcTextWidth(badgeText, 18) + 24
this.drawRoundRect(ctx, 56, 56, badgeW, 32, 16, '#E8FAF0', null)
this.drawSafeText(ctx, badgeText, 56 + 12, 56 + 7, {
fontSize: 18,
color: '#00A854',
align: 'left',
baseline: 'top',
})
const cityText = this.post.area ? `贵阳 · ${this.post.area}` : '同城精选'
const cityW = this.calcTextWidth(cityText, 18) + 24
const cityX = 584 - cityW
this.drawRoundRect(ctx, cityX, 56, cityW, 32, 16, '#F1F5F9', null)
this.drawSafeText(ctx, cityText, cityX + 12, 56 + 7, {
fontSize: 18,
color: '#64748B',
align: 'left',
baseline: 'top',
})
// 4. 标题行(字号 30px,高级灰 #1E293B,最多 2 行流式换行)
const titleY = 106
let curY = this.drawTextFlow(ctx, this.post.title || '', 56, titleY, 528, 42, 2, {
fontSize: 30,
color: '#1E293B',
})
curY += 14
// 5. 实拍主图 / 视觉展示区
let coverPath = ''
if (this.imageUrls && this.imageUrls.length) {
coverPath = await this.getImageLocalPath(this.imageUrls[0])
}
const imgW = 528
const imgH = 250
const imgY = curY
if (coverPath) {
// 绘制图片底衬与无破坏性边框(避免 ctx.clip 造成全局裁剪错乱)
this.drawRoundRect(ctx, 56, imgY, imgW, imgH, 14, '#F8FAFC', '#E2E8F0', 1)
ctx.drawImage(coverPath, 56, imgY, imgW, imgH)
this.drawRoundRect(ctx, 56, imgY, imgW, imgH, 14, null, 'rgba(0,0,0,0.06)', 1)
// 大图左下角分类胶囊
const catText = this.post.categoryName || '同城便民'
const catW = this.calcTextWidth(catText, 18) + 22
this.drawRoundRect(ctx, 70, imgY + imgH - 42, catW, 28, 14, 'rgba(4, 48, 30, 0.72)', null)
this.drawSafeText(ctx, catText, 81, imgY + imgH - 37, {
fontSize: 18,
color: '#00E070',
align: 'left',
baseline: 'top',
})
// 大图右上角置顶/推荐标签
if (this.post.isTop || this.post.isPaid) {
const topText = '置顶推荐'
const topW = this.calcTextWidth(topText, 16) + 18
this.drawRoundRect(ctx, 56 + imgW - topW - 12, imgY + 12, topW, 26, 13, '#F5A623', null)
this.drawSafeText(ctx, topText, 56 + imgW - topW - 3, imgY + 17, {
fontSize: 16,
color: '#FFFFFF',
align: 'left',
baseline: 'top',
})
}
curY = imgY + imgH + 18
} else {
// 无图时展示轻氧绿色生态卡片
const emptyH = 120
this.drawRoundRect(ctx, 56, imgY, imgW, emptyH, 14, '#EBF9F2', '#D3EFE1', 1)
this.drawSafeText(ctx, this.post.categoryName || '同城便民服务', 80, imgY + 28, {
fontSize: 28,
color: '#00A854',
align: 'left',
baseline: 'top',
})
this.drawSafeText(ctx, '平台官方审核认证 · 便民服务放心选', 80, imgY + 68, {
fontSize: 20,
color: '#64748B',
align: 'left',
baseline: 'top',
})
curY = imgY + emptyH + 18
}
// 6. 价值展示行(价格 + 浏览量,精准计算宽度零重叠)
const priceY = curY
if (this.post.price > 0) {
// 人民币符号
this.drawSafeText(ctx, '¥', 56, priceY + 12, {
fontSize: 24,
color: '#F5A623',
align: 'left',
baseline: 'top',
})
// 价格数字
const numStr = String(this.post.price)
this.drawSafeText(ctx, numStr, 76, priceY, {
fontSize: 40,
color: '#F5A623',
align: 'left',
baseline: 'top',
})
// 价格单位
if (this.post.priceUnit) {
const numW = this.calcTextWidth(numStr, 40)
this.drawSafeText(ctx, this.post.priceUnit, 82 + numW, priceY + 16, {
fontSize: 20,
color: '#94A3B8',
align: 'left',
baseline: 'top',
})
}
} else {
this.drawSafeText(ctx, this.post.priceUnit || '面议', 56, priceY + 4, {
fontSize: 32,
color: '#00A854',
align: 'left',
baseline: 'top',
})
}
// 右侧关注度与认证(纯文字排版,无 Emoji,符合规范)
const viewsNum = this.post.views || 0
const viewStr = `${viewsNum}人关注 · 平台认证`
const viewW = this.calcTextWidth(viewStr, 19)
this.drawSafeText(ctx, viewStr, 584 - viewW, priceY + 14, {
fontSize: 19,
color: '#64748B',
align: 'left',
baseline: 'top',
})
curY = priceY + 50
// 7. 详细描述摘录(最多 2 行流式换行)
if (this.post.content) {
const afterDescY = this.drawTextFlow(ctx, this.post.content, 56, curY, 528, 30, 2, {
fontSize: 21,
color: '#475569',
})
curY = afterDescY + 12
}
// 8. 特色服务标签(最多 3 个)
if (this.post.tags && this.post.tags.length) {
let tagX = 56
const tags = this.post.tags.slice(0, 3)
for (let t = 0; t < tags.length; t++) {
const tag = tags[t]
const tagW = this.calcTextWidth(tag, 18) + 20
if (tagX + tagW > 584) break
this.drawRoundRect(ctx, tagX, curY, tagW, 28, 6, '#EBF9F2', '#D3EFE1', 1)
this.drawSafeText(ctx, tag, tagX + 10, curY + 5, {
fontSize: 18,
color: '#00A854',
align: 'left',
baseline: 'top',
})
tagX += tagW + 10
}
curY += 36
}
// 地址行(无 Emoji,简洁清晰)
const addrText = this.post.address || this.post.area
if (addrText) {
const fullAddr = `服务地点:${addrText}`
const truncatedAddr = fullAddr.length > 28 ? fullAddr.slice(0, 27) + '…' : fullAddr
this.drawSafeText(ctx, truncatedAddr, 56, curY, {
fontSize: 19,
color: '#64748B',
align: 'left',
baseline: 'top',
})
}
// 9. 底部优雅虚线分割栏(固定停靠在底部 y = 812)
this.drawDashedLine(ctx, 56, 812, 584, 812, 6, 4, '#E2E8F0')
// 10. 底部扫码与转化区(y = 830)
let inviterId = 0
try {
const user = this.currentUser
if (user && user.id) inviterId = user.id
} catch (e) {}
const qrUrl = `https://fenleixinxi.gzyakj.com.cn/pages/detail/detail?id=${this.id}${inviterId ? '&inviterId=' + inviterId : ''}`
// 优先拉取并绘制微信官方小程序码,未发布或受限时无缝回退至自绘二维码(124 x 124)
let wxacodeLocal = ''
if (this.currentUser) {
try {
const posterMeta = await distributionApi.poster({ postId: this.id })
if (posterMeta && posterMeta.wxacodeUrl) {
wxacodeLocal = await this.getImageLocalPath(assetUrl(posterMeta.wxacodeUrl))
}
} catch (e) {}
}
if (wxacodeLocal) {
this.drawRoundRect(ctx, 56, 830, 124, 124, 10, '#FFFFFF', '#DFF2E8', 1)
ctx.drawImage(wxacodeLocal, 60, 834, 116, 116)
} else {
this.drawRealQRCode(ctx, qrUrl, 56, 830, 124)
}
// 右侧指引文案(显式左对齐,彻底杜绝对齐错乱)
this.drawSafeText(ctx, '微信长按识别小程序', 198, 842, {
fontSize: 25,
color: '#1E293B',
align: 'left',
baseline: 'top',
})
this.drawSafeText(ctx, '联系发布者 · 查看完整信息', 198, 880, {
fontSize: 20,
color: '#00A854',
align: 'left',
baseline: 'top',
})
this.drawSafeText(ctx, '同城生活分类信息 · 官方严选保障', 198, 916, {
fontSize: 17,
color: '#94A3B8',
align: 'left',
baseline: 'top',
})
// 11. 最终导出超清海报(2x Retina 分辨率:1280 x 2000)
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath(
{
canvasId: 'postPosterCanvas',
width: W,
height: H,
destWidth: W * 2,
destHeight: H * 2,
fileType: 'png',
quality: 1,
success: (res) => {
this.posterImagePath = res.tempFilePath
this.posterLoading = false
},
fail: (err) => {
console.error('canvasToTempFilePath error', err)
this.posterLoading = false
uni.showToast({ title: '海报绘制失败,请重试', icon: 'none' })
},
},
this
)
}, 300)
})
},
/**
* 唤起微信原生图片分享面板(一键发给好友/发朋友圈/收藏),不支持时回退全屏预览长按转发
*/
sharePosterImage() {
if (!this.posterImagePath) {
uni.showToast({ title: '海报准备中,请稍候', icon: 'none' })
return
}
// #ifdef MP-WEIXIN
if (typeof wx !== 'undefined' && typeof wx.showShareImageMenu === 'function') {
wx.showShareImageMenu({
path: this.posterImagePath,
fail: (err) => {
if (err && err.errMsg && !err.errMsg.includes('cancel')) {
this.previewPoster()
}
},
})
return
}
// #endif
this.previewPoster()
},
/**
* 预览海报大图
*/
previewPoster() {
if (!this.posterImagePath) return
uni.previewImage({
urls: [this.posterImagePath],
current: this.posterImagePath,
})
},
/**
* 保存海报到手机相册
*/
savePoster() {
if (!this.posterImagePath) {
uni.showToast({ title: '海报准备中,请稍候', icon: 'none' })
return
}
// #ifdef H5
uni.showToast({ title: '请长按上方海报保存图片', icon: 'none' })
// #endif
// #ifndef H5
uni.saveImageToPhotosAlbum({
filePath: this.posterImagePath,
success: () => {
uni.showToast({ title: '已保存到系统相册', icon: 'success' })
},
fail: (err) => {
if (err && err.errMsg && (err.errMsg.includes('auth') || err.errMsg.includes('deny'))) {
uni.showModal({
title: '授权提示',
content: '需要您的相册权限以保存海报,是否前往开启?',
confirmText: '去设置',
confirmColor: '#00E070',
success: (res) => {
if (res.confirm) uni.openSetting()
},
})
} else {
uni.showToast({ title: '保存失败,可点击大图长按保存', icon: 'none' })
}
},
})
// #endif
},
/**
* 一键复制海报文案
*/
copyPosterText() {
const addr = this.post.address || this.post.area || '同城'
const priceText = this.post.price > 0 ? ('¥' + this.post.price + (this.post.priceUnit ? this.post.priceUnit : '')) : (this.post.priceUnit || '面议')
const text = `【${this.post.categoryName || '同城信息'}】${this.post.title}\n价格:${priceText}\n地点:${addr}\n长按识别海报或在同城便民平台查看详情!`
uni.setClipboardData({
data: text,
success: () => {
uni.showToast({ title: '文案已复制', icon: 'none' })
},
})
},
},
}
</script>
<style lang="scss" scoped>
.detail {
min-height: 100vh;
padding-bottom: calc(180rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
/* 骨架屏结构(淡入微动画) */
.detail__skeleton-wrap {
animation: fadeIn 0.25s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0.6;
}
to {
opacity: 1;
}
}
.detail__content-wrap {
animation: fadeIn 0.25s ease-out;
}
.detail__skeleton-gallery {
width: 100%;
height: 440rpx;
border-radius: 0 0 $fc-radius-lg $fc-radius-lg;
}
.detail__skeleton-card {
padding: 28rpx 28rpx 32rpx;
margin-top: 20rpx;
}
.detail__skeleton-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__skeleton-col {
display: flex;
flex-direction: column;
}
.detail__skeleton-avatar {
width: 84rpx;
height: 84rpx;
border-radius: 50%;
}
.detail__skeleton-pill {
border-radius: $fc-radius-pill;
}
.detail__skeleton-block {
border-radius: $fc-radius;
width: 100%;
}
.detail__skeleton-line {
border-radius: 8rpx;
}
/* 实拍画廊 */
.detail__gallery {
position: relative;
width: 100%;
height: 480rpx;
overflow: hidden;
background: #000000;
}
.detail__swiper {
width: 100%;
height: 100%;
}
.detail__swiper-img {
width: 100%;
height: 100%;
cursor: pointer;
}
.detail__swiper-badge {
position: absolute;
right: 24rpx;
bottom: 24rpx;
padding: 4rpx 18rpx;
border-radius: 999rpx;
background: rgba(0, 0, 0, 0.55);
color: #ffffff;
font-size: 22rpx;
}
.detail__hero-badges--float {
position: absolute;
left: 24rpx;
bottom: 24rpx;
z-index: 2;
}
/* 无图时的插画封面 */
.detail__hero {
padding: 24rpx 24rpx 0;
}
.detail__hero-thumb {
height: 380rpx;
border-radius: $fc-radius-lg;
background: $fc-gradient-soft;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.detail__hero-img {
width: 220rpx;
height: 220rpx;
}
.detail__hero-badges {
display: flex;
margin-top: 18rpx;
}
.detail__badge {
margin-right: 12rpx;
}
.detail__badge--paid {
background: $fc-gradient-warm;
color: #ffffff;
}
/* 核心信息 */
.detail__head {
margin-top: 20rpx;
}
.detail__title {
font-size: 34rpx;
font-weight: 700;
color: $fc-text;
line-height: 1.4;
word-break: break-all;
}
.detail__price-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-top: 18rpx;
}
.detail__amount {
font-size: 46rpx;
}
.detail__unit {
margin-left: 6rpx;
font-size: $fc-font-xs;
color: $fc-text-secondary;
}
.detail__neg {
font-size: 38rpx;
}
.detail__stats {
display: flex;
font-size: $fc-font-xs;
color: $fc-text-placeholder;
}
.detail__stat-num {
margin-left: 6rpx;
font-size: $fc-font-xs;
color: $fc-text-placeholder;
}
.detail__stat-gap {
margin-left: 18rpx;
}
.detail__meta {
margin-top: 14rpx;
font-size: $fc-font-sm;
color: $fc-text-secondary;
}
/* 商家门店卡片 */
.detail__merchant {
margin-top: 20rpx;
background: #ffffff;
border-radius: $fc-radius-lg;
padding: 24rpx;
border: 1rpx solid #dcf3e5;
box-shadow: 0 4rpx 16rpx rgba(0, 224, 112, 0.05);
transition: all 0.2s ease;
&:active {
transform: scale(0.995);
background: #fbfdfc;
}
}
.detail__merchant-entry {
display: flex;
align-items: center;
gap: 4rpx;
}
.detail__merchant-entry-txt {
font-size: 22rpx;
color: #04874a;
font-weight: 600;
}
.detail__merchant-entry-arrow {
font-size: 24rpx;
color: #04874a;
font-weight: 700;
}
.detail__merchant-badge-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(90deg, #edfbf3 0%, #f6fdf9 100%);
border-radius: 12rpx;
padding: 10rpx 16rpx;
margin-bottom: 20rpx;
border: 1rpx solid #d4f5e2;
}
.detail__merchant-flag {
display: flex;
align-items: center;
gap: 8rpx;
}
.detail__merchant-flag-txt {
font-size: 22rpx;
font-weight: 700;
color: #04874a;
}
.detail__merchant-security {
font-size: 20rpx;
color: #04874a;
}
.detail__merchant-main {
display: flex;
align-items: center;
}
.detail__merchant-logo {
width: 96rpx;
height: 96rpx;
border-radius: 16rpx;
border: 1rpx solid #eef2f0;
flex-shrink: 0;
background: #f8faf8;
}
.detail__merchant-logo-placeholder {
width: 96rpx;
height: 96rpx;
border-radius: 16rpx;
border: 1rpx solid #dcf3e5;
background: #f0fbf5;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail__merchant-meta {
flex: 1;
margin-left: 20rpx;
}
.detail__merchant-name-row {
display: flex;
align-items: center;
}
.detail__merchant-name {
font-size: 32rpx;
font-weight: 700;
color: #0f172a;
line-height: 1.3;
}
.detail__merchant-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10rpx;
margin-top: 10rpx;
}
.detail__merchant-tag-item {
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: 6rpx;
background: #e6f9ef;
color: #04874a;
font-weight: 600;
&--credit {
background: #fef7ec;
color: #d97706;
}
}
.detail__merchant-notice {
display: flex;
align-items: flex-start;
gap: 10rpx;
background: #fbfdfc;
border: 1rpx dashed #d1eedc;
border-radius: 12rpx;
padding: 14rpx 18rpx;
margin-top: 20rpx;
}
.detail__merchant-notice-txt {
flex: 1;
font-size: 24rpx;
color: #334155;
line-height: 1.5;
}
.detail__merchant-address-box {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8faf8;
border-radius: 14rpx;
padding: 16rpx 20rpx;
margin-top: 20rpx;
cursor: pointer;
}
.detail__merchant-address-left {
display: flex;
align-items: center;
gap: 12rpx;
flex: 1;
margin-right: 16rpx;
}
.detail__merchant-address-txt {
font-size: 26rpx;
color: #1e293b;
font-weight: 500;
line-height: 1.4;
}
.detail__merchant-nav-btn {
background: #00e070;
color: #04301e;
font-size: 22rpx;
font-weight: 700;
padding: 8rpx 18rpx;
border-radius: 30rpx;
display: flex;
align-items: center;
gap: 4rpx;
flex-shrink: 0;
}
.detail__merchant-nav-arrow {
font-size: 24rpx;
font-weight: 700;
}
/* 发布者 */
.detail__publisher {
display: flex;
align-items: center;
}
.detail__avatar {
width: 84rpx;
height: 84rpx;
border-radius: 50%;
background: $fc-gradient-warm;
color: #ffffff;
font-size: 34rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--img {
background: #f0f0f0;
}
}
.detail__publisher-info {
flex: 1;
margin-left: 20rpx;
}
.detail__publisher-name-row {
display: flex;
align-items: center;
}
.detail__publisher-name {
font-size: 30rpx;
font-weight: 600;
color: $fc-text;
}
.detail__vip-tag {
margin-left: 12rpx;
background: linear-gradient(135deg, #fffbeb, #fef3c7);
border: 1rpx solid #f59e0b;
color: #b45309;
font-weight: 700;
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: $fc-radius-pill;
}
.detail__verified {
margin-left: 12rpx;
background: #e4fdf0;
border: 1rpx solid rgba(0, 224, 112, 0.4);
color: #04874a;
font-weight: 600;
font-size: 20rpx;
padding: 2rpx 12rpx;
border-radius: $fc-radius-pill;
}
.detail__publisher-sub {
display: block;
margin-top: 6rpx;
font-size: $fc-font-xs;
color: $fc-text-secondary;
}
.detail__follow {
padding: 10rpx 26rpx;
border-radius: $fc-radius-pill;
border: 2rpx solid $fc-primary-text;
color: $fc-primary-text;
font-size: 24rpx;
font-weight: 600;
&--active {
border-color: $fc-border;
color: $fc-text-placeholder;
background: $fc-bg;
}
}
/* 详细内容 */
.detail__section-title {
font-size: 30rpx;
font-weight: 700;
color: $fc-text;
}
/* 产地规格块:浅绿底三列信息小卡,与发布页「价格与产地」分组呼应 */
.detail__specs {
display: flex;
gap: 14rpx;
margin-top: 20rpx;
padding: 20rpx;
background: linear-gradient(135deg, #f0fbf5, #f7fdf9);
border: 1rpx solid rgba(0, 210, 106, 0.16);
border-radius: 18rpx;
}
.detail__spec-item {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 10rpx;
padding: 16rpx 14rpx;
background: #ffffff;
border-radius: 14rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 210, 106, 0.08);
}
.detail__spec-key {
display: flex;
align-items: center;
font-size: 22rpx;
color: $fc-text-secondary;
letter-spacing: 1rpx;
}
.detail__spec-dot {
width: 10rpx;
height: 10rpx;
margin-right: 8rpx;
border-radius: 50%;
background: linear-gradient(135deg, #00d26a, #00e070);
flex-shrink: 0;
}
.detail__spec-val {
font-size: 26rpx;
font-weight: 700;
color: $fc-text;
line-height: 1.4;
word-break: break-all;
}
.detail__content {
display: block;
margin-top: 16rpx;
font-size: $fc-font;
color: $fc-text-regular;
line-height: 1.65;
word-break: break-all;
white-space: pre-wrap;
}
/* 完整信息锁定态:摘要 + 渐隐遮罩 + 解锁引导卡 */
.detail__content-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__lock-flag {
display: inline-flex;
align-items: center;
gap: 6rpx;
font-size: 22rpx;
color: #f5a623;
font-weight: 600;
}
.detail__content--locked {
max-height: 220rpx;
overflow: hidden;
position: relative;
z-index: 1;
}
.detail__lock-box {
position: relative;
margin-top: -120rpx;
z-index: 2;
}
.detail__lock-fade {
height: 140rpx;
background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 0.92) 60%, #ffffff 100%);
}
.detail__lock-card {
margin-top: 8rpx;
background: #f7fdf9;
border: 1rpx solid #d3f3e1;
border-radius: 20rpx;
padding: 28rpx 24rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.detail__lock-title {
font-size: 30rpx;
font-weight: 700;
color: #1e293b;
}
.detail__lock-desc {
margin-top: 8rpx;
font-size: 24rpx;
color: #64748b;
}
.detail__lock-actions {
margin-top: 24rpx;
display: flex;
gap: 20rpx;
width: 100%;
}
.detail__lock-btn {
flex: 1;
height: 76rpx;
border-radius: 38rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 27rpx;
font-weight: 600;
&--ghost {
background: #ffffff;
border: 1rpx solid #00d26a;
color: #00a854;
}
&--primary {
background: linear-gradient(135deg, #00d26a, #00e070);
color: #ffffff;
box-shadow: 0 6rpx 18rpx rgba(0, 210, 106, 0.3);
}
&:active {
transform: scale(0.98);
opacity: 0.92;
}
}
.detail__tags {
display: flex;
flex-wrap: wrap;
margin-top: 20rpx;
}
.detail__tag {
margin: 0 12rpx 12rpx 0;
background: #eafaf1;
color: #00a854;
font-size: 22rpx;
font-weight: 500;
padding: 8rpx 20rpx;
border-radius: 999rpx;
border: 1rpx solid rgba(0, 210, 106, 0.18);
}
.detail__map {
margin-top: 22rpx;
display: flex;
align-items: center;
background: linear-gradient(135deg, #f0fbf5, #f7fdf9);
border: 1rpx solid rgba(0, 210, 106, 0.16);
border-radius: 18rpx;
padding: 20rpx;
transition: transform 0.12s ease;
&:active {
transform: scale(0.985);
opacity: 0.92;
}
}
.detail__map-bg {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.detail__map-info {
flex: 1;
min-width: 0;
margin-left: 20rpx;
display: flex;
flex-direction: column;
}
.detail__map-name {
font-size: $fc-font;
font-weight: 600;
color: $fc-text;
line-height: 1.45;
}
.detail__map-action {
margin-top: 6rpx;
font-size: $fc-font-xs;
color: #00a854;
font-weight: 500;
}
.detail__map-go {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 12rpx 4rpx 12rpx 20rpx;
}
.detail__map-go-arrow {
font-size: 48rpx;
font-weight: 500;
color: #00d26a;
line-height: 1;
margin-top: -6rpx;
}
.detail__report {
margin-top: 16rpx;
display: flex;
justify-content: flex-end;
}
.detail__report-text {
font-size: $fc-font-xs;
color: $fc-text-placeholder;
&:active {
color: $fc-text-secondary;
}
}
/* 档案与编号 */
.detail__archive {
padding: 10rpx 36rpx 20rpx;
display: flex;
flex-direction: column;
}
.detail__archive-id {
font-size: 22rpx;
color: $fc-text-placeholder;
}
.detail__archive-safe {
margin-top: 6rpx;
font-size: 20rpx;
color: $fc-text-placeholder;
}
/* 评价互动 */
.detail__cmt-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__cmt-write {
font-size: $fc-font-sm;
color: $fc-primary-text;
font-weight: 600;
}
.detail__cmt {
margin-top: 20rpx;
padding-top: 18rpx;
border-top: 2rpx solid $fc-border;
}
.detail__cmt-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__cmt-user {
font-size: $fc-font;
font-weight: 600;
color: $fc-text;
}
.detail__cmt-score {
display: flex;
align-items: center;
}
.detail__cmt-content {
display: block;
margin-top: 10rpx;
font-size: $fc-font;
color: $fc-text-regular;
line-height: 1.6;
}
.detail__cmt-reply-box {
margin-top: 14rpx;
padding: 14rpx 18rpx;
border-radius: $fc-radius-sm;
background: #f4faf6;
border-left: 4rpx solid $fc-primary;
}
.detail__cmt-reply-tag {
display: inline-block;
font-size: 20rpx;
font-weight: 700;
color: #04874a;
background: rgba(0, 224, 112, 0.18);
padding: 2rpx 12rpx;
border-radius: 6rpx;
margin-right: 12rpx;
vertical-align: middle;
}
.detail__cmt-reply-text {
font-size: $fc-font-sm;
color: $fc-text-regular;
line-height: 1.5;
vertical-align: middle;
}
.detail__cmt-actions {
display: flex;
justify-content: flex-end;
margin-top: 14rpx;
}
.detail__cmt-action-btn {
display: inline-flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 20rpx;
background: #eefaf2;
border-radius: $fc-radius-pill;
border: 1rpx solid rgba(0, 224, 112, 0.35);
}
.detail__cmt-action-text {
font-size: 24rpx;
color: #04874a;
font-weight: 600;
}
.detail__cmt-empty {
display: block;
margin-top: 20rpx;
text-align: center;
font-size: $fc-font-sm;
color: $fc-text-placeholder;
}
/* 底部操作栏 */
.detail__bar-space {
height: calc(150rpx + env(safe-area-inset-bottom));
}
.detail__bar-wrapper {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
}
.detail__bar-quota-tip {
background: linear-gradient(90deg, #fffbeb, #fef3c7);
border-top: 1rpx solid #fde68a;
padding: 12rpx 28rpx;
display: flex;
align-items: center;
gap: 12rpx;
}
.detail__bar-quota-txt {
flex: 1;
font-size: 22rpx;
color: #92400e;
font-weight: 500;
}
.detail__bar-quota-go {
font-size: 22rpx;
color: #d97706;
font-weight: 700;
flex-shrink: 0;
}
.detail__bar {
position: relative;
display: flex;
align-items: center;
padding: 14rpx 24rpx calc(14rpx + env(safe-area-inset-bottom));
background: #ffffff;
border-top: 2rpx solid rgba(4, 48, 30, 0.06);
box-shadow: 0 -6rpx 24rpx rgba(4, 48, 30, 0.05);
}
.detail__bar-item {
width: 100rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
&--btn {
background: transparent;
border: none;
outline: none;
padding: 0;
margin: 0;
line-height: 1;
&::after {
display: none;
}
}
}
.detail__bar-text {
margin-top: 6rpx;
font-size: 20rpx;
color: $fc-text-secondary;
&--active {
color: #04874a;
font-weight: 700;
}
}
.detail__bar-main {
flex: 1;
height: 84rpx;
margin-left: 12rpx;
border-radius: $fc-radius-pill;
background: $fc-gradient-primary;
color: $fc-primary-ink;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
box-shadow: 0 6rpx 20rpx rgba(0, 224, 112, 0.35);
&--vip {
background: linear-gradient(135deg, #00e070, #00d26a 45%, #f5a623) !important;
color: #04301e !important;
box-shadow: 0 8rpx 24rpx rgba(245, 166, 35, 0.32) !important;
}
}
.detail__bar-vip-icon {
width: 32rpx;
height: 32rpx;
}
/* 详情预览页:同城 VIP 会员特权卡片 */
.detail__vip-card {
margin: 20rpx 24rpx 0;
padding: 24rpx 26rpx;
background: linear-gradient(135deg, #f0fdf4, #ffffff 60%, #fffbf0);
border: 2rpx solid #d4f3e3;
border-radius: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 168, 84, 0.05);
display: flex;
align-items: center;
justify-content: space-between;
&--active {
background: linear-gradient(135deg, #f7fee7, #ffffff 60%, #ecfdf5);
border-color: #bef264;
}
}
.detail__vip-card-left {
display: flex;
align-items: center;
gap: 18rpx;
flex: 1;
min-width: 0;
}
.detail__vip-card-badge {
width: 64rpx;
height: 64rpx;
border-radius: 18rpx;
background: linear-gradient(135deg, #fef3c7, #fffbeb);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 2rpx 8rpx rgba(245, 166, 35, 0.15);
&--active {
background: linear-gradient(135deg, #dcfce7, #f0fdf4);
}
}
.detail__vip-card-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.detail__vip-card-title-row {
display: flex;
align-items: center;
gap: 10rpx;
}
.detail__vip-card-title {
font-size: 28rpx;
font-weight: 700;
color: #1e293b;
}
.detail__vip-card-pill {
padding: 2rpx 12rpx;
border-radius: 999rpx;
background: linear-gradient(135deg, #f5a623, #d97706);
color: #ffffff;
font-size: 20rpx;
font-weight: 700;
&--active {
background: linear-gradient(135deg, #00e070, #00d26a);
}
}
.detail__vip-card-desc {
margin-top: 6rpx;
font-size: 22rpx;
color: #64748b;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail__vip-card-btn {
flex-shrink: 0;
margin-left: 16rpx;
padding: 10rpx 22rpx;
border-radius: 999rpx;
background: #ffffff;
border: 1rpx solid #00a854;
display: flex;
align-items: center;
gap: 4rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 168, 84, 0.08);
&--active {
border-color: #00d26a;
}
}
.detail__vip-card-btn-txt {
font-size: 24rpx;
font-weight: 700;
color: #00a854;
}
.detail__vip-card-btn-arrow {
font-size: 22rpx;
font-weight: 700;
color: #00a854;
}
.detail__vip-card:active {
opacity: 0.88;
}
/* 通用弹层遮罩(评价 / 回复 / 海报等底部弹层共用) */
.detail__mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 200;
background: rgba(4, 48, 30, 0.55);
backdrop-filter: blur(4rpx);
display: flex;
align-items: flex-end;
}
/* 评价输入弹层 */
.detail__sheet {
width: 100%;
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 36rpx 32rpx calc(40rpx + env(safe-area-inset-bottom));
}
.detail__sheet-title {
display: block;
text-align: center;
font-size: 32rpx;
font-weight: 700;
color: $fc-text;
}
.detail__stars {
display: flex;
justify-content: center;
margin: 24rpx 0;
}
.detail__star-img {
width: 52rpx;
height: 52rpx;
margin: 0 10rpx;
}
.detail__sheet-input {
width: 100%;
height: 180rpx;
background: $fc-bg;
border-radius: $fc-radius;
padding: 20rpx 24rpx;
font-size: $fc-font;
box-sizing: border-box;
}
.detail__sheet-btn {
margin-top: 24rpx;
height: 84rpx;
border-radius: $fc-radius-pill;
background: $fc-gradient-primary;
color: $fc-primary-ink;
font-size: 28rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.detail__sheet-btn--disabled {
opacity: 0.6;
pointer-events: none;
}
.detail__sheet-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24rpx;
position: relative;
}
.detail__sheet-close {
font-size: 36rpx;
color: $fc-text-placeholder;
line-height: 1;
padding: 8rpx;
}
.detail__reply-target {
padding: 16rpx 20rpx;
background: $fc-bg;
border-radius: $fc-radius-sm;
margin-bottom: 20rpx;
font-size: $fc-font-sm;
line-height: 1.5;
}
.detail__reply-target-user {
font-weight: 600;
color: #04874a;
}
.detail__reply-target-content {
color: $fc-text-secondary;
}
/* 海报弹层 */
.detail__poster {
width: 100%;
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 32rpx 36rpx calc(40rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.detail__poster-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__poster-top-title {
font-size: 32rpx;
font-weight: 700;
color: $fc-text;
}
.detail__poster-close {
font-size: 36rpx;
color: $fc-text-placeholder;
padding: 8rpx;
}
/* 海报加载动画 */
.detail__poster-loading {
height: 480rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.detail__poster-spinner {
width: 54rpx;
height: 54rpx;
border: 6rpx solid #eefbf4;
border-top-color: #00e070;
border-radius: 50%;
animation: posterSpin 0.8s linear infinite;
}
@keyframes posterSpin {
to {
transform: rotate(360deg);
}
}
.detail__poster-loading-text {
margin-top: 24rpx;
font-size: 26rpx;
color: $fc-text-secondary;
}
/* 海报真实预览图 */
.detail__poster-preview {
margin-top: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.detail__poster-img {
width: 480rpx;
max-height: 780rpx;
border-radius: 20rpx;
box-shadow: 0 12rpx 36rpx rgba(4, 48, 30, 0.16);
}
.detail__poster-tip {
margin-top: 14rpx;
font-size: 22rpx;
color: $fc-text-placeholder;
}
/* 底部操作区 */
.detail__poster-actions {
display: flex;
align-items: center;
margin-top: 28rpx;
}
.detail__poster-btn {
flex: 1;
height: 80rpx;
border-radius: $fc-radius-pill;
display: flex;
align-items: center;
justify-content: center;
font-size: 26rpx;
font-weight: 700;
&--primary {
flex: 1.4;
background: $fc-gradient-primary;
color: $fc-primary-ink;
box-shadow: 0 6rpx 18rpx rgba(0, 224, 112, 0.3);
}
&--share {
margin-left: 14rpx;
background: #eefbf4;
color: #04874a;
border: none;
outline: none;
line-height: 1;
&::after {
display: none;
}
}
&--ghost {
margin-left: 14rpx;
flex: 0.8;
background: $fc-bg;
color: $fc-text;
border: 2rpx solid $fc-border;
}
}
.detail__poster-btn-text {
margin-left: 6rpx;
}
</style>