文件
fenleixinxi/uniapp/pages/detail/detail.vue

2975 行
83 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">面议</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 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" 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) ? '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">
<text class="detail__section-title">详细描述</text>
<text class="detail__content">{{ post.content }}</text>
<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>
<view class="detail__report" @click="openReport">
<text class="detail__report-text">举报该信息 ›</text>
</view>
</view>
<!-- 联系方式(极简清晰:解锁直接展示,未解锁简约引导) -->
<view class="fc-card detail__contact">
<view class="detail__contact-head">
<text class="detail__section-title">联系方式</text>
<text v-if="unlocked" class="detail__contact-badge">已解锁</text>
</view>
<!-- 已解锁态(会员/本人/已付费):清爽展示联系人与电话 -->
<view v-if="unlocked" class="detail__contact-unlocked">
<view v-if="contact.name" class="detail__contact-item">
<text class="detail__contact-key">联系人</text>
<text class="detail__contact-name">{{ contact.name }}</text>
</view>
<view class="detail__contact-item">
<text class="detail__contact-key">联系电话</text>
<text class="detail__contact-phone" @click="callPhone">{{ contact.phone }}</text>
</view>
<view v-if="contact.wechat" class="detail__contact-item">
<text class="detail__contact-key">微信号</text>
<text class="detail__contact-wechat" @click="copyWechat">{{ contact.wechat }}</text>
</view>
<view class="detail__contact-btns">
<view class="detail__btn-primary" @click="callPhone">
<image class="fc-icon fc-icon--sm" :src="iconUrl('phone')" mode="aspectFit" />
<text class="detail__btn-text">拨打电话</text>
</view>
<view v-if="contact.wechat" class="detail__btn-ghost" @click="copyWechat">
<text>复制微信号</text>
</view>
</view>
</view>
<!-- 未解锁态:会员特权与普通付费分流,权益真实呈现 -->
<view v-else class="detail__contact-locked">
<view class="detail__locked-row">
<text class="detail__contact-key">联系电话</text>
<text class="detail__locked-mask">{{ post.phone || post.contact || '188****5042' }}</text>
</view>
<view class="detail__locked-action-bar">
<view class="detail__btn-unlock" :class="{ 'detail__btn-unlock--vip': isVipUser && (freeViewUnlimited || freeViewLeft > 0) }" @click="handleUnlockClick">
<text>{{ unlockBtnText }}</text>
</view>
<text v-if="!isVipUser" class="detail__vip-link" @click="goVip">
{{ freeViewUnlimited ? '开通会员享无限次免费看 ›' : `开通会员享每日 ${freeViewQuota || 20} 条免费看 ›` }}
</text>
<text v-else class="detail__vip-link detail__vip-link--member">
{{ freeViewUnlimited ? 'VIP尊享无限次免费解锁' : `今日剩余 ${freeViewLeft} 次免费` }}
</text>
</view>
</view>
</view>
<!-- 基础档案信息(信息编号与安全提示) -->
<view class="detail__archive">
<text class="detail__archive-id">信息编号:#{{ post.id }}</text>
<text class="detail__archive-safe">提示:交易时请仔细核对,防范网络风险</text>
</view>
<!-- 用户评价 -->
<view 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">
<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" @click="onMainAction">
<text>{{ bottomBarText }}</text>
</view>
</view>
<!-- 支付解锁收银弹窗 -->
<view v-if="showPayModal" class="detail__mask" @click="showPayModal = false">
<view class="detail__cashier" @click.stop>
<view class="detail__cashier-header">
<text class="detail__cashier-title">解锁完整内容</text>
<text class="detail__cashier-close" @click="showPayModal = false">✕</text>
</view>
<view class="detail__cashier-target">
<text class="detail__cashier-target-title fc-ellipsis">{{ post.title }}</text>
<view class="detail__cashier-price-row">
<text class="detail__cashier-price-symbol">¥</text>
<text class="detail__cashier-price-val">{{ Number(payViewPrice).toFixed(2) }}</text>
</view>
</view>
<view class="detail__cashier-methods">
<view
class="detail__cashier-method"
:class="{ 'detail__cashier-method--active': payType === 'wxpay' }"
@click="payType = 'wxpay'"
>
<image class="fc-icon fc-icon--md" :src="iconUrl('paywechat')" mode="aspectFit" />
<view class="detail__cashier-method-info">
<text class="detail__cashier-method-name">微信支付</text>
<text class="detail__cashier-method-tip">微信安全支付</text>
</view>
<view class="detail__cashier-radio">
<view v-if="payType === 'wxpay'" class="detail__cashier-radio-inner"></view>
</view>
</view>
<view
class="detail__cashier-method"
:class="{ 'detail__cashier-method--active': payType === 'balance' }"
@click="payType = 'balance'"
>
<image class="fc-icon fc-icon--md" :src="iconUrl('wallet')" mode="aspectFit" />
<view class="detail__cashier-method-info">
<text class="detail__cashier-method-name">余额支付</text>
<text class="detail__cashier-method-tip">即时扣款</text>
</view>
<view class="detail__cashier-radio">
<view v-if="payType === 'balance'" class="detail__cashier-radio-inner"></view>
</view>
</view>
</view>
<view class="detail__cashier-vip-banner" @click="goVip">
<image class="fc-icon fc-icon--sm" :src="iconUrl('crown')" mode="aspectFit" />
<text class="detail__cashier-vip-text">
{{ freeViewUnlimited ? '开通会员,尊享无限次免费查看' : `开通会员,享每日 ${freeViewQuota || 20} 条免费看` }}
</text>
<text class="detail__cashier-vip-arrow">去开通 ›</text>
</view>
<view class="detail__cashier-btn" :class="{ 'detail__cashier-btn--disabled': submitting }" @click="confirmPay">
<text>{{ submitting ? '处理中…' : `立即支付 ¥${payViewPrice} 解锁` }}</text>
</view>
</view>
</view>
<!-- 支付密码弹窗(余额支付验证) -->
<fc-pay-password :show="pwdPopup.show" :title="pwdPopup.title" :tip="pwdPopup.tip"
@close="pwdPopup.show = false" @confirm="onPwdConfirm" />
<!-- 评价弹层 -->
<view v-if="showComment" class="detail__mask" @click="showComment = false">
<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" class="detail__mask" @click="showReplyModal = false">
<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">
<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 memberApi from '@/common/api/member.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 { pay } from '@/common/utils/pay.js'
import { useUserStore } from '@/common/store/index.js'
import config from '@/common/config/index.js'
import FcNavbar from '@/components/fc-navbar/fc-navbar.vue'
import FcPayPassword from '@/components/fc-pay-password/fc-pay-password.vue'
import QRCode from '@/common/utils/qrcode-generator.js'
export default {
components: { FcNavbar, FcPayPassword },
data() {
return {
id: 0,
post: { publisher: {}, tags: [] },
unlocked: false,
isVipUser: false,
freeViewQuota: 20,
freeViewUnlimited: false,
freeViewLeft: 0,
payViewPrice: 2,
payType: config.defaultPayType || 'wxpay',
contact: { name: '', phone: '', wechat: '' },
comments: [],
followed: false,
faved: false,
favoriteCount: 0,
reportReasons: REPORT_REASONS,
showComment: false,
showReplyModal: false,
activeReplyComment: null,
replyText: '',
replySubmitting: false,
showPoster: false,
showPayModal: false,
pwdPopup: { show: false, title: '', tip: '' },
score: 5,
commentText: '',
submitting: false,
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
},
/**
* 联系方式卡片解锁按钮文案
* @returns {string}
*/
unlockBtnText() {
if (this.isVipUser && this.freeViewUnlimited) {
return 'VIP免费解锁 (不限次数)'
}
if (this.isVipUser && this.freeViewLeft > 0) {
return `VIP免费解锁 (剩${this.freeViewLeft}次)`
}
if (this.isVipUser && this.freeViewLeft === 0) {
return `免费额度已用完 (¥${this.payViewPrice}解锁)`
}
return `解锁完整内容 (¥${this.payViewPrice})`
},
/**
* 底部主操作栏文案
* @returns {string}
*/
bottomBarText() {
if (this.loading && !this.loaded) return '加载中…'
if (this.unlocked) return '拨打电话'
if (this.isVipUser && this.freeViewUnlimited) {
return 'VIP免费解锁 (不限次数)'
}
if (this.isVipUser && this.freeViewLeft > 0) {
return `VIP免费解锁 (今日剩余${this.freeViewLeft}次)`
}
if (this.isVipUser && this.freeViewLeft === 0) {
return `今日免费已用完 · ¥${this.payViewPrice} 解锁`
}
return `解锁完整内容 (¥${this.payViewPrice})`
},
timeText() {
return fromNow(this.post.createdAt)
},
/**
* 媒体列表图片
*/
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 (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) {
this.unlocked = !!(res && res.unlocked)
if (res) {
if (res.isMember != null) this.isVipUser = !!res.isMember
if (res.freeViewQuota != null) this.freeViewQuota = Number(res.freeViewQuota)
if (res.freeViewUnlimited != null) this.freeViewUnlimited = !!res.freeViewUnlimited
if (res.freeViewLeft != null) this.freeViewLeft = Number(res.freeViewLeft)
if (res.payViewPrice != null) this.payViewPrice = Number(res.payViewPrice)
}
let phoneStr = ''
let wechatStr = ''
let nameStr = ''
if (this.unlocked && res) {
if (res.contactInfo && typeof res.contactInfo === 'object') {
phoneStr = String(res.contactInfo.phone || '')
wechatStr = String(res.contactInfo.wechat || '')
nameStr = String(res.contactInfo.name || res.contactName || '')
} else if (res.contact && typeof res.contact === 'object') {
phoneStr = String(res.contact.phone || '')
wechatStr = String(res.contact.wechat || '')
nameStr = String(res.contact.name || res.contactName || '')
} else {
phoneStr = String(res.phone || (typeof res.contact === 'string' ? res.contact : ''))
wechatStr = String(res.wechat || '')
nameStr = String(res.contactName || (res.publisher && res.publisher.name) || '')
}
}
this.contact = this.unlocked
? {
name: nameStr,
phone: phoneStr,
wechat: wechatStr,
}
: { name: '', phone: '', wechat: '' }
// 若发帖人有关联商家,解锁态下同步刷新商家实体门店电话
if (this.unlocked && res && res.merchant && res.merchant.contact && this.post.merchant) {
this.post.merchant.contact = res.merchant.contact
}
},
/**
* 点击联系方式解锁按钮(智能区分会员免密免费解锁与非会员收银台)
*/
handleUnlockClick() {
if (!this.ensureLogin()) return
if (this.isVipUser && this.freeViewLeft > 0) {
this.unlockWithVip()
} else {
this.openPayModal()
}
},
/**
* 会员每日免密免费解锁(真实扣减会员当日权益)
*/
async unlockWithVip() {
if (this.submitting) return
this.submitting = true
try {
const res = await postApi.payView(this.id, { payType: 'free' })
this.applyAccess(res)
uni.showToast({ title: '已使用会员免费权益解锁', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message || '解锁失败', icon: 'none' })
} finally {
this.submitting = false
}
},
/**
* 页面回显静默刷新解锁权限
*/
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) {}
},
/**
* 打开解锁收银台
*/
openPayModal() {
if (!this.ensureLogin()) return
this.showPayModal = true
},
/**
* 底部主按钮响应
*/
onMainAction() {
if (this.loading && !this.loaded) return
if (this.unlocked) {
this.callPhone()
} else if (this.isVipUser && this.freeViewLeft > 0) {
this.unlockWithVip()
} else {
this.openPayModal()
}
},
/**
* 确认支付解锁
*/
async confirmPay() {
if (this.submitting) return
// 后台开启支付密码:余额支付需先验证(未设置则引导去余额中心设置)
if (this.payType === 'balance' && uiState.payPasswordEnabled) {
const assets = await this.fetchAssetsSafe()
if (!assets.hasPayPassword) {
uni.showModal({
title: '需要支付密码',
content: '开启支付密码验证后才能使用余额支付,请先到「余额中心」设置。',
confirmText: '去设置',
success: (r) => {
if (r.confirm) uni.navigateTo({ url: '/pages/member/recharge' })
},
})
return
}
this.pwdPopup = { show: true, title: '请输入支付密码', tip: `解锁该信息 · ¥${Number(this.payViewPrice).toFixed(2)}` }
return
}
await this.doPay()
},
/**
* 实际支付(confirmPay 校验通过 / 支付密码验证后调用)
* @param {string} [payPassword] 余额支付密码(开关开启时必传)
*/
async doPay(payPassword) {
this.submitting = true
try {
const res = await postApi.payView(this.id, { payType: this.payType, payPassword })
if (res.order) {
await pay({ ...res.order, payType: this.payType })
await this.waitUnlock()
this.showPayModal = false
return
}
this.applyAccess(res)
this.showPayModal = false
uni.showToast({ title: '已解锁联系方式', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message || '支付失败', icon: 'none' })
} finally {
this.submitting = false
}
},
/**
* 支付密码弹窗确认(余额支付验证)
* @param {string} password
*/
async onPwdConfirm(password) {
this.pwdPopup.show = false
await this.doPay(password)
},
/**
* 拉取资产(仅取支付密码状态,失败不阻断流程)
*/
async fetchAssetsSafe() {
try {
return await memberApi.assets()
} catch (e) {
return {}
}
},
/**
* 微信支付结果轮询
*/
async waitUnlock() {
for (let i = 0; i < 4; i++) {
await new Promise((resolve) => setTimeout(resolve, i === 0 ? 800 : 1200))
try {
const res = await postApi.payView(this.id, { payType: config.constants.PAY_TYPE.WXPAY })
if (res && res.unlocked) {
this.applyAccess(res)
uni.showToast({ title: '已解锁联系方式', icon: 'none' })
return
}
} catch (e) {}
}
uni.showToast({ title: '支付成功,稍后生效', icon: 'none' })
},
/**
* 写评价点击拦截(禁止发布者自评)
*/
onOpenComment() {
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' })
}
},
})
},
/**
* 前往会员中心
*/
goVip() {
this.showPayModal = false
uni.navigateTo({ url: '/pages/member/index' })
},
/**
* 导航或复制地址
*/
/**
* 跳转至个人用户公开主页
*/
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' }),
})
},
/**
* 拨打电话
*/
callPhone() {
if (!this.unlocked) return this.openPayModal()
const phone = this.contact.phone || this.post.phone || this.post.contact
if (!phone) return uni.showToast({ title: '暂未提供电话', icon: 'none' })
uni.makePhoneCall({ phoneNumber: String(phone) })
},
/**
* 复制微信
*/
copyWechat() {
if (!this.contact.wechat) return uni.showToast({ title: '暂未提供微信号', icon: 'none' })
uni.setClipboardData({
data: this.contact.wechat,
success: () => 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, '面议', 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 : '')) : '面议'
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__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__tags {
display: flex;
flex-wrap: wrap;
margin-top: 16rpx;
}
.detail__tag {
margin: 0 12rpx 12rpx 0;
background: #eefbf4;
color: #04874a;
font-size: 22rpx;
padding: 6rpx 16rpx;
border-radius: 8rpx;
}
.detail__map {
margin-top: 20rpx;
display: flex;
align-items: center;
background: $fc-bg;
border-radius: $fc-radius;
padding: 18rpx 20rpx;
}
.detail__map-bg {
width: 96rpx;
height: 76rpx;
border-radius: 10rpx;
background: linear-gradient(135deg, #d8f1e8, #93d6c2);
display: flex;
align-items: center;
justify-content: center;
}
.detail__map-info {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
}
.detail__map-name {
font-size: $fc-font;
color: $fc-text;
}
.detail__map-action {
margin-top: 6rpx;
font-size: $fc-font-xs;
color: $fc-primary-text;
}
.detail__report {
margin-top: 14rpx;
display: flex;
justify-content: flex-end;
}
.detail__report-text {
font-size: $fc-font-xs;
color: $fc-text-placeholder;
}
/* 联系方式专区(极简规范) */
.detail__contact-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__contact-badge {
font-size: 20rpx;
color: #04874a;
background: #e4fdf0;
padding: 4rpx 14rpx;
border-radius: $fc-radius-pill;
font-weight: 600;
}
.detail__contact-unlocked {
margin-top: 16rpx;
}
.detail__contact-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
border-bottom: 2rpx solid $fc-border;
}
.detail__contact-key {
font-size: $fc-font;
color: $fc-text-secondary;
}
.detail__contact-name {
font-size: $fc-font;
font-weight: 600;
color: $fc-text;
}
.detail__contact-phone {
font-size: 32rpx;
font-weight: 700;
color: #04874a;
letter-spacing: 1rpx;
}
.detail__contact-wechat {
font-size: 28rpx;
font-weight: 600;
color: $fc-text;
}
.detail__contact-btns {
display: flex;
margin-top: 24rpx;
}
.detail__btn-primary {
flex: 1;
height: 80rpx;
border-radius: $fc-radius-pill;
background: $fc-gradient-primary;
color: $fc-primary-ink;
display: flex;
align-items: center;
justify-content: center;
font-size: $fc-font;
font-weight: 700;
box-shadow: 0 6rpx 18rpx rgba(0, 224, 112, 0.25);
}
.detail__btn-ghost {
margin-left: 20rpx;
width: 220rpx;
height: 80rpx;
border-radius: $fc-radius-pill;
background: #ffffff;
border: 2rpx solid $fc-primary-text;
color: $fc-primary-text;
display: flex;
align-items: center;
justify-content: center;
font-size: $fc-font;
font-weight: 600;
}
.detail__btn-text {
margin-left: 8rpx;
}
/* 未解锁态(极简) */
.detail__contact-locked {
margin-top: 16rpx;
}
.detail__locked-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
border-bottom: 2rpx solid $fc-border;
}
.detail__locked-mask {
font-size: 32rpx;
font-weight: 700;
color: $fc-text-placeholder;
letter-spacing: 2rpx;
}
.detail__locked-action-bar {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
}
.detail__btn-unlock {
flex: 1;
height: 80rpx;
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;
box-shadow: 0 6rpx 18rpx rgba(0, 224, 112, 0.28);
}
.detail__vip-link {
margin-left: 24rpx;
font-size: 24rpx;
color: #f5a623;
font-weight: 600;
}
.detail__vip-link--member {
color: #00a854;
}
/* 档案与编号 */
.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 {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 99;
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;
box-shadow: 0 6rpx 20rpx rgba(0, 224, 112, 0.35);
}
/* 统一收银弹层 */
.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__cashier {
width: 100%;
background: #ffffff;
border-radius: 36rpx 36rpx 0 0;
padding: 36rpx 36rpx calc(40rpx + env(safe-area-inset-bottom));
box-sizing: border-box;
}
.detail__cashier-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__cashier-title {
font-size: 32rpx;
font-weight: 700;
color: $fc-text;
}
.detail__cashier-close {
font-size: 36rpx;
color: $fc-text-placeholder;
padding: 8rpx;
}
.detail__cashier-target {
margin-top: 20rpx;
padding: 20rpx;
background: $fc-bg;
border-radius: $fc-radius;
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__cashier-target-title {
flex: 1;
font-size: $fc-font;
color: $fc-text-regular;
margin-right: 20rpx;
}
.detail__cashier-price-row {
display: flex;
align-items: baseline;
color: #04874a;
}
.detail__cashier-price-symbol {
font-size: 24rpx;
font-weight: 700;
}
.detail__cashier-price-val {
font-size: 38rpx;
font-weight: 800;
}
.detail__cashier-methods {
margin-top: 20rpx;
}
.detail__cashier-method {
display: flex;
align-items: center;
padding: 22rpx 20rpx;
border-radius: $fc-radius;
border: 2rpx solid $fc-border;
margin-bottom: 14rpx;
&--active {
border-color: #00e070;
background: #eefbf4;
}
}
.detail__cashier-method-info {
flex: 1;
margin-left: 20rpx;
display: flex;
flex-direction: column;
}
.detail__cashier-method-name {
font-size: $fc-font;
font-weight: 600;
color: $fc-text;
}
.detail__cashier-method-tip {
font-size: 20rpx;
color: $fc-text-placeholder;
margin-top: 2rpx;
}
.detail__cashier-radio {
width: 34rpx;
height: 34rpx;
border-radius: 50%;
border: 2rpx solid $fc-border;
display: flex;
align-items: center;
justify-content: center;
}
.detail__cashier-method--active .detail__cashier-radio {
border-color: #00e070;
}
.detail__cashier-radio-inner {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background: #00e070;
}
.detail__cashier-vip-banner {
margin-top: 8rpx;
display: flex;
align-items: center;
padding: 16rpx 20rpx;
background: #fff8e8;
border-radius: $fc-radius;
}
.detail__cashier-vip-text {
flex: 1;
margin-left: 12rpx;
font-size: 24rpx;
color: #925800;
}
.detail__cashier-vip-arrow {
font-size: 24rpx;
font-weight: 700;
color: #f5a623;
}
.detail__cashier-btn {
margin-top: 28rpx;
height: 88rpx;
border-radius: $fc-radius-pill;
background: $fc-gradient-primary;
color: $fc-primary-ink;
font-size: 30rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 20rpx rgba(0, 224, 112, 0.35);
&--disabled {
opacity: 0.6;
}
}
/* 评价输入弹层 */
.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>