文件
fenleixinxi/uniapp/pages/detail/detail.vue
linda 376a7ed793 feat(uniapp): 小程序端(用户端 uni-app)源码入库到 uniapp/ 目录
需求:小程序端代码要同时同步到服务器与 git 远程仓库,此前前端仅存在于本地目录(无 remote,从未推送),
服务器与 gitcode 上只有后端。现并入本仓库,复用现有「push 即部署」链路,一次推送双端落地。

- 新增 uniapp/:小程序端源码全量快照(150 文件 / 1.2M)
  pages(13 页面) · components(fc-navbar/fc-tabbar/fc-post-card/fc-empty) ·
  common(api·utils·mock·assets·store·config) · static(74 图标 + 3 轮播插画 + LOGO) ·
  docs(01-04 交付文档) · tests(零依赖全链路用例) · tools(图标/主题/轮播配图生成脚本)
- 明确排除:AGENTS.md(含后台密码、服务器 root 密码、小程序 AppSecret,切勿入库)、
  unpackage/(构建产物,由 uniapp/.gitignore 忽略)、node_modules、.DS_Store、*.log
- .gitignore 新增 /uniapp/AGENTS.md 兜底(与根 /AGENTS.md、/fluniapp/ 同一约定)
- README.md 增补「本项目结构 + 部署链路」章节,说明 uniapp/ 为源码快照、
  小程序真正上线仍需微信开发者工具 cli upload

说明:本次为源码快照,未携带前端本地仓库历史——其历史提交中含 AGENTS.md 凭据,
若做 subtree 合并会把密码永久写入本仓库历史,故采用快照方式。
2026-10-17 14:19:03 +08:00

762 行
19 KiB
Vue

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

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

<template>
<view class="detail fc-page">
<!-- 顶部视觉 -->
<view 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.isPaid" class="fc-tag detail__badge detail__badge--paid">置顶推荐</text>
</view>
</view>
<!-- 标题与价格 -->
<view class="fc-card detail__head">
<text class="detail__title">{{ post.title }}</text>
<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" 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 }}</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 }} · {{ post.address }}</text>
</view>
</view>
<!-- 发布者 -->
<view class="fc-card detail__publisher">
<view 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="fc-tag detail__verified">已认证</text>
</view>
<text class="detail__publisher-sub">
{{ post.publisher && post.publisher.isMerchant ? '商家用户' : '个人用户' }} · {{ timeText }}发布
</text>
</view>
<view class="detail__follow" @click="toggleFollow">
<text>{{ followed ? '已关注' : '+ 关注' }}</text>
</view>
</view>
<!-- 内容展示 -->
<view class="fc-card">
<text class="detail__section-title">详细描述</text>
<text class="detail__content">{{ post.content }}</text>
<view class="detail__tags">
<text v-for="t in post.tags || []" :key="t" class="fc-tag 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">地图导航 ›</text>
</view>
</view>
</view>
<!-- 付费查看联系方式 -->
<view class="fc-card detail__contact">
<text class="detail__section-title">联系方式</text>
<view v-if="unlocked" class="detail__contact-inner">
<view class="detail__contact-row">
<text class="detail__contact-label">电话</text>
<text class="detail__contact-value" @click="callPhone">{{ contact.phone }}</text>
</view>
<view class="detail__contact-row">
<text class="detail__contact-label">微信</text>
<text class="detail__contact-value">{{ contact.wechat }}</text>
</view>
<view class="detail__contact-actions">
<view class="detail__contact-btn" @click="callPhone">
<image class="fc-icon fc-icon--sm" :src="iconUrl('phone')" mode="aspectFit" />
<text class="detail__btn-text">拨打电话</text>
</view>
<view class="detail__contact-btn detail__contact-btn--ghost" @click="copyWechat">
<text>复制微信号</text>
</view>
</view>
</view>
<view v-else class="detail__locked">
<image class="fc-icon fc-icon--xl" :src="iconUrl('lock')" mode="aspectFit" />
<text class="detail__locked-text">非会员查看完整联系方式需付费或开通会员</text>
<view class="detail__pay-type">
<view class="detail__pay-item" :class="{ 'detail__pay-item--active': payType === 'balance' }"
@click="payType = 'balance'"><text>余额支付</text></view>
<view class="detail__pay-item" :class="{ 'detail__pay-item--active': payType === 'wxpay' }"
@click="payType = 'wxpay'"><text>微信支付</text></view>
</view>
<view class="detail__locked-actions">
<view class="detail__locked-btn" @click="payView(payType)"><text>¥2 付费查看</text></view>
<view class="detail__locked-btn detail__locked-btn--vip" @click="goVip"><text>开通会员免费看</text></view>
</view>
</view>
</view>
<!-- 评价互动 -->
<view class="fc-card">
<view class="detail__cmt-head">
<text class="detail__section-title">用户评价({{ comments.length }})</text>
<text class="detail__cmt-write" @click="showComment = true">写评价 ›</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>
<text v-if="c.reply" class="detail__cmt-reply">商家回复:{{ c.reply }}</text>
</view>
<text v-if="!comments.length" class="detail__cmt-empty">暂无评价,快来抢沙发~</text>
</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">收藏</text>
</view>
<view class="detail__bar-item" @click="onShare">
<image class="fc-icon fc-icon--md" :src="iconUrl('bar-share')" mode="aspectFit" />
<text class="detail__bar-text">分享</text>
</view>
<view class="detail__bar-item" @click="showPoster = true">
<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="unlocked ? callPhone() : payView(payType)">
<text>{{ unlocked ? '立即联系' : '付费查看' }}</text>
</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="showPoster" class="detail__mask" @click="showPoster = false">
<view class="detail__poster" @click.stop>
<view class="detail__poster-head">
<text class="detail__poster-title">{{ post.title }}</text>
<text class="detail__poster-price">¥{{ post.price > 0 ? post.price : '面议' }}</text>
</view>
<text class="detail__poster-tip">长按识别 / 保存海报分享给好友</text>
<view class="fc-inline detail__poster-qr">
<image class="fc-icon fc-icon--sm" :src="iconUrl('qrcode')" mode="aspectFit" />
<text class="detail__poster-qr-text">扫码查看同城详情</text>
</view>
</view>
</view>
</view>
</template>
<script>
import postApi from '@/common/api/post.js'
import { iconUrl, categoryIconUrl, loadUiConfig } from '@/common/store/ui.js'
import { fromNow, maskPhone } from '@/common/utils/format.js'
import config from '@/common/config/index.js'
export default {
data() {
return {
id: 0,
post: { publisher: {}, tags: [] },
unlocked: false,
payType: config.defaultPayType,
contact: { phone: '', wechat: '' },
comments: [],
followed: false,
faved: false,
showComment: false,
showPoster: false,
score: 5,
commentText: '',
}
},
computed: {
timeText() {
return fromNow(this.post.createdAt)
},
},
onLoad(query) {
this.id = Number(query.id || 101)
loadUiConfig()
this.fetch()
},
methods: {
iconUrl,
catIcon() {
return categoryIconUrl(this.post.categoryId)
},
async fetch() {
try {
const res = await postApi.detail(this.id)
this.post = res
this.comments = [
{ id: 5001, user: '小王同学', score: 5, content: '联系很及时,沟通顺畅。', reply: '' },
{ id: 5002, user: '果果', score: 4, content: '信息真实,价格合理。', reply: '感谢支持~' },
]
this.contact = { phone: maskPhone(res.contact), wechat: '' }
} catch (e) {
uni.showToast({ title: '信息加载失败', icon: 'none' })
}
},
async payView(payType) {
try {
const res = await postApi.payView(this.id, { payType })
this.unlocked = true
this.contact = { phone: res.contact.phone, wechat: res.contact.wechat }
uni.showToast({ title: '已解锁完整联系方式', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message || '支付失败', icon: 'none' })
}
},
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.comments.unshift({ id: Date.now(), user: '我', score: this.score, content: this.commentText, reply: '' })
this.commentText = ''
this.showComment = false
uni.showToast({ title: '评价成功', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message, icon: 'none' })
}
},
toggleFollow() {
this.followed = !this.followed
},
toggleFav() {
this.faved = !this.faved
uni.showToast({ title: this.faved ? '已收藏' : '已取消收藏', icon: 'none' })
},
goVip() {
uni.navigateTo({ url: '/pages/member/index' })
},
openMap() {
uni.openLocation({
latitude: 26.647,
longitude: 106.63,
name: this.post.address,
address: this.post.area,
fail: () => uni.showToast({ title: '导航需在小程序内使用', icon: 'none' }),
})
},
callPhone() {
if (!this.unlocked) return this.payView(this.payType)
uni.makePhoneCall({ phoneNumber: this.contact.phone })
},
copyWechat() {
uni.setClipboardData({ data: this.contact.wechat })
uni.showToast({ title: '微信号已复制', icon: 'none' })
},
onShare() {
uni.showToast({ title: '已生成分享链接', icon: 'none' })
},
},
}
</script>
<style lang="scss" scoped>
.detail {
padding-bottom: 40rpx;
}
.detail__hero {
padding: 32rpx 24rpx 0;
}
.detail__hero-thumb {
height: 420rpx;
border-radius: $fc-radius-lg;
background: $fc-gradient-soft;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.detail__hero-img {
width: 240rpx;
height: 240rpx;
}
.detail__hero-badges {
display: flex;
margin-top: 20rpx;
}
.detail__badge {
margin-right: 12rpx;
}
.detail__badge--paid {
background: $fc-gradient-warm;
color: #ffffff;
}
.detail__head {
margin-top: 20rpx;
}
.detail__title {
font-size: $fc-font-title;
font-weight: 700;
color: $fc-text;
line-height: 1.35;
}
.detail__price-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
margin-top: 20rpx;
}
.detail__amount {
font-size: 48rpx;
}
.detail__unit {
margin-left: 6rpx;
font-size: $fc-font-xs;
color: $fc-text-secondary;
font-weight: 400;
}
.detail__neg {
font-size: 40rpx;
}
.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: 16rpx;
font-size: $fc-font-sm;
color: $fc-text-secondary;
}
.detail__publisher {
display: flex;
align-items: center;
}
.detail__avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: $fc-gradient-warm;
color: #ffffff;
font-size: 36rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.detail__publisher-info {
flex: 1;
margin-left: 20rpx;
}
.detail__publisher-name-row {
display: flex;
align-items: center;
}
.detail__publisher-name {
font-size: $fc-font-lg;
font-weight: 600;
color: $fc-text;
}
.detail__verified {
margin-left: 12rpx;
background: #e9f9ee;
color: $fc-success;
}
.detail__publisher-sub {
display: block;
margin-top: 6rpx;
font-size: $fc-font-xs;
color: $fc-text-secondary;
}
.detail__follow {
padding: 14rpx 28rpx;
border-radius: $fc-radius-pill;
border: 2rpx solid $fc-primary-text;
color: $fc-primary-text;
font-size: $fc-font-sm;
}
.detail__section-title {
font-size: $fc-font-lg;
font-weight: 700;
color: $fc-text;
}
.detail__content {
display: block;
margin-top: 18rpx;
font-size: $fc-font;
color: $fc-text-regular;
line-height: 1.7;
}
.detail__tags {
display: flex;
flex-wrap: wrap;
margin-top: 18rpx;
}
.detail__tag {
margin: 0 12rpx 12rpx 0;
}
.detail__map {
margin-top: 24rpx;
display: flex;
align-items: center;
background: $fc-bg;
border-radius: $fc-radius;
padding: 20rpx;
}
.detail__map-bg {
width: 120rpx;
height: 90rpx;
border-radius: 12rpx;
background: linear-gradient(135deg, #d8f1e8, #93d6c2);
display: flex;
align-items: center;
justify-content: center;
}
.detail__map-pin {
font-size: 36rpx;
}
.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: 8rpx;
font-size: $fc-font-xs;
color: $fc-primary-text;
}
.detail__locked {
display: flex;
flex-direction: column;
align-items: center;
padding: 32rpx 0 8rpx;
}
.detail__locked-icon {
font-size: 64rpx;
}
.detail__locked-text {
margin-top: 16rpx;
font-size: $fc-font-sm;
color: $fc-text-secondary;
text-align: center;
}
.detail__locked-actions {
display: flex;
margin-top: 28rpx;
}
/* 支付方式选择(付费查看) */
.detail__pay-type {
display: flex;
margin-top: 24rpx;
}
.detail__pay-item {
min-width: 168rpx;
height: 66rpx;
padding: 0 24rpx;
margin-right: 16rpx;
border-radius: $fc-radius;
border: 2rpx solid $fc-border;
background: $fc-bg;
display: flex;
align-items: center;
justify-content: center;
font-size: $fc-font-sm;
color: $fc-text-regular;
&--active {
border-color: $fc-primary-text;
background: $fc-primary-lighter;
color: $fc-primary-text;
font-weight: 600;
}
}
.detail__locked-btn {
padding: 18rpx 40rpx;
border-radius: $fc-radius-pill;
background: $fc-gradient-primary;
color: $fc-primary-ink;
font-size: $fc-font-sm;
font-weight: 600;
&--vip {
margin-left: 20rpx;
background: $fc-gradient-warm;
}
}
.detail__contact-row {
display: flex;
align-items: center;
justify-content: space-between;
height: 80rpx;
border-bottom: 2rpx solid $fc-border;
}
.detail__contact-label {
font-size: $fc-font;
color: $fc-text-secondary;
}
.detail__contact-value {
font-size: $fc-font-lg;
font-weight: 600;
color: $fc-primary-text;
}
.detail__contact-actions {
display: flex;
margin-top: 24rpx;
}
.detail__contact-btn {
flex: 1;
height: 84rpx;
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;
&--ghost {
margin-left: 20rpx;
background: #ffffff;
border: 2rpx solid $fc-primary-text;
color: $fc-primary-text;
}
}
.detail__cmt-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.detail__cmt-write {
font-size: $fc-font-sm;
color: $fc-primary-text;
}
.detail__cmt {
margin-top: 24rpx;
padding-top: 20rpx;
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__btn-text {
margin-left: 10rpx;
}
.detail__cmt-score {
display: flex;
align-items: center;
}
.detail__cmt-content {
display: block;
margin-top: 12rpx;
font-size: $fc-font;
color: $fc-text-regular;
line-height: 1.6;
}
.detail__cmt-reply {
display: block;
margin-top: 12rpx;
padding: 14rpx 20rpx;
border-radius: $fc-radius-sm;
background: $fc-bg;
font-size: $fc-font-sm;
color: $fc-text-secondary;
}
.detail__cmt-empty {
display: block;
margin-top: 24rpx;
text-align: center;
font-size: $fc-font-sm;
color: $fc-text-placeholder;
}
.detail__bar-space {
height: 160rpx;
}
.detail__bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
padding: 14rpx 24rpx;
background: #ffffff;
border-top: 2rpx solid $fc-border;
box-shadow: 0 -6rpx 24rpx rgba(4, 48, 30, 0.08);
}
.detail__bar-item {
width: 110rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.detail__bar-text {
margin-top: 4rpx;
font-size: 20rpx;
color: $fc-text-secondary;
}
.detail__bar-main {
flex: 1;
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 8rpx 24rpx rgba(4, 48, 30, 0.3);
}
.detail__mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
z-index: 200;
background: rgba(4, 48, 42, 0.5);
display: flex;
align-items: flex-end;
}
.detail__sheet {
width: 100%;
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
padding: 36rpx 32rpx 60rpx;
}
.detail__sheet-title {
display: block;
text-align: center;
font-size: $fc-font-lg;
font-weight: 700;
}
.detail__stars {
display: flex;
justify-content: center;
margin: 28rpx 0;
}
.detail__star-img {
width: 56rpx;
height: 56rpx;
margin: 0 10rpx;
}
.detail__sheet-input {
width: 100%;
height: 180rpx;
background: $fc-bg;
border-radius: $fc-radius;
padding: 22rpx 24rpx;
font-size: $fc-font;
}
.detail__sheet-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;
}
.detail__poster {
width: 100%;
background: #ffffff;
border-radius: 32rpx 32rpx 0 0;
padding: 44rpx 40rpx 60rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.detail__poster-head {
width: 100%;
border-radius: $fc-radius-lg;
background: $fc-gradient-hero;
padding: 40rpx 32rpx;
display: flex;
flex-direction: column;
}
.detail__poster-title {
color: #ffffff;
font-size: $fc-font-xl;
font-weight: 700;
}
.detail__poster-price {
margin-top: 20rpx;
color: #ffffff;
font-size: 52rpx;
font-weight: 700;
}
.detail__poster-tip {
margin-top: 28rpx;
font-size: $fc-font-sm;
color: $fc-text-secondary;
}
.detail__poster-qr {
margin-top: 24rpx;
width: 100%;
height: 140rpx;
border-radius: $fc-radius;
background: $fc-bg;
display: flex;
align-items: center;
justify-content: center;
color: $fc-primary-text;
font-size: $fc-font;
}
.detail__poster-qr-text {
margin-left: 12rpx;
}
</style>