Files
medical-mall/pages/mall/merchant/reviews.uvue

297 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 商家端 - 评价管理页面 -->
<template>
<view class="reviews-page">
<!-- #ifdef MP-WEIXIN -->
<view style="padding-top: var(--status-bar-height); background-color: #ffffff; display: flex; flex-direction: row; align-items: flex-end; border-bottom: 1rpx solid #eeeeee; box-sizing: border-box; height: calc(88rpx + var(--status-bar-height));">
<view style="display: flex; flex-direction: row; align-items: center; padding: 0 30rpx; height: 88rpx;" @click="uni.navigateBack()">
<text style="font-size: 44rpx; color: #333333; line-height: 1; margin-right: 6rpx;"></text>
<text style="font-size: 28rpx; color: #333333;">返回</text>
</view>
</view>
<!-- #endif -->
<view class="filter-tabs">
<view class="filter-tab" :class="{ active: currentFilter === 'all' }" @click="switchFilter('all')">全部</view>
<view class="filter-tab" :class="{ active: currentFilter === 'pending' }" @click="switchFilter('pending')">待回复</view>
<view class="filter-tab" :class="{ active: currentFilter === 'replied' }" @click="switchFilter('replied')">已回复</view>
</view>
<scroll-view class="reviews-list" scroll-y :refresher-enabled="true" :refresher-triggered="refreshing" @refresherrefresh="onRefresh" @scrolltolower="loadMore">
<view v-if="loading && reviews.length === 0" class="loading-container"><text class="loading-text">加载中...</text></view>
<view v-else-if="reviews.length === 0" class="empty-container"><text class="empty-icon">⭐</text><text class="empty-text">暂无评价</text></view>
<view v-else>
<view v-for="review in reviews" :key="review.id" class="review-card">
<view class="review-header">
<image :src="review.user_avatar || '/static/images/default-avatar.png'" class="user-avatar" mode="aspectFill"/>
<view class="user-info">
<text class="user-name">{{ review.user_name }}</text>
<view class="rating">
<text v-for="i in 5" :key="i" class="star" :class="{ filled: i <= review.rating }">★</text>
</view>
</view>
<text class="review-time">{{ formatTime(review.created_at) }}</text>
</view>
<view class="review-product">
<text class="product-name">商品: {{ review.product_name }}</text>
</view>
<view class="review-content">{{ review.content }}</view>
<view v-if="review.images" class="review-images">
<image v-for="(img, idx) in parseImages(review.images)" :key="idx" :src="img" class="review-image" mode="aspectFill" @click="previewImage(review.images, idx)"/>
</view>
<view v-if="review.reply" class="review-reply">
<text class="reply-label">商家回复:</text>
<text class="reply-content">{{ review.reply }}</text>
</view>
<view v-else class="review-actions">
<view class="action-btn" @click="replyReview(review)">回复评价</view>
</view>
</view>
</view>
<view v-if="loadingMore" class="load-more"><text class="load-more-text">加载中...</text></view>
</scroll-view>
<view v-if="showReplyModal" class="modal-mask" @click="closeReplyModal">
<view class="modal-content" @click.stop>
<view class="modal-header"><text class="modal-title">回复评价</text><text class="modal-close" @click="closeReplyModal">×</text></view>
<view class="modal-body">
<textarea class="reply-input" v-model="replyContent" placeholder="请输入回复内容" maxlength="200"/>
</view>
<view class="modal-footer">
<view class="modal-btn cancel" @click="closeReplyModal">取消</view>
<view class="modal-btn confirm" @click="submitReply">提交</view>
</view>
</view>
</view>
</view>
</template>
<script lang="uts">
import supa from '@/components/supadb/aksupainstance.uts'
type ReviewType = {
id: string
product_id: string
product_name: string
user_id: string
user_name: string
user_avatar: string
rating: number
content: string
images: string
reply: string
created_at: string
}
export default {
data() {
return {
currentFilter: 'all',
reviews: [] as ReviewType[],
loading: false,
loadingMore: false,
refreshing: false,
page: 1,
limit: 20,
hasMore: true,
merchantId: '',
showReplyModal: false,
currentReview: null as ReviewType | null,
replyContent: ''
}
},
onLoad() {
this.initMerchantId()
},
onShow() {
this.loadReviews()
},
methods: {
async initMerchantId() {
try {
const session = supa.getSession()
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
} catch (e) {}
},
async loadReviews() {
if (this.loading) return
this.loading = true
try {
let query = supa
.from('ml_product_reviews')
.select('*')
.eq('merchant_id', this.merchantId)
.order('created_at', { ascending: false })
.page(this.page)
.limit(this.limit)
const response = await query.execute()
if (response.error != null || !response.data) {
this.reviews = []
return
}
const rawData = response.data as any[]
const reviewsData: ReviewType[] = []
for (let i = 0; i < rawData.length; i++) {
const item = rawData[i] as UTSJSONObject
reviewsData.push({
id: item.getString('id') || '',
product_id: item.getString('product_id') || '',
product_name: item.getString('product_name') || '',
user_id: item.getString('user_id') || '',
user_name: item.getString('user_name') || '匿名用户',
user_avatar: item.getString('user_avatar') || '',
rating: item.getNumber('rating') || 5,
content: item.getString('content') || '',
images: item.getString('images') || '',
reply: item.getString('reply') || '',
created_at: item.getString('created_at') || ''
} as ReviewType)
}
if (this.page === 1) {
this.reviews = reviewsData
} else {
this.reviews = [...this.reviews, ...reviewsData]
}
this.hasMore = rawData.length >= this.limit
} catch (e) {
console.error('加载评价失败:', e)
} finally {
this.loading = false
this.refreshing = false
}
},
switchFilter(filter: string) {
this.currentFilter = filter
this.page = 1
this.hasMore = true
this.loadReviews()
},
onRefresh() {
this.refreshing = true
this.page = 1
this.loadReviews()
},
loadMore() {
if (!this.loadingMore && this.hasMore) {
this.loadingMore = true
this.page++
this.loadReviews().then(() => { this.loadingMore = false })
}
},
replyReview(review: ReviewType) {
this.currentReview = review
this.replyContent = ''
this.showReplyModal = true
},
closeReplyModal() {
this.showReplyModal = false
this.currentReview = null
this.replyContent = ''
},
async submitReply() {
if (!this.replyContent.trim()) {
uni.showToast({ title: '请输入回复内容', icon: 'none' })
return
}
try {
const response = await supa
.from('ml_product_reviews')
.update({
reply: this.replyContent,
reply_at: new Date().toISOString()
})
.eq('id', this.currentReview!.id)
.execute()
if (response.error != null) {
uni.showToast({ title: '回复失败', icon: 'none' })
return
}
uni.showToast({ title: '回复成功', icon: 'success' })
this.closeReplyModal()
this.loadReviews()
} catch (e) {
uni.showToast({ title: '回复失败', icon: 'none' })
}
},
parseImages(imagesStr: string): string[] {
if (!imagesStr) return []
try {
return JSON.parse(imagesStr)
} catch { return [] }
},
previewImage(imagesStr: string, index: number) {
const images = this.parseImages(imagesStr)
uni.previewImage({ urls: images, current: index })
},
formatTime(timeStr: string): string {
if (!timeStr) return ''
const date = new Date(timeStr)
return `${date.getMonth() + 1}-${date.getDate()}`
}
}
}
</script>
<style>
.reviews-page { background-color: #f5f5f5; min-height: 100vh; }
.filter-tabs { display: flex; background-color: #fff; padding: 0 20rpx; margin-bottom: 20rpx; }
.filter-tab { flex: 1; text-align: center; padding: 24rpx 0; font-size: 26rpx; color: #666; position: relative; }
.filter-tab.active { color: #007AFF; font-weight: bold; }
.filter-tab.active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 40rpx; height: 4rpx; background-color: #007AFF; border-radius: 2rpx; }
.reviews-list { padding: 0 20rpx; height: calc(100vh - 120rpx); }
.loading-container, .empty-container { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 100rpx 0; }
.empty-icon { font-size: 100rpx; margin-bottom: 20rpx; }
.empty-text, .loading-text { font-size: 28rpx; color: #999; }
.review-card { background-color: #fff; border-radius: 16rpx; margin-bottom: 20rpx; padding: 24rpx; }
.review-header { display: flex; align-items: center; margin-bottom: 20rpx; }
.user-avatar { width: 70rpx; height: 70rpx; border-radius: 50%; margin-right: 16rpx; background-color: #f5f5f5; }
.user-info { flex: 1; }
.user-name { font-size: 26rpx; color: #333; font-weight: 500; display: block; margin-bottom: 8rpx; }
.rating { display: flex; }
.star { font-size: 22rpx; color: #ddd; margin-right: 4rpx; }
.star.filled { color: #FFB800; }
.review-time { font-size: 22rpx; color: #999; }
.review-product { font-size: 24rpx; color: #666; margin-bottom: 12rpx; }
.review-content { font-size: 26rpx; color: #333; line-height: 1.5; margin-bottom: 16rpx; }
.review-images { display: flex; flex-wrap: wrap; gap: 12rpx; margin-bottom: 16rpx; }
.review-image { width: 120rpx; height: 120rpx; border-radius: 8rpx; }
.review-reply { background-color: #f5f5f5; padding: 16rpx; border-radius: 8rpx; margin-top: 16rpx; }
.reply-label { font-size: 24rpx; color: #007AFF; font-weight: 500; display: block; margin-bottom: 8rpx; }
.reply-content { font-size: 24rpx; color: #666; }
.review-actions { margin-top: 16rpx; text-align: right; }
.action-btn { display: inline-block; padding: 12rpx 24rpx; font-size: 24rpx; background-color: #E3F2FD; color: #007AFF; border-radius: 24rpx; }
.load-more { padding: 30rpx 0; text-align: center; }
.load-more-text { font-size: 24rpx; color: #999; }
.modal-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0.5); display: flex; align-items: flex-end; justify-content: center; z-index: 1000; }
.modal-content { width: 100%; background-color: #fff; border-radius: 24rpx 24rpx 0 0; padding-bottom: env(safe-area-inset-bottom); }
.modal-header { display: flex; justify-content: space-between; align-items: center; padding: 30rpx; border-bottom: 1rpx solid #f5f5f5; }
.modal-title { font-size: 32rpx; font-weight: bold; color: #333; }
.modal-close { font-size: 44rpx; color: #999; }
.modal-body { padding: 30rpx; }
.reply-input { width: 100%; height: 200rpx; border: 1rpx solid #e5e5e5; border-radius: 8rpx; padding: 20rpx; font-size: 28rpx; box-sizing: border-box; }
.modal-footer { display: flex; border-top: 1rpx solid #f5f5f5; }
.modal-btn { flex: 1; height: 88rpx; line-height: 88rpx; text-align: center; font-size: 28rpx; }
.modal-btn.cancel { color: #666; border-right: 1rpx solid #f5f5f5; }
.modal-btn.confirm { color: #007AFF; font-weight: bold; }
</style>