Files
mp-pilates/packages/app/src/components/ClassReviewForm.vue
richarjiang 139882d7a1 feat: 支持课后评价与成长档案
完成后即可评价并订阅提醒,馆主可记录体测、笔记与成长照片,首页展示匿名星级均分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 15:55:48 +08:00

77 lines
6.1 KiB
Vue
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">
<text class="eyebrow">课后 · 留一点感受</text>
<text class="title">这节课感觉怎么样</text>
<text class="hint">文字与标签仅你和馆主可见星级会计入首页匿名均分</text>
<view v-if="loading" class="hint">正在读取评价</view>
<view v-else-if="error" class="hint">{{ error }}<button class="secondary" @tap="load">重新加载</button></view>
<template v-else-if="review">
<text class="saved-stars">{{ '★'.repeat(review.rating) }}{{ '☆'.repeat(5 - review.rating) }}</text>
<view class="tags"><text v-for="tag in review.tags" :key="tag" class="tag selected">{{ tag }}</text></view>
<text class="comment">{{ review.comment || '谢谢你留下这份反馈。' }}</text>
<text class="hint">已评价 · {{ formatChinaDate(review.createdAt) }}</text>
</template>
<template v-else-if="canReview">
<view class="stars"><button v-for="n in 5" :key="n" :aria-label="n + ' 星'" :class="{ chosen: rating >= n }" @tap="rating = n">{{ rating >= n ? '★' : '☆' }}</button></view>
<text class="rating-label">{{ rating ? labels[rating - 1] : '轻触星星,为这节课评分' }}</text>
<view class="tags"><button v-for="tag in REVIEW_TAGS" :key="tag" class="tag" :class="{ selected: tags.includes(tag) }" @tap="toggle(tag)">{{ tag }}</button></view>
<text class="hint">可选最多 3 个标签</text>
<textarea v-model="comment" maxlength="200" placeholder="哪里让你有收获?还有什么可以做得更好?" />
<text class="counter">{{ comment.length }} / 200</text>
<picker :range="recommendations" @change="recommendation = Number($event.detail.value) - 1"><view class="recommend">你有多愿意推荐我们<text>{{ recommendation < 0 ? '选填 ' : recommendation + ' / 10 ' }}</text></view></picker>
<text class="hint">0 表示完全不愿意10 表示非常愿意</text>
<button class="primary" :loading="saving" :disabled="saving || !rating" @tap="submit">提交评价</button>
</template>
<text v-else class="hint">课程完成后即可评价</text>
</view>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { REVIEW_TAGS } from '@mp-pilates/shared'
import type { ClassReview } from '@mp-pilates/shared'
import { get, post } from '../utils/request'
import { formatChinaDate } from '../utils/format'
const props = defineProps<{ bookingId: string }>()
const review = ref<ClassReview | null>(null), canReview = ref(false), loading = ref(false), saving = ref(false), error = ref('')
const rating = ref(0), tags = ref<string[]>([]), comment = ref(''), recommendation = ref(-1)
const labels = ['不太满意', '有待改善', '整体还好', '很满意', '非常满意']
const recommendations = ['暂不填写', ...Array.from({ length: 11 }, (_, n) => String(n))]
let sequence = 0
async function load() {
const seq = ++sequence; loading.value = true; error.value = ''
try { const result = await get<{ review: ClassReview | null; canReview: boolean }>(`/booking/${props.bookingId}/review`); if (seq === sequence) { review.value = result.review; canReview.value = result.canReview } }
catch (e) { if (seq === sequence) error.value = e instanceof Error ? e.message : '评价加载失败' }
finally { if (seq === sequence) loading.value = false }
}
function toggle(tag: string) {
if (tags.value.includes(tag)) tags.value = tags.value.filter(t => t !== tag)
else if (tags.value.length < 3) tags.value = [...tags.value, tag]
else uni.showToast({ title: '最多选择 3 个标签', icon: 'none' })
}
async function submit() {
if (saving.value || !rating.value) return
saving.value = true
try { review.value = await post<ClassReview>(`/booking/${props.bookingId}/review`, { rating: rating.value, tags: tags.value, comment: comment.value, ...(recommendation.value >= 0 ? { recommendation: recommendation.value } : {}) }); uni.showToast({ title: '谢谢你的反馈', icon: 'success' }) }
catch (e) { uni.showToast({ title: e instanceof Error ? e.message : '提交失败,请重试', icon: 'none' }) }
finally { saving.value = false }
}
watch(() => props.bookingId, () => { review.value = null; rating.value = 0; tags.value = []; comment.value = ''; recommendation.value = -1; void load() }, { immediate: true })
</script>
<style lang="scss" scoped>
.review { margin: 28rpx 32rpx; padding: 32rpx; border: 1rpx solid #e7e0d7; border-radius: 28rpx; background: #fffdf9; color: #514943; }
.eyebrow { display:block; color:#8c7867; font-size:22rpx; letter-spacing:3rpx; }
.title { display:block; margin:18rpx 0; font-family:'Songti SC','STSong',serif; font-size:38rpx; }
.hint { display:block; font-size:23rpx; line-height:1.8; color:#82796e; }
.stars { display:flex; justify-content:space-between; margin:24rpx 0 8rpx; }
.stars button { padding:0; margin:0; width:96rpx; height:96rpx; line-height:96rpx; font-size:60rpx; background:transparent; color:#b7ac9c; &::after {border:0;} &.chosen {color:#a5824b;} }
.rating-label {display:block; text-align:center; color:#8c7867; font-size:24rpx; margin-bottom:24rpx;}
.tags {display:flex;flex-wrap:wrap;gap:14rpx;margin:18rpx 0;}
.tag {margin:0;padding:15rpx 20rpx;font-size:24rpx;line-height:1.5;border-radius:16rpx;background:#f4f1eb;color:#72695f;&::after{border:0;} &.selected{background:#e7eee7;color:#4d685c;}}
textarea {margin-top:22rpx;padding:24rpx;width:100%;height:190rpx;box-sizing:border-box;background:#f6f3ed;border-radius:18rpx;font-size:26rpx;line-height:1.7;}
.counter{display:block;text-align:right;color:#8b817b;font-size:21rpx;margin:10rpx 0 20rpx;}
.recommend{display:flex;justify-content:space-between;gap:16rpx;align-items:center;min-height:88rpx;border-top:1rpx solid #eee8e0;font-size:24rpx;}
.primary,.secondary{margin-top:24rpx;min-height:88rpx;line-height:88rpx;border-radius:22rpx;background:#617d70;color:#fff;font-size:27rpx;&::after{border:0;}}
.secondary{background:#eee9df;color:#645c52;}.primary[disabled]{background:#d5dcd2;color:#697365;}
.saved-stars{display:block;font-size:46rpx;color:#a5824b;margin-top:24rpx;}.comment{display:block;line-height:1.8;font-size:27rpx;margin:20rpx 0;white-space:pre-wrap;}
</style>