consumer模块完成度95%,能编译在安卓端运行,在解决数据获取和页面布局问题

This commit is contained in:
cyh666666
2026-02-27 08:20:43 +08:00
parent e606c597ca
commit b9acce6c35
1554 changed files with 23471 additions and 8551 deletions

View File

@@ -0,0 +1,288 @@
<!-- 商家端 - 评价管理页面 -->
<template>
<view class="reviews-page">
<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>