2540 行
68 KiB
Vue
2540 行
68 KiB
Vue
<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 class="fc-card detail__publisher">
|
||
<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.verified" class="detail__verified">已认证</text>
|
||
</view>
|
||
<text class="detail__publisher-sub">
|
||
{{ post.publisher && post.publisher.isMerchant ? '商家用户' : '个人用户' }} · {{ timeText }}发布
|
||
</text>
|
||
</view>
|
||
<view v-if="!isSelf" class="detail__follow" :class="{ 'detail__follow--active': followed }" @click="toggleFollow">
|
||
<text>{{ followed ? '已关注' : '+ 关注' }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 商家门店专区(仅商家发布或关联认证商户时展示) -->
|
||
<view v-if="isMerchantPost" class="fc-card detail__merchant">
|
||
<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>
|
||
<text class="detail__merchant-security">工商资质已核验</text>
|
||
</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="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" @click="openPayModal">
|
||
<text>解锁完整内容 (¥2)</text>
|
||
</view>
|
||
<text class="detail__vip-link" @click="goVip">开通会员免费看 ›</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>{{ loading && !loaded ? '加载中…' : (unlocked ? '拨打电话' : '解锁完整内容 (¥2)') }}</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">2.00</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">开通会员,全站信息免费看</text>
|
||
<text class="detail__cashier-vip-arrow">去开通 ›</text>
|
||
</view>
|
||
<view class="detail__cashier-btn" :class="{ 'detail__cashier-btn--disabled': submitting }" @click="confirmPay">
|
||
<text>{{ submitting ? '处理中…' : '立即支付 ¥2 解锁' }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 评价弹层 -->
|
||
<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" open-type="share">
|
||
<image class="fc-icon fc-icon--sm" :src="iconUrl('bar-share')" mode="aspectFit" />
|
||
<text class="detail__poster-btn-text">发给微信好友</text>
|
||
</button>
|
||
<view class="detail__poster-btn detail__poster-btn--ghost" @click="copyPosterText">
|
||
<text>复制文案</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 隐藏的离屏 Canvas,用于高质量绘制海报(640 x 1020 逻辑分辨率) -->
|
||
<canvas
|
||
canvas-id="postPosterCanvas"
|
||
id="postPosterCanvas"
|
||
class="detail__poster-canvas"
|
||
style="width: 640px; height: 1020px; position: fixed; left: -9999px; top: -9999px;"
|
||
/>
|
||
</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 { 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 QRCode from '@/common/utils/qrcode-generator.js'
|
||
|
||
export default {
|
||
components: { FcNavbar },
|
||
data() {
|
||
return {
|
||
id: 0,
|
||
post: { publisher: {}, tags: [] },
|
||
unlocked: false,
|
||
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,
|
||
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
|
||
},
|
||
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
|
||
}
|
||
},
|
||
},
|
||
onLoad(query) {
|
||
this.id = Number(query.id || 101)
|
||
loadUiConfig()
|
||
this.fetch()
|
||
},
|
||
onShow() {
|
||
if (this.loaded) this.refreshAccess()
|
||
},
|
||
/**
|
||
* 转发给微信好友(携带分销邀请人与精美封面图)
|
||
*/
|
||
onShareAppMessage() {
|
||
let inviterId = 0
|
||
try {
|
||
const user = this.currentUser
|
||
if (user && user.id) inviterId = user.id
|
||
} 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}${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
|
||
try {
|
||
const user = this.currentUser
|
||
if (user && user.id) inviterId = user.id
|
||
} 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}${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)
|
||
this.contact = this.unlocked
|
||
? {
|
||
name: (res && (res.contactName || res.publisher?.name)) || '',
|
||
phone: (res && (res.phone || res.contact)) || '',
|
||
wechat: (res && res.wechat) || '',
|
||
}
|
||
: { name: '', phone: '', wechat: '' }
|
||
},
|
||
/**
|
||
* 页面回显静默刷新解锁权限
|
||
*/
|
||
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 {
|
||
this.openPayModal()
|
||
}
|
||
},
|
||
/**
|
||
* 确认支付解锁
|
||
*/
|
||
async confirmPay() {
|
||
if (this.submitting) return
|
||
this.submitting = true
|
||
try {
|
||
const res = await postApi.payView(this.id, { payType: this.payType })
|
||
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
|
||
}
|
||
},
|
||
/**
|
||
* 微信支付结果轮询
|
||
*/
|
||
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' })
|
||
},
|
||
/**
|
||
* 导航或复制地址
|
||
*/
|
||
/**
|
||
* 导航前往商家门店或复制实体地址
|
||
*/
|
||
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 图片链接
|
||
*/
|
||
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(''),
|
||
})
|
||
})
|
||
},
|
||
/**
|
||
* 辅助函数:绘制圆角矩形
|
||
*/
|
||
drawRoundRect(ctx, x, y, width, height, radius, fillColor, strokeColor) {
|
||
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.stroke()
|
||
}
|
||
ctx.restore()
|
||
},
|
||
/**
|
||
* 辅助函数:多行文本绘制并带省略号
|
||
*/
|
||
drawTextWrapped(ctx, text, x, y, maxWidth, lineHeight, maxLines = 2) {
|
||
if (!text) return y
|
||
const words = String(text).split('')
|
||
let line = ''
|
||
let lineCount = 0
|
||
let currentY = y
|
||
|
||
for (let i = 0; i < words.length; i++) {
|
||
const testLine = line + words[i]
|
||
const metrics = ctx.measureText(testLine)
|
||
if (metrics.width > maxWidth && i > 0) {
|
||
lineCount++
|
||
if (lineCount >= maxLines) {
|
||
line = line.slice(0, -1) + '…'
|
||
ctx.fillText(line, x, currentY)
|
||
return currentY + lineHeight
|
||
}
|
||
ctx.fillText(line, x, currentY)
|
||
line = words[i]
|
||
currentY += lineHeight
|
||
} else {
|
||
line = testLine
|
||
}
|
||
}
|
||
if (line) {
|
||
ctx.fillText(line, x, currentY)
|
||
currentY += lineHeight
|
||
}
|
||
return currentY
|
||
},
|
||
/**
|
||
* 在 Canvas 上绘制真实的、符合国际标准且微信100%可识别的长按二维码
|
||
* @param {Object} ctx Canvas 上下文
|
||
* @param {String} text 二维码目标 URL
|
||
* @param {Number} x 起始 X
|
||
* @param {Number} y 起始 Y
|
||
* @param {Number} size 二维码尺寸(px)
|
||
*/
|
||
drawRealQRCode(ctx, text, x, y, size) {
|
||
try {
|
||
const qr = QRCode.create(text, { errorCorrectionLevel: 'M' })
|
||
const modules = qr.modules
|
||
const count = modules.size
|
||
const padding = 10
|
||
const qrRealSize = size - padding * 2
|
||
const cellSize = qrRealSize / count
|
||
|
||
// 1. 纯白底色(高对比度保障扫描率)
|
||
ctx.save()
|
||
this.drawRoundRect(ctx, x, y, size, size, 14, '#FFFFFF', '#DFF2E8')
|
||
|
||
// 2. 绘制深墨绿码元(#04301E),符合春绿规范且微信识别率100%
|
||
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.4,
|
||
cellSize + 0.4
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 3. 中心微质感同城 Logo 徽标
|
||
const centerSize = Math.floor(size * 0.22)
|
||
const cx = x + size / 2
|
||
const cy = y + size / 2
|
||
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, centerSize / 2 + 3, 0, Math.PI * 2)
|
||
ctx.setFillStyle('#FFFFFF')
|
||
ctx.fill()
|
||
|
||
ctx.beginPath()
|
||
ctx.arc(cx, cy, centerSize / 2, 0, Math.PI * 2)
|
||
ctx.setFillStyle('#00E070')
|
||
ctx.fill()
|
||
|
||
ctx.setFontSize(Math.floor(centerSize * 0.58))
|
||
ctx.setFillStyle('#04301E')
|
||
ctx.setTextAlign('center')
|
||
ctx.setTextBaseline('middle')
|
||
ctx.fillText('同', cx, cy + 1)
|
||
|
||
ctx.restore()
|
||
} catch (e) {
|
||
console.error('QR draw error', e)
|
||
}
|
||
},
|
||
/**
|
||
* 真实商业级 Canvas 绘制高清同城分享海报
|
||
*/
|
||
async generatePoster() {
|
||
this.posterLoading = true
|
||
const W = 640
|
||
const H = 1020
|
||
const ctx = uni.createCanvasContext('postPosterCanvas', this)
|
||
|
||
// 1. 底板背景(薄荷微浅绿)
|
||
ctx.setFillStyle('#F2FEF7')
|
||
ctx.fillRect(0, 0, W, H)
|
||
|
||
// 2. 主体纯白高光卡片(圆角 24px,带微立体边框)
|
||
this.drawRoundRect(ctx, 24, 24, 592, 972, 26, '#FFFFFF', '#DDF2E7')
|
||
|
||
// 3. 头部 Hero 品牌渐变区
|
||
const grad = ctx.createLinearGradient(24, 24, 616, 210)
|
||
grad.addColorStop(0, '#04874A')
|
||
grad.addColorStop(1, '#023A21')
|
||
this.drawRoundRect(ctx, 24, 24, 592, 180, 26, grad, null)
|
||
|
||
// 补齐头部底部的直角以平滑承接内容
|
||
ctx.setFillStyle(grad)
|
||
ctx.fillRect(24, 160, 592, 44)
|
||
|
||
// 头部品牌与城市胶囊
|
||
this.drawRoundRect(ctx, 48, 50, 200, 36, 18, 'rgba(255, 255, 255, 0.22)', null)
|
||
ctx.setFontSize(20)
|
||
ctx.setFillStyle('#FFFFFF')
|
||
ctx.fillText('同城生活 · 便民严选', 64, 75)
|
||
|
||
const cityText = this.post.area ? `贵阳 · ${this.post.area}` : '贵阳同城'
|
||
const cityW = ctx.measureText(cityText).width + 30
|
||
this.drawRoundRect(ctx, 592 - cityW, 50, cityW, 36, 18, 'rgba(255, 255, 255, 0.16)', null)
|
||
ctx.setFontSize(20)
|
||
ctx.setFillStyle('#FFFFFF')
|
||
ctx.fillText(cityText, 607 - cityW, 75)
|
||
|
||
// 头部大标题(白字粗体,最多折 2 行)
|
||
ctx.setFontSize(32)
|
||
ctx.setFillStyle('#FFFFFF')
|
||
this.drawTextWrapped(ctx, this.post.title || '', 48, 128, 544, 40, 2)
|
||
|
||
// 4. 实拍大图 / 主题视觉区
|
||
let coverPath = ''
|
||
if (this.imageUrls && this.imageUrls.length) {
|
||
coverPath = await this.getImageLocalPath(this.imageUrls[0])
|
||
}
|
||
const picY = 224
|
||
if (coverPath) {
|
||
ctx.save()
|
||
this.drawRoundRect(ctx, 48, picY, 544, 290, 16, null, null)
|
||
ctx.clip()
|
||
ctx.drawImage(coverPath, 48, picY, 544, 290)
|
||
ctx.restore()
|
||
|
||
// 大图内右上方贴上“置顶推荐 / 实拍保真”标签
|
||
if (this.post.isTop || this.post.isPaid) {
|
||
this.drawRoundRect(ctx, 450, picY + 16, 126, 36, 18, '#F5A623', null)
|
||
ctx.setFontSize(18)
|
||
ctx.setFillStyle('#FFFFFF')
|
||
ctx.fillText('置顶推荐', 478, picY + 41)
|
||
}
|
||
// 大图左下方贴上分类胶囊
|
||
this.drawRoundRect(ctx, 64, picY + 236, 136, 36, 18, 'rgba(4, 48, 30, 0.75)', null)
|
||
ctx.setFontSize(20)
|
||
ctx.setFillStyle('#00E070')
|
||
ctx.fillText(this.post.categoryName || '同城便民', 80, picY + 261)
|
||
} else {
|
||
// 无实拍图时展示生态质感主题大卡片
|
||
this.drawRoundRect(ctx, 48, picY, 544, 220, 16, '#EBF9F2', '#D3EFE1')
|
||
ctx.setFontSize(32)
|
||
ctx.setFillStyle('#04874A')
|
||
ctx.fillText(this.post.categoryName || '同城便民服务', 72, picY + 90)
|
||
ctx.setFontSize(22)
|
||
ctx.setFillStyle('#5E8C78')
|
||
ctx.fillText('平台官方审核认证 · 放心联系', 72, picY + 140)
|
||
}
|
||
|
||
// 5. 价值展示行(价格 + 浏览量)
|
||
const valY = coverPath ? picY + 332 : picY + 262
|
||
ctx.setFontSize(26)
|
||
ctx.setFillStyle('#F5A623')
|
||
if (this.post.price > 0) {
|
||
ctx.fillText('¥', 48, valY)
|
||
ctx.setFontSize(48)
|
||
ctx.fillText(String(this.post.price), 68, valY)
|
||
if (this.post.priceUnit) {
|
||
ctx.setFontSize(22)
|
||
ctx.setFillStyle('#888888')
|
||
ctx.fillText(this.post.priceUnit, 72 + ctx.measureText(String(this.post.price)).width + 10, valY)
|
||
}
|
||
} else {
|
||
ctx.setFontSize(40)
|
||
ctx.fillText('面议', 48, valY)
|
||
}
|
||
|
||
// 右侧显示浏览人次
|
||
const viewsText = `🔥 ${this.post.views || 0} 人关注 · 平台认证`
|
||
ctx.setFontSize(20)
|
||
ctx.setFillStyle('#7A998A')
|
||
ctx.fillText(viewsText, 592 - ctx.measureText(viewsText).width - 48, valY - 6)
|
||
|
||
// 6. 详细描述摘录
|
||
const descY = valY + 44
|
||
ctx.setFontSize(22)
|
||
ctx.setFillStyle('#3E5246')
|
||
this.drawTextWrapped(ctx, this.post.content || '', 48, descY, 544, 34, 2)
|
||
|
||
// 7. 特色服务标签与地址信息
|
||
const tagY = descY + 76
|
||
if (this.post.tags && this.post.tags.length) {
|
||
let tagStartX = 48
|
||
this.post.tags.slice(0, 3).forEach((tag) => {
|
||
const tW = ctx.measureText(tag).width + 24
|
||
this.drawRoundRect(ctx, tagStartX, tagY, tW, 34, 8, '#EBF9F2', '#D3EFE1')
|
||
ctx.setFontSize(19)
|
||
ctx.setFillStyle('#04874A')
|
||
ctx.fillText(tag, tagStartX + 12, tagY + 24)
|
||
tagStartX += tW + 12
|
||
})
|
||
}
|
||
|
||
// 地址行
|
||
const addrY = tagY + 54
|
||
ctx.setFontSize(20)
|
||
ctx.setFillStyle('#6E8276')
|
||
const addrFull = `📍 ${this.post.address || this.post.area || '同城便民服务地点'}`
|
||
ctx.fillText(addrFull.slice(0, 28), 48, addrY)
|
||
|
||
// 8. 分割虚线
|
||
ctx.setStrokeStyle('#E2EBE5')
|
||
ctx.setLineWidth(1)
|
||
ctx.beginPath()
|
||
ctx.moveTo(48, 804)
|
||
ctx.lineTo(592, 804)
|
||
ctx.stroke()
|
||
|
||
// 9. 底部扫码交互区(真实高密度二维码 + 醒目指引)
|
||
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 : ''}`
|
||
|
||
// 绘制真实合规的微信二维码
|
||
this.drawRealQRCode(ctx, qrUrl, 48, 826, 140)
|
||
|
||
// 右侧指引文案
|
||
ctx.setFontSize(26)
|
||
ctx.setFillStyle('#04301E')
|
||
ctx.fillText('微信长按识别小程序', 214, 866)
|
||
|
||
ctx.setFontSize(21)
|
||
ctx.setFillStyle('#04874A')
|
||
ctx.fillText('查看完整电话 · 微信 · 地图导航', 214, 904)
|
||
|
||
ctx.setFontSize(18)
|
||
ctx.setFillStyle('#88A395')
|
||
ctx.fillText('同城生活分类信息 · 官方便民服务保障', 214, 938)
|
||
|
||
// 10. 最终导出高保真图片
|
||
ctx.draw(false, () => {
|
||
setTimeout(() => {
|
||
uni.canvasToTempFilePath(
|
||
{
|
||
canvasId: 'postPosterCanvas',
|
||
width: W,
|
||
height: H,
|
||
destWidth: W,
|
||
destHeight: H,
|
||
fileType: 'png',
|
||
quality: 1,
|
||
success: (res) => {
|
||
this.posterImagePath = res.tempFilePath
|
||
this.posterLoading = false
|
||
},
|
||
fail: () => {
|
||
this.posterLoading = false
|
||
uni.showToast({ title: '海报绘制失败,请重试', icon: 'none' })
|
||
},
|
||
},
|
||
this
|
||
)
|
||
}, 260)
|
||
})
|
||
},
|
||
/**
|
||
* 预览海报大图
|
||
*/
|
||
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 text = `【${this.post.categoryName || '同城信息'}】${this.post.title}\n价格:${this.post.price > 0 ? '¥' + this.post.price : '面议'}\n位置:${this.post.address || ''}\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 #e2f4ea;
|
||
}
|
||
.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__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__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>
|