| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622 |
- <template>
- <view class="review-list-page client-theme client-subpage">
- <view class="client-subpage-hero review-list-hero">
- <text class="client-subpage-hero__title">商品评价</text>
- <text class="client-subpage-hero__sub">{{ product?.name || '查看买家真实反馈' }}</text>
- </view>
- <scroll-view
- scroll-y
- class="client-subpage-scroll review-list-scroll"
- :show-scrollbar="true"
- @scrolltolower="loadMore"
- >
- <view class="client-subpage-body review-list-body">
- <ClientSkeleton v-if="initialLoading" variant="list" :rows="3" />
- <template v-else>
- <!-- 商品信息卡片 -->
- <view class="product-card">
- <ClientImageLoader class="product-image" :src="product?.imageUrl || IMG_DEFAULT_PRODUCT" mode="aspectFill" radius="8rpx" />
- <view class="product-info">
- <text class="product-name">{{ product?.name || '' }}</text>
- <text class="product-price">¥{{ formatPrice(product?.price || 0) }}</text>
- </view>
- </view>
- <!-- 评价统计 -->
- <view class="review-stats">
- <view class="avg-rating">
- <text class="score">{{ reviewStats?.avgRating?.toFixed(1) || '0.0' }}</text>
- <text class="label">分</text>
- </view>
- <view class="rating-bars">
- <view v-for="i in 5" :key="i" class="rating-bar-item">
- <text class="star-num">{{ 6 - i }}星</text>
- <view class="bar-wrapper">
- <view class="bar" :style="{ width: getBarWidth(6 - i) }"></view>
- </view>
- <text class="count">{{ reviewStats?.ratingDistribution?.[String(6 - i)] || 0 }}</text>
- </view>
- </view>
- </view>
- <!-- 筛选标签 -->
- <view class="filter-tabs">
- <view
- v-for="tab in tabs"
- :key="tab.key"
- class="tab-item"
- :class="{ active: currentTab === tab.key }"
- @click="switchTab(tab.key)"
- >
- <text>{{ tab.label }}</text>
- <text v-if="tab.count !== undefined" class="tab-count">{{ tab.count }}</text>
- </view>
- </view>
- <!-- 评价列表 -->
- <view v-for="review in reviewList" :key="review.id" class="review-item" hover-class="review-item--pressed" :hover-stay-time="100">
- <!-- 用户信息 -->
- <view class="review-header">
- <view class="user-info">
- <view class="avatar">{{ getAvatarText(review.userName) }}</view>
- <view class="user-detail">
- <text class="username">{{ review.isAnonymous ? '匿名用户' : review.userName }}</text>
- <van-rate :value="review.rating" readonly size="12" />
- </view>
- </view>
- <text class="review-time">{{ formatTime(review.createAt) }}</text>
- </view>
- <!-- 评价内容 -->
- <view class="review-content">
- <text v-if="review.ratingTags" class="tags">{{ formatTags(review.ratingTags) }}</text>
- <text class="content-text">{{ review.content }}</text>
- </view>
- <!-- 媒体 -->
- <view v-if="review.mediaList?.length" class="review-media">
- <view
- v-for="media in review.mediaList"
- :key="media.id"
- class="media-item"
- @click="previewImage(media.fileUrl)"
- >
- <ClientImageLoader v-if="media.mediaType === 'image'" :src="media.fileUrl" mode="aspectFill" class="media-image" />
- <video v-else :src="media.fileUrl" controls />
- </view>
- </view>
- <!-- 商家回复 -->
- <view v-if="review.replyContent" class="seller-reply">
- <text class="reply-label">商家回复:</text>
- <text class="reply-text">{{ review.replyContent }}</text>
- <text v-if="review.replyTime" class="reply-time">{{ formatTime(review.replyTime) }}</text>
- </view>
- <!-- 点赞 -->
- <view class="review-actions">
- <view class="like-btn" @click="handleLike(review)">
- <text class="icon">👍</text>
- <text class="count">{{ review.likeCount || 0 }}</text>
- </view>
- </view>
- </view>
- <!-- 加载更多 -->
- <view v-if="loading" class="loading-more">
- <van-loading size="20px">加载中...</van-loading>
- </view>
- <view v-else-if="noMore" class="no-more">
- <text>没有更多了</text>
- </view>
- <!-- 空状态 -->
- <view v-if="!loading && reviewList.length === 0" class="empty-state">
- <image class="empty-image" :src="IMG_EMPTY" mode="aspectFit" />
- <text class="empty-text">暂无评价</text>
- </view>
- </template>
- </view>
- </scroll-view>
- </view>
- </template>
- <script setup>
- import { ref, computed, onMounted } from 'vue'
- import { onLoad, onShow } from '@dcloudio/uni-app'
- import { useUserStore } from '@/stores/user'
- import { fetchProductReviews, fetchLikeReview, fetchReviewStatistics } from '@/api/reviewApi'
- import { formatFenAsYuan } from '@/utils/format'
- import { IMG_DEFAULT_PRODUCT, IMG_EMPTY } from '@/utils/staticAsset'
- import ClientSkeleton from '@/components/client/client-skeleton.vue'
- import ClientImageLoader from '@/components/client/client-image-loader.vue'
- const userStore = useUserStore()
- // 商品ID
- let productId = ''
- // 商品信息
- const product = ref(null)
- // 评价统计
- const reviewStats = ref(null)
- // 评价列表
- const reviewList = ref([])
- const loading = ref(false)
- const initialLoading = ref(true)
- const page = ref(1)
- const pageSize = 10
- const noMore = ref(false)
- // 筛选标签
- const currentTab = ref('all')
- const tabs = computed(() => [
- { key: 'all', label: '全部', count: reviewStats.value?.totalCount },
- { key: 'hasMedia', label: '有图', count: undefined }
- ])
- // 格式化价格
- const formatPrice = (fen) => formatFenAsYuan(fen)
- // 格式化时间
- const formatTime = (time) => {
- if (!time) return ''
- return time.slice(0, 10)
- }
- // 格式化标签
- const formatTags = (tags) => {
- return tags.split(',').filter(Boolean).map((t) => `#${t.trim()}`).join(' ')
- }
- // 获取头像文字
- const getAvatarText = (name) => {
- if (!name) return '?'
- return name.slice(-2)
- }
- // 获取评分条宽度
- const getBarWidth = (star) => {
- const total = reviewStats.value?.totalCount || 1
- const count = reviewStats.value?.ratingDistribution?.[String(star)] || 0
- return `${(count / total) * 100}%`
- }
- // 切换标签
- const switchTab = async (key) => {
- currentTab.value = key
- page.value = 1
- noMore.value = false
- reviewList.value = []
- await loadReviews()
- }
- // 加载评价
- const loadReviews = async () => {
- if (loading.value || noMore.value) return
- loading.value = true
- try {
- const res = await fetchProductReviews(productId, {
- page: page.value,
- pageSize,
- hasMedia: currentTab.value === 'hasMedia' ? 1 : undefined
- })
- const list = (res && res.list) || []
- if (page.value === 1) {
- reviewList.value = list
- } else {
- reviewList.value = [...reviewList.value, ...list]
- }
- noMore.value = list.length < pageSize
- page.value++
- } catch (e) {
- console.error('加载评价失败', e)
- } finally {
- loading.value = false
- }
- }
- // 加载更多
- const loadMore = () => {
- if (!noMore.value) {
- loadReviews()
- }
- }
- // 点赞
- const handleLike = async (review) => {
- try {
- const res = await fetchLikeReview(review.id)
- if (res && typeof res.likeCount === 'number') review.likeCount = res.likeCount
- } catch (e) {
- console.error('点赞失败', e)
- }
- }
- // 预览图片
- const previewImage = (url) => {
- const images = reviewList.value
- .flatMap((r) => r.mediaList || [])
- .filter((m) => m.mediaType === 'image')
- .map((m) => m.fileUrl)
- uni.previewImage({
- current: url,
- urls: images
- })
- }
- // 加载评价统计
- const loadStatistics = async () => {
- try {
- reviewStats.value = await fetchReviewStatistics(productId)
- } catch (e) {
- console.error('加载评价统计失败', e)
- }
- }
- const refreshReviewData = async () => {
- page.value = 1
- noMore.value = false
- reviewList.value = []
- await Promise.all([loadReviews(), loadStatistics()])
- }
- onLoad((options) => {
- if (options.productId) {
- productId = options.productId
- }
- if (options.product) {
- try {
- product.value = JSON.parse(decodeURIComponent(options.product))
- } catch (e) {
- console.error('解析商品信息失败', e)
- }
- }
- })
- onMounted(async () => {
- userStore.checkLoginStatus()
- if (!userStore.isLoggedIn) {
- uni.reLaunch({ url: '/pages/login/login' })
- return
- }
- if (!userStore.isClient) {
- uni.showToast({ title: '请使用客户账号登录', icon: 'none' })
- setTimeout(() => userStore.navigateToHome(), 1200)
- return
- }
- try {
- await refreshReviewData()
- } finally {
- initialLoading.value = false
- }
- })
- onShow(() => {
- userStore.checkLoginStatus()
- if (!userStore.isLoggedIn || !userStore.isClient) return
- void refreshReviewData()
- })
- </script>
- <style lang="scss" scoped>
- .review-list-page {
- min-height: 100vh;
- background: var(--c-bg);
- box-sizing: border-box;
- }
- .review-list-scroll {
- flex: 1;
- min-height: 0;
- height: 0;
- }
- .review-item--pressed {
- opacity: 0.94;
- }
- .product-card {
- display: flex;
- padding: 20rpx;
- background: var(--c-card);
- border: 1rpx solid var(--c-border);
- border-radius: var(--c-radius-card);
- margin: 0 0 20rpx;
- box-sizing: border-box;
- .product-image {
- width: 120rpx;
- height: 120rpx;
- border-radius: 8rpx;
- background: #f5f7fa;
- }
- .product-info {
- flex: 1;
- margin-left: 20rpx;
- display: flex;
- flex-direction: column;
- justify-content: center;
- .product-name {
- font-size: 28rpx;
- color: var(--c-text);
- margin-bottom: 10rpx;
- }
- .product-price {
- font-size: 32rpx;
- color: var(--c-danger);
- font-weight: bold;
- }
- }
- }
- .review-stats {
- display: flex;
- padding: 30rpx 20rpx;
- background: var(--c-card);
- border: 1rpx solid var(--c-border);
- border-radius: var(--c-radius-card);
- margin: 0 0 20rpx;
- box-sizing: border-box;
- .avg-rating {
- display: flex;
- align-items: baseline;
- padding-right: 30rpx;
- border-right: 1px solid #ebeef5;
- .score {
- font-size: 60rpx;
- color: var(--c-barley-yellow);
- font-weight: bold;
- }
- .label {
- font-size: 24rpx;
- color: #909399;
- margin-left: 8rpx;
- }
- }
- .rating-bars {
- flex: 1;
- margin-left: 30rpx;
- .rating-bar-item {
- display: flex;
- align-items: center;
- margin-bottom: 10rpx;
- .star-num {
- font-size: 22rpx;
- color: #606266;
- width: 60rpx;
- }
- .bar-wrapper {
- flex: 1;
- height: 12rpx;
- background: #ebeef5;
- border-radius: 6rpx;
- margin: 0 10rpx;
- overflow: hidden;
- .bar {
- height: 100%;
- background: var(--c-barley-yellow);
- border-radius: 6rpx;
- }
- }
- .count {
- font-size: 22rpx;
- color: #909399;
- width: 40rpx;
- text-align: right;
- }
- }
- }
- }
- .filter-tabs {
- display: flex;
- padding: 20rpx;
- background: var(--c-card);
- border: 1rpx solid var(--c-border);
- border-radius: var(--c-radius-card);
- margin: 0 0 20rpx;
- box-sizing: border-box;
- flex-wrap: wrap;
- gap: 12rpx;
- .tab-item {
- padding: 10rpx 30rpx;
- font-size: 26rpx;
- color: #606266;
- border-radius: 20rpx;
- background: #f5f7fa;
- margin-right: 20rpx;
- &.active {
- color: #fff;
- background: var(--c-brand-green);
- }
- .tab-count {
- margin-left: 8rpx;
- }
- }
- }
- .review-item {
- background: var(--c-card);
- border: 1rpx solid var(--c-border);
- border-radius: var(--c-radius-card);
- padding: 24rpx;
- margin-bottom: 20rpx;
- box-sizing: border-box;
- .review-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- margin-bottom: 16rpx;
- .user-info {
- display: flex;
- align-items: center;
- .avatar {
- width: 60rpx;
- height: 60rpx;
- border-radius: 50%;
- background: var(--c-brand-green);
- color: #fff;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 24rpx;
- margin-right: 16rpx;
- }
- .user-detail {
- display: flex;
- flex-direction: column;
- .username {
- font-size: 26rpx;
- color: var(--c-text);
- margin-bottom: 6rpx;
- }
- }
- }
- .review-time {
- font-size: 22rpx;
- color: #909399;
- }
- }
- .review-content {
- .tags {
- display: inline-block;
- font-size: 24rpx;
- color: var(--c-brand-green);
- background: var(--c-brand-green-soft);
- padding: 4rpx 12rpx;
- border-radius: 999rpx;
- margin-bottom: 12rpx;
- }
- .content-text {
- display: block;
- font-size: 26rpx;
- color: var(--c-text);
- line-height: 1.6;
- }
- }
- .review-media {
- display: flex;
- flex-wrap: wrap;
- gap: 12rpx;
- margin-top: 16rpx;
- .media-item {
- width: 180rpx;
- height: 180rpx;
- border-radius: 8rpx;
- overflow: hidden;
- image, video, .media-image {
- width: 100%;
- height: 100%;
- }
- }
- }
- .seller-reply {
- margin-top: 16rpx;
- padding: 16rpx;
- background: #f5f7fa;
- border-radius: 8rpx;
- font-size: 24rpx;
- .reply-label {
- color: #909399;
- }
- .reply-text {
- color: var(--c-text);
- margin-left: 8rpx;
- }
- .reply-time {
- display: block;
- color: #c0c4cc;
- margin-top: 8rpx;
- font-size: 22rpx;
- }
- }
- .review-actions {
- display: flex;
- justify-content: flex-end;
- margin-top: 16rpx;
- padding-top: 16rpx;
- border-top: 1px solid #f5f7fa;
- .like-btn {
- display: flex;
- align-items: center;
- padding: 8rpx 20rpx;
- background: #f5f7fa;
- border-radius: 20rpx;
- font-size: 24rpx;
- color: #606266;
- .icon {
- margin-right: 8rpx;
- }
- }
- }
- }
- .loading-more, .no-more {
- text-align: center;
- padding: 30rpx;
- color: #909399;
- font-size: 24rpx;
- }
- .empty-state {
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- padding: 72rpx 0;
- .empty-image {
- width: 140rpx;
- height: 140rpx;
- margin-bottom: 12rpx;
- }
- .empty-text {
- font-size: 28rpx;
- color: #909399;
- text-align: center;
- line-height: 1.2;
- }
- }
- </style>
|