297 lines
11 KiB
Plaintext
297 lines
11 KiB
Plaintext
<!-- 商家端 - 评价管理页面 -->
|
||
<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>
|