597 lines
14 KiB
Plaintext
597 lines
14 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'
|
||
import { USE_MOCK, MOCK_MERCHANT_ID, getMockReviews } from '@/pages/mall/merchant/mock/merchant-mock-data.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() {
|
||
if (USE_MOCK) {
|
||
this.merchantId = MOCK_MERCHANT_ID
|
||
this.loadReviews()
|
||
return
|
||
}
|
||
// 同步设置 merchantId,不用 async 包裹,避免 generator 内 this 绑定异常
|
||
try {
|
||
const session = supa.getSession()
|
||
this.merchantId = session?.user?.getString('id') || uni.getStorageSync('user_id') || ''
|
||
} catch (e) {}
|
||
this.loadReviews()
|
||
},
|
||
|
||
onShow() {
|
||
this.loadReviews()
|
||
},
|
||
|
||
methods: {
|
||
async loadReviews() {
|
||
if (!this.merchantId || this.merchantId.split('-').length !== 5) return
|
||
if (this.loading) return
|
||
this.loading = true
|
||
if (USE_MOCK) {
|
||
this.reviews = getMockReviews(this.currentFilter) as ReviewType[]
|
||
this.hasMore = false
|
||
this.loading = false
|
||
this.refreshing = false
|
||
return
|
||
}
|
||
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++
|
||
const resetMore = () => { this.loadingMore = false }
|
||
this.loadReviews().then(resetMore).catch(resetMore)
|
||
}
|
||
},
|
||
|
||
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 {
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: #f5f5f5;
|
||
min-height: 100vh;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.filter-tabs {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background-color: #ffffff;
|
||
padding: 0 20rpx;
|
||
margin-bottom: 20rpx;
|
||
border-bottom: 1rpx solid #f2f2f2;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.filter-tab {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 88rpx;
|
||
font-size: 26rpx;
|
||
color: #666666;
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.filter-tab.active {
|
||
color: #09C39D;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.filter-tab.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: 0;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
width: 44rpx;
|
||
height: 4rpx;
|
||
background-color: #09C39D;
|
||
border-radius: 2rpx;
|
||
}
|
||
|
||
.reviews-list {
|
||
flex: 1;
|
||
height: calc(100vh - 176rpx);
|
||
padding: 0 20rpx 20rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.loading-container,
|
||
.empty-container {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 120rpx 0;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 100rpx;
|
||
line-height: 1;
|
||
margin-bottom: 20rpx;
|
||
}
|
||
|
||
.empty-text,
|
||
.loading-text {
|
||
font-size: 28rpx;
|
||
color: #999999;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.review-card {
|
||
background-color: #ffffff;
|
||
border-radius: 16rpx;
|
||
margin-bottom: 20rpx;
|
||
padding: 24rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.review-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
margin-bottom: 20rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.user-avatar {
|
||
width: 72rpx;
|
||
height: 72rpx;
|
||
border-radius: 36rpx;
|
||
margin-right: 16rpx;
|
||
background-color: #f5f5f5;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.user-info {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.user-name {
|
||
font-size: 26rpx;
|
||
color: #333333;
|
||
font-weight: 500;
|
||
line-height: 1.4;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.rating {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
flex-wrap: nowrap;
|
||
}
|
||
|
||
.star {
|
||
font-size: 22rpx;
|
||
line-height: 1;
|
||
color: #dddddd;
|
||
margin-right: 4rpx;
|
||
}
|
||
|
||
.star.filled {
|
||
color: #ffb800;
|
||
}
|
||
|
||
.review-time {
|
||
margin-left: 16rpx;
|
||
font-size: 22rpx;
|
||
color: #999999;
|
||
line-height: 1.4;
|
||
text-align: right;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.review-product {
|
||
margin-bottom: 12rpx;
|
||
}
|
||
|
||
.product-name {
|
||
font-size: 24rpx;
|
||
color: #666666;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.review-content {
|
||
font-size: 26rpx;
|
||
color: #333333;
|
||
line-height: 1.6;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.review-images {
|
||
display: flex;
|
||
flex-direction: row;
|
||
flex-wrap: wrap;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.review-image {
|
||
width: 120rpx;
|
||
height: 120rpx;
|
||
border-radius: 8rpx;
|
||
margin-right: 12rpx;
|
||
margin-bottom: 12rpx;
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
.review-reply {
|
||
display: flex;
|
||
flex-direction: column;
|
||
background-color: #f7f8fa;
|
||
padding: 18rpx 20rpx;
|
||
border-radius: 10rpx;
|
||
margin-top: 16rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.reply-label {
|
||
font-size: 24rpx;
|
||
color: #09C39D;
|
||
font-weight: 500;
|
||
line-height: 1.4;
|
||
margin-bottom: 8rpx;
|
||
}
|
||
|
||
.reply-content {
|
||
font-size: 24rpx;
|
||
color: #666666;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.review-actions {
|
||
display: flex;
|
||
flex-direction: row;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
margin-top: 16rpx;
|
||
}
|
||
|
||
.action-btn {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 12rpx 24rpx;
|
||
font-size: 24rpx;
|
||
line-height: 1.4;
|
||
background-color: #e8f0fe;
|
||
color: #09C39D;
|
||
border-radius: 24rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.load-more {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 30rpx 0;
|
||
}
|
||
|
||
.load-more-text {
|
||
font-size: 24rpx;
|
||
color: #999999;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.modal-mask {
|
||
position: fixed;
|
||
left: 0;
|
||
right: 0;
|
||
top: 0;
|
||
bottom: 0;
|
||
background-color: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
z-index: 1000;
|
||
}
|
||
|
||
.modal-content {
|
||
width: 100%;
|
||
background-color: #ffffff;
|
||
border-radius: 24rpx 24rpx 0 0;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 30rpx;
|
||
border-bottom: 1rpx solid #f2f2f2;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.modal-title {
|
||
font-size: 32rpx;
|
||
font-weight: bold;
|
||
color: #333333;
|
||
line-height: 1.4;
|
||
}
|
||
|
||
.modal-close {
|
||
font-size: 44rpx;
|
||
color: #999999;
|
||
line-height: 1;
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 30rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.reply-input {
|
||
width: 100%;
|
||
height: 220rpx;
|
||
border: 1rpx solid #e5e5e5;
|
||
border-radius: 12rpx;
|
||
padding: 20rpx;
|
||
font-size: 28rpx;
|
||
line-height: 1.6;
|
||
box-sizing: border-box;
|
||
background-color: #ffffff;
|
||
}
|
||
|
||
.modal-footer {
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
border-top: 1rpx solid #f2f2f2;
|
||
}
|
||
|
||
.modal-btn {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 88rpx;
|
||
font-size: 28rpx;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.modal-btn.cancel {
|
||
color: #666666;
|
||
border-right: 1rpx solid #f2f2f2;
|
||
}
|
||
|
||
.modal-btn.confirm {
|
||
color: #09C39D;
|
||
font-weight: bold;
|
||
}
|
||
</style> |