Files
medical-mall/pages/mall/consumer/review.uvue

853 lines
20 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="review-page">
<!-- 顶部栏 -->
<view class="review-header">
<text class="back-btn" @click="goBack"></text>
<text class="header-title">评价商品</text>
</view>
<scroll-view class="review-content" direction="vertical">
<!-- 订单信息 -->
<view class="order-section">
<text class="order-no">订单号: {{ order != null ? order.order_no : '' }}</text>
<text class="order-time">下单时间: {{ formatTime(order != null ? order.created_at : '') }}</text>
</view>
<!-- 商品评价 -->
<view class="products-section">
<view v-for="(item, index) in orderItems" :key="item.id" class="product-review">
<view class="product-header">
<image class="product-image" :src="item.product_image ?? '/static/default-product.png'" />
<view class="product-info">
<text class="product-name">{{ item.product_name }}</text>
<text v-if="item.sku_specifications != null" class="product-spec">{{ getSpecText(item.sku_specifications) }}</text>
</view>
</view>
<!-- 评分 -->
<view class="rating-section">
<text class="rating-label">评分</text>
<view class="rating-stars">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= ratings[index] }"
@click="setRating(index, star)">
</text>
</view>
<text class="rating-text">{{ getRatingText(ratings[index]) }}</text>
</view>
<!-- 评价内容 -->
<view class="content-section">
<textarea class="review-textarea"
v-model="contents[index]"
placeholder="请写下您的使用感受,分享给其他小伙伴吧"
maxlength="500" />
<text class="word-count">{{ contents[index]?.length ?? 0 }}/500</text>
</view>
<!-- 图片上传 -->
<view class="images-section">
<text class="images-label">上传图片(可选)</text>
<view class="images-grid">
<view v-for="(image, imgIndex) in images[index]"
:key="imgIndex"
class="image-item">
<image class="uploaded-image" :src="image" />
<text class="delete-image" @click="deleteImage(index, imgIndex)">×</text>
</view>
<view v-if="images[index].length < 9"
class="upload-btn"
@click="uploadImage(index)">
<text class="upload-icon">+</text>
<text class="upload-text">添加图片</text>
</view>
</view>
</view>
<!-- 匿名评价 -->
<view class="anonymous-section">
<view class="anonymous-switch">
<text class="switch-label">匿名评价</text>
<switch :checked="anonymous" @change="toggleAnonymous" />
</view>
<text class="anonymous-tip">评价内容对其他用户不可见</text>
</view>
</view>
</view>
<!-- 店铺评价 -->
<view v-if="merchant" class="merchant-section">
<text class="section-title">店铺评价</text>
<view class="merchant-rating">
<text class="rating-item">商品描述相符</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.description }"
@click="setMerchantRating('description', star)">
</text>
</view>
</view>
<view class="merchant-rating">
<text class="rating-item">物流服务</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.logistics }"
@click="setMerchantRating('logistics', star)">
</text>
</view>
</view>
<view class="merchant-rating">
<text class="rating-item">服务态度</text>
<view class="rating-stars small">
<text v-for="star in 5"
:key="star"
class="star-icon"
:class="{ active: star <= merchantRating.service }"
@click="setMerchantRating('service', star)">
</text>
</view>
</view>
</view>
<!-- 评价提示 -->
<view class="tips-section">
<text class="tip-title">评价须知</text>
<text class="tip-item">1. 评价后不可修改,请谨慎评价</text>
<text class="tip-item">2. 上传图片需为真实商品照片</text>
<text class="tip-item">3. 恶意评价将被删除并限制评价功能</text>
<text class="tip-item">4. 优质评价可获得积分奖励</text>
</view>
</scroll-view>
<!-- 提交按钮 -->
<view class="submit-section">
<button class="submit-btn"
:class="{ disabled: canSubmit === false || isSubmitting }"
@click="submitReview">
<text v-if="isSubmitting === false" class="submit-text">提交评价</text>
<text v-else class="submit-text">提交中...</text>
</button>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import supa from '@/components/supadb/aksupainstance.uts'
import { supabaseService } from '@/utils/supabaseService.uts'
type OrderType = {
id: string
order_no: string
created_at: string
merchant_id: string
}
type OrderItemType = {
id: number
order_id: number
product_id: number
product_name: string
product_image: string
sku_specifications: any | null
price: number
quantity: number
}
type MerchantRatingType = {
description: number
logistics: number
service: number
}
type MerchantType = {
id: string
shop_name: string
rating: number
}
const orderId = ref<string>('')
const order = ref<OrderType | null>(null)
const orderItems = ref<Array<OrderItemType>>([])
const merchant = ref<MerchantType | null>(null)
const ratings = ref<Array<number>>([])
const contents = ref<Array<string>>([])
const images = ref<Array<Array<string>>>([])
const anonymous = ref<boolean>(false)
const merchantRating = ref<MerchantRatingType>({
description: 5,
logistics: 5,
service: 5
} as MerchantRatingType)
const isSubmitting = ref<boolean>(false)
const loadOrderData = async (): Promise<void> => {
try {
const orderRes = await supa
.from('ml_orders')
.select('*')
.eq('id', orderId.value)
.single()
.execute()
if (orderRes.error != null) {
console.error('加载订单失败:', orderRes.error)
return
}
if (orderRes.data != null) {
const orderData = orderRes.data as UTSJSONObject
order.value = {
id: orderData.getString('id') ?? '',
order_no: orderData.getString('order_no') ?? '',
created_at: orderData.getString('created_at') ?? '',
merchant_id: orderData.getString('merchant_id') ?? ''
} as OrderType
}
const itemsRes = await supa
.from('ml_order_items')
.select(`
*,
product:product_id(images)
`)
.eq('order_id', orderId.value)
.execute()
if (itemsRes.error != null) {
console.error('加载订单商品失败:', itemsRes.error)
return
}
const rawData = itemsRes.data
let itemsArray: Array<any> = []
if (rawData != null) {
itemsArray = rawData as Array<any>
}
const processedItems: Array<OrderItemType> = []
for (let i: number = 0; i < itemsArray.length; i++) {
const item = itemsArray[i] as UTSJSONObject
const productObjRaw = item.get('product')
const productObj = (productObjRaw != null) ? (productObjRaw as UTSJSONObject) : null
const imagesArrRaw = (productObj != null) ? productObj.get('images') : null
const imagesArr = (imagesArrRaw != null) ? (imagesArrRaw as Array<string>) : []
const firstImage = (imagesArr.length > 0) ? imagesArr[0] : '/static/default-product.png'
const skuSpecRaw = item.get('sku_specifications')
const skuSpec = (skuSpecRaw != null) ? (skuSpecRaw as any) : null
const processedItem: OrderItemType = {
id: (item.getNumber('id') ?? 0) as number,
order_id: (item.getNumber('order_id') ?? 0) as number,
product_id: (item.getNumber('product_id') ?? 0) as number,
product_name: item.getString('product_name') ?? '',
price: (item.getNumber('price') ?? 0) as number,
quantity: (item.getNumber('quantity') ?? 1) as number,
sku_specifications: skuSpec,
product_image: firstImage
}
processedItems.push(processedItem)
}
orderItems.value = processedItems
const count = orderItems.value.length
const newRatings: Array<number> = []
const newContents: Array<string> = []
const newImages: Array<Array<string>> = []
for (let i: number = 0; i < count; i++) {
newRatings.push(5)
newContents.push('')
newImages.push([])
}
ratings.value = newRatings
contents.value = newContents
images.value = newImages
const orderObj = order.value as UTSJSONObject
const merchantId = orderObj.getString('merchant_id')
if (merchantId != null && merchantId !== '') {
const merchantRes = await supa
.from('ml_shops')
.select('id, shop_name, rating')
.eq('id', merchantId)
.single()
.execute()
if (merchantRes.error == null && merchantRes.data != null) {
merchant.value = merchantRes.data as MerchantType
}
}
} catch (err) {
console.error('加载订单数据异常:', err)
}
}
const canSubmit = computed((): boolean => {
if (ratings.value.length === 0) return false
for (let i: number = 0; i < ratings.value.length; i++) {
if (ratings.value[i] <= 0) return false
}
return true
})
onLoad((options: any) => {
if (options != null) {
const optObj = options as UTSJSONObject
orderId.value = optObj.getString('orderId') ?? ''
if (orderId.value != '') loadOrderData()
}
})
// 格式化时间
const formatTime = (timeStr?: string): string => {
if (timeStr == null) return ''
const date = new Date(timeStr)
const year = date.getFullYear()
const month = (date.getMonth() + 1).toString().padStart(2, '0')
const day = date.getDate().toString().padStart(2, '0')
return `${year}-${month}-${day}`
}
const getSpecText = (specs: any | null): string => {
if (specs == null) return ''
if (specs instanceof UTSJSONObject) {
return '规格信息'
}
return specs as string
}
// 获取评分文本
const getRatingText = (rating: number): string => {
if (rating === 1) return '非常差'
if (rating === 2) return '差'
if (rating === 3) return '一般'
if (rating === 4) return '好'
if (rating === 5) return '非常好'
return '未评价'
}
// 设置商品评分
const setRating = (index: number, rating: number) => {
ratings.value[index] = rating
// 触发响应式更新
const newRatings: number[] = []
for (let i: number = 0; i < ratings.value.length; i++) {
newRatings.push(ratings.value[i])
}
ratings.value = newRatings
}
const setMerchantRating = (type: string, rating: number) => {
if (type === 'description') {
merchantRating.value.description = rating
} else if (type === 'logistics') {
merchantRating.value.logistics = rating
} else if (type === 'service') {
merchantRating.value.service = rating
}
}
// 切换匿名
const toggleAnonymous = (event: any) => {
const eventObj = event as UTSJSONObject
const detailRaw = eventObj.get('detail')
const detail = detailRaw != null ? (detailRaw as UTSJSONObject) : (new UTSJSONObject())
const valueRaw = detail.get('value')
anonymous.value = valueRaw != null ? (valueRaw as boolean) : false
}
// 上传图片
const uploadImage = async (index: number) => {
// 检查图片数量限制
if (images.value[index].length >= 9) {
uni.showToast({
title: '最多上传9张图片',
icon: 'none'
})
return
}
// 使用uni.chooseImage选择图片
uni.chooseImage({
count: 9 - images.value[index].length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const resObj = res as UTSJSONObject
const tempFilesRaw = resObj.get('tempFilePaths')
const tempFiles = tempFilesRaw != null ? (tempFilesRaw as Array<string>) : []
uni.showLoading({
title: '上传中...'
})
setTimeout(() => {
for (let i: number = 0; i < tempFiles.length; i++) {
images.value[index].push(tempFiles[i])
}
const newImages: Array<Array<string>> = []
for (let i: number = 0; i < images.value.length; i++) {
const innerArray: Array<string> = []
for (let j: number = 0; j < images.value[i].length; j++) {
innerArray.push(images.value[i][j])
}
newImages.push(innerArray)
}
images.value = newImages
uni.hideLoading()
uni.showToast({
title: '上传成功',
icon: 'success'
})
}, 1000)
}
})
}
// 删除图片
const deleteImage = (index: number, imgIndex: number) => {
images.value[index].splice(imgIndex, 1)
// 触发响应式更新
const newImages: string[][] = []
for (let i: number = 0; i < images.value.length; i++) {
const innerArray: string[] = []
for (let j: number = 0; j < images.value[i].length; j++) {
innerArray.push(images.value[i][j])
}
newImages.push(innerArray)
}
images.value = newImages
}
// 获取当前用户ID
const getCurrentUserId = (): string => {
const userStore = uni.getStorageSync('userInfo')
if (userStore == null) return ''
const userInfo = userStore as UTSJSONObject
return userInfo.getString('id') ?? ''
}
const submitReview = async (): Promise<void> => {
if (canSubmit.value === false || isSubmitting.value) return
isSubmitting.value = true
try {
const userId = getCurrentUserId()
if (userId == '') {
uni.showToast({
title: '用户信息错误',
icon: 'none'
})
return
}
type ProductReviewType = {
user_id: string,
product_id: number,
order_id: string,
rating: number,
content: string,
images: Array<string>,
is_anonymous: boolean
}
const productReviews: Array<UTSJSONObject> = []
for (let index: number = 0; index < orderItems.value.length; index++) {
const item = orderItems.value[index]
const reviewObj: UTSJSONObject = new UTSJSONObject()
reviewObj.set('user_id', userId)
reviewObj.set('product_id', item.product_id)
reviewObj.set('order_id', orderId.value)
reviewObj.set('rating', ratings.value[index])
reviewObj.set('content', contents.value[index] != '' ? contents.value[index] : '')
reviewObj.set('images', images.value[index])
reviewObj.set('is_anonymous', anonymous.value)
productReviews.push(reviewObj)
}
const reviewsSuccess = await supabaseService.submitProductReviews(productReviews)
if (reviewsSuccess == false) {
uni.showToast({
title: '提交失败',
icon: 'none'
})
isSubmitting.value = false
return
}
if (merchant.value != null) {
type MerchantReviewType = {
user_id: string,
shop_id: string,
order_id: string,
description_rating: number,
logistics_rating: number,
service_rating: number
}
const merchantReviewObj: UTSJSONObject = new UTSJSONObject()
merchantReviewObj.set('user_id', userId)
merchantReviewObj.set('shop_id', merchant.value.id)
merchantReviewObj.set('order_id', orderId.value)
merchantReviewObj.set('description_rating', merchantRating.value.description)
merchantReviewObj.set('logistics_rating', merchantRating.value.logistics)
merchantReviewObj.set('service_rating', merchantRating.value.service)
await supabaseService.submitShopReview(merchantReviewObj)
}
await supabaseService.updateOrderStatus(orderId.value, 4)
uni.showToast({
title: '评价成功',
icon: 'success',
duration: 2000
})
// 跳转到评价成功页面
setTimeout(() => {
uni.navigateBack()
}, 1500)
} catch (err) {
console.error('提交评价失败:', err)
uni.showToast({
title: '提交失败',
icon: 'none'
})
} finally {
isSubmitting.value = false
}
}
// 返回
const goBack = (): void => {
uni.navigateBack()
}
</script>
<style scoped>
.review-page {
display: flex;
flex-direction: column;
flex: 1;
background-color: #f5f5f5;
}
.review-header {
background-color: #ffffff;
padding: 15px;
display: flex;
align-items: center;
border-bottom: 1px solid #e5e5e5;
}
.back-btn {
font-size: 24px;
color: #333333;
padding: 5px;
margin-right: 15px;
}
.header-title {
font-size: 18px;
font-weight: bold;
color: #333333;
}
.review-content {
flex: 1;
}
.order-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.order-no {
font-size: 14px;
color: #333333;
}
.order-time {
font-size: 12px;
color: #999999;
}
.products-section {
background-color: #ffffff;
margin-bottom: 10px;
}
.product-review {
padding: 15px;
border-bottom: 1px solid #f5f5f5;
}
.product-header {
display: flex;
margin-bottom: 20px;
}
.product-image {
width: 60px;
height: 60px;
border-radius: 5px;
margin-right: 15px;
}
.product-info {
flex: 1;
}
.product-name {
font-size: 14px;
color: #333333;
line-height: 1.4;
margin-bottom: 5px;
/* display: block; removed */
}
.product-spec {
font-size: 12px;
color: #999999;
/* display: block; removed */
}
.rating-section {
display: flex;
align-items: center;
margin-bottom: 15px;
}
.rating-label {
font-size: 14px;
color: #333333;
margin-right: 15px;
}
.rating-stars {
display: flex;
/* gap: 10px; removed */
}
.rating-stars.small {
/* gap: 5px; removed */
}
.star-icon {
font-size: 24px;
color: #cccccc;
margin-right: 10px;
}
.star-icon.active {
color: #ffa726;
}
.rating-text {
margin-left: 15px;
font-size: 14px;
color: #666666;
}
.content-section {
margin-bottom: 15px;
}
.review-textarea {
width: 100%;
min-height: 80px;
padding: 10px;
border: 1px solid #e5e5e5;
border-radius: 8px;
font-size: 14px;
color: #333333;
line-height: 1.4;
}
.word-count {
/* display: block; removed */
text-align: right;
font-size: 12px;
color: #999999;
margin-top: 5px;
}
.images-section {
margin-bottom: 15px;
}
.images-label {
/* display: block; removed */
font-size: 14px;
color: #333333;
margin-bottom: 10px;
}
.images-grid {
display: flex;
flex-wrap: wrap;
/* gap: 10px; removed */
}
.image-item {
margin-right: 10px;
margin-bottom: 10px;
width: 70px;
height: 70px;
border-radius: 5px;
overflow: hidden;
position: relative;
}
.uploaded-image {
width: 100%;
height: 100%;
}
.delete-image {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border-radius: 8px;
background-color: rgba(0, 0, 0, 0.5);
color: #ffffff;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.upload-btn {
width: 70px;
height: 70px;
border: 1px dashed #cccccc;
border-radius: 5px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
font-size: 24px;
color: #999999;
margin-bottom: 5px;
}
.upload-text {
font-size: 10px;
color: #999999;
}
.anonymous-section {
margin-bottom: 15px;
}
.anonymous-switch {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.switch-label {
font-size: 14px;
/* display: block; removed */
}
.anonymous-tip {
/* display: block; removed */
font-size: 12px;
color: #999999;
}
.merchant-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.section-title {
/* display: block; removed */
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 15px;
}
.merchant-rating {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.rating-item {
font-size: 14px;
color: #333333;
}
.tips-section {
background-color: #ffffff;
padding: 15px;
margin-bottom: 10px;
}
.tip-title {
/* display: block; removed */
font-size: 16px;
font-weight: bold;
color: #333333;
margin-bottom: 10px;
}
.tip-item {
/* display: block; removed */
font-size: 12px;
color: #666666;
line-height: 1.6;
margin-bottom: 5px;
}
.submit-section {
background-color: #ffffff;
padding: 15px;
border-top: 1px solid #e5e5e5;
}
.submit-btn {
background-color: #007aff;
color: #ffffff;
height: 50px;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
border: none;
}
.submit-btn.disabled {
background-color: #cccccc;
opacity: 0.6;
}
</style>