Files
medical-mall/pages/mall/merchant/reviews.uvue
2026-04-13 11:32:31 +08:00

597 lines
14 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'
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>