uni-app 评价系统
章节介绍
评价系统是电商应用和服务类应用中不可或缺的组成部分,它不仅可以帮助用户了解商品或服务的真实情况,还可以为商家提供宝贵的反馈信息,促进产品和服务的改进。在 uni-app 中实现评价系统需要考虑跨端适配、数据同步、权限管理等多个方面。本教程将详细介绍 uni-app 评价系统的核心知识点和实现方法,帮助开发者快速构建功能完善的评价体系。
核心知识点
评价系统架构设计
评价系统通常包含以下核心组件:
- 评价管理模块:处理评价的创建、更新、删除和查询
- 评价内容模块:管理评价的文本、图片、视频等内容
- 评价互动模块:处理评价的点赞、回复等互动功能
- 评价分析模块:分析评价数据,生成评价报告
- 权限控制模块:管理评价的发布和管理权限
评价类型设计
常见的评价类型包括:
- 商品评价:用户对购买的商品进行评价
- 服务评价:用户对接受的服务进行评价
- 订单评价:用户对整个订单进行评价
- 商家评价:用户对商家整体进行评价
- 物流评价:用户对物流服务进行评价
评价内容结构
评价内容通常包含以下信息:
- 基本信息:评价ID、用户ID、被评价对象ID、评价时间
- 评分信息:整体评分、各项指标评分(如商品质量、服务态度、物流速度等)
- 文本内容:评价的详细文字描述
- 媒体内容:评价的图片、视频等多媒体内容
- 互动信息:点赞数、回复数、是否匿名
- 状态信息:评价状态(如待审核、已发布、已删除)
评价流程设计
完整的评价流程包括:
- 评价触发:用户完成订单或服务后,系统触发评价提醒
- 评价填写:用户填写评价内容,包括评分、文本、图片等
- 评价提交:用户提交评价内容到系统
- 评价审核:系统或人工审核评价内容
- 评价发布:审核通过后,评价正式发布
- 评价互动:其他用户可以对评价进行点赞、回复
- 评价管理:商家可以对评价进行回复、处理
评价展示设计
评价展示需要考虑以下因素:
- 展示方式:列表展示、详情展示、统计展示
- 排序方式:时间排序、评分排序、热度排序
- 筛选方式:评分筛选、标签筛选、时间筛选
- 聚合展示:评价统计、好评率、各项指标评分分布
- 响应式设计:适配不同设备的展示效果
实用案例分析
案例:实现完整的商品评价系统
功能需求
- 评价发布:用户可以对已购买的商品发布评价,包括评分、文字、图片
- 评价管理:商家可以查看和回复评价
- 评价展示:商品详情页展示评价列表和评价统计
- 评价互动:用户可以对评价进行点赞和回复
- 评价审核:系统对评价内容进行审核
实现步骤
- 设计评价数据结构:定义评价相关的数据模型
- 实现评价管理 API:开发评价相关的接口
- 构建评价发布页面:用户填写和提交评价的界面
- 开发评价展示组件:在商品详情页展示评价
- 实现评价互动功能:点赞和回复功能
- 集成评价审核功能:管理后台的评价审核
代码示例
评价数据结构设计
// 评价数据模型
const reviewSchema = {
id: String, // 评价ID
userId: String, // 用户ID
userName: String, // 用户名(用于展示)
userAvatar: String, // 用户头像
productId: String, // 商品ID
orderId: String, // 订单ID
rating: Number, // 整体评分(1-5星)
ratings: Object, // 各项指标评分
content: String, // 评价内容
images: Array, // 评价图片
videos: Array, // 评价视频
isAnonymous: Boolean, // 是否匿名
likes: Number, // 点赞数
replies: Array, // 回复列表
status: String, // 状态(pending/rejected/published)
createdAt: Date, // 创建时间
updatedAt: Date // 更新时间
};
// 评价回复数据模型
const replySchema = {
id: String, // 回复ID
reviewId: String, // 评价ID
userId: String, // 回复用户ID
userName: String, // 回复用户名
userRole: String, // 用户角色(user/merchant/admin)
content: String, // 回复内容
createdAt: Date, // 创建时间
};
// 评价统计数据模型
const reviewStatsSchema = {
productId: String, // 商品ID
totalReviews: Number, // 总评价数
averageRating: Number, // 平均评分
ratingDistribution: Object, // 评分分布
tags: Array, // 评价标签
pros: Array, // 优点
cons: Array, // 缺点
updatedAt: Date // 更新时间
};
// 评价标签数据模型
const reviewTagSchema = {
id: String, // 标签ID
name: String, // 标签名称
type: String, // 标签类型(pro/con/neutral)
count: Number, // 使用次数
createdAt: Date, // 创建时间
};评价管理API
// api/review.js
import request from './request';
export const reviewApi = {
// 获取商品评价列表
getProductReviews(params) {
return request({
url: '/api/reviews/product',
method: 'GET',
params
});
},
// 获取商品评价统计
getProductReviewStats(productId) {
return request({
url: `/api/reviews/product/${productId}/stats`,
method: 'GET'
});
},
// 发布评价
createReview(data) {
return request({
url: '/api/reviews',
method: 'POST',
data
});
},
// 更新评价
updateReview(id, data) {
return request({
url: `/api/reviews/${id}`,
method: 'PUT',
data
});
},
// 删除评价
deleteReview(id) {
return request({
url: `/api/reviews/${id}`,
method: 'DELETE'
});
},
// 点赞评价
likeReview(id) {
return request({
url: `/api/reviews/${id}/like`,
method: 'POST'
});
},
// 取消点赞
unlikeReview(id) {
return request({
url: `/api/reviews/${id}/unlike`,
method: 'POST'
});
},
// 回复评价
replyReview(id, data) {
return request({
url: `/api/reviews/${id}/reply`,
method: 'POST',
data
});
},
// 获取用户评价列表
getUserReviews(params) {
return request({
url: '/api/reviews/user',
method: 'GET',
params
});
},
// 获取待评价订单
getPendingReviews() {
return request({
url: '/api/reviews/pending',
method: 'GET'
});
}
};评价发布组件
<template>
<view class="review-form">
<view class="form-header">
<text class="form-title">发表评价</text>
<text class="form-desc">请对您购买的商品进行评价,帮助其他用户做出选择</text>
</view>
<!-- 商品信息 -->
<view class="product-info" v-if="product">
<image :src="product.image" class="product-image" mode="aspectFill"></image>
<view class="product-details">
<text class="product-name">{{ product.name }}</text>
<text class="product-spec">{{ product.spec }}</text>
<text class="product-price">¥{{ product.price }}</text>
</view>
</view>
<!-- 评分 -->
<view class="rating-section">
<text class="section-title">整体评分</text>
<view class="rating-stars">
<text
v-for="star in 5"
:key="star"
@click="setRating(star)"
:class="['star', { 'active': rating >= star }]"
>
★
</text>
</view>
<text class="rating-text">{{ ratingText }}</text>
</view>
<!-- 分项评分 -->
<view class="sub-rating-section" v-if="subRatings.length > 0">
<text class="section-title">详细评分</text>
<view class="sub-rating-item" v-for="(item, index) in subRatings" :key="index">
<text class="sub-rating-label">{{ item.label }}</text>
<view class="sub-rating-stars">
<text
v-for="star in 5"
:key="star"
@click="setSubRating(index, star)"
:class="['star', { 'active': item.value >= star }]"
>
★
</text>
</view>
</view>
</view>
<!-- 评价内容 -->
<view class="content-section">
<text class="section-title">评价内容</text>
<textarea
v-model="content"
placeholder="请输入您的评价,分享您的使用体验..."
class="content-textarea"
maxlength="500"
></textarea>
<text class="content-count">{{ content.length }}/500</text>
</view>
<!-- 上传图片 -->
<view class="image-upload-section">
<text class="section-title">上传图片(最多9张)</text>
<view class="upload-images">
<view
v-for="(image, index) in images"
:key="index"
class="uploaded-image"
>
<image :src="image" class="image-item" mode="aspectFill"></image>
<text @click="removeImage(index)" class="remove-image">×</text>
</view>
<view
v-if="images.length < 9"
@click="chooseImage"
class="add-image"
>
<text class="add-icon">+</text>
<text class="add-text">添加图片</text>
</view>
</view>
</view>
<!-- 匿名评价 -->
<view class="anonymous-section">
<checkbox v-model="anonymous" class="anonymous-checkbox"></checkbox>
<text class="anonymous-label">匿名评价</text>
</view>
<!-- 提交按钮 -->
<button
@click="submitReview"
class="submit-btn"
:disabled="!canSubmit"
>
提交评价
</button>
</view>
</template>
<script>
export default {
props: {
product: {
type: Object,
default: null
},
orderId: {
type: String,
default: ''
}
},
data() {
return {
rating: 0,
subRatings: [
{ label: '商品质量', value: 0 },
{ label: '服务态度', value: 0 },
{ label: '物流速度', value: 0 },
{ label: '包装情况', value: 0 }
],
content: '',
images: [],
anonymous: false
};
},
computed: {
ratingText() {
const texts = ['', '很差', '较差', '一般', '满意', '非常满意'];
return texts[this.rating] || '';
},
canSubmit() {
return this.rating > 0 && this.content.trim().length > 0;
}
},
methods: {
setRating(star) {
this.rating = star;
},
setSubRating(index, star) {
this.subRatings[index].value = star;
},
chooseImage() {
const maxCount = 9 - this.images.length;
uni.chooseImage({
count: maxCount,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
this.images = [...this.images, ...res.tempFilePaths];
}
});
},
removeImage(index) {
this.images.splice(index, 1);
},
async submitReview() {
if (!this.canSubmit) return;
uni.showLoading({ title: '提交中...' });
try {
// 上传图片
const uploadedImages = await this.uploadImages();
// 构建评价数据
const reviewData = {
productId: this.product.id,
orderId: this.orderId,
rating: this.rating,
ratings: this.subRatings.reduce((acc, item) => {
acc[item.label] = item.value;
return acc;
}, {}),
content: this.content.trim(),
images: uploadedImages,
isAnonymous: this.anonymous
};
// 提交评价
const response = await this.$api.review.createReview(reviewData);
if (response.success) {
uni.showToast({ title: '评价提交成功', icon: 'success' });
setTimeout(() => {
uni.navigateBack();
}, 1500);
} else {
uni.showToast({ title: response.message || '评价提交失败', icon: 'none' });
}
} catch (error) {
console.error('提交评价失败', error);
uni.showToast({ title: '评价提交失败,请重试', icon: 'none' });
} finally {
uni.hideLoading();
}
},
async uploadImages() {
if (this.images.length === 0) return [];
const uploadedImages = [];
for (const image of this.images) {
// 实际项目中调用上传接口
// const uploadResponse = await this.$api.upload.uploadImage(image);
// uploadedImages.push(uploadResponse.url);
// 模拟上传
uploadedImages.push(image);
}
return uploadedImages;
}
}
};
</script>
<style scoped>
.review-form {
background-color: #f5f5f5;
min-height: 100vh;
padding: 20px;
}
.form-header {
margin-bottom: 20px;
}
.form-title {
font-size: 18px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 10px;
}
.form-desc {
font-size: 14px;
color: #666;
display: block;
line-height: 1.5;
}
.product-info {
display: flex;
background-color: #fff;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.product-image {
width: 80px;
height: 80px;
border-radius: 4px;
margin-right: 15px;
}
.product-details {
flex: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.product-name {
font-size: 14px;
color: #333;
line-height: 1.4;
margin-bottom: 5px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-spec {
font-size: 12px;
color: #999;
margin-bottom: 5px;
}
.product-price {
font-size: 16px;
font-weight: bold;
color: #ff4d4f;
}
.rating-section {
background-color: #fff;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.section-title {
font-size: 16px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 15px;
}
.rating-stars {
display: flex;
margin-bottom: 10px;
}
.star {
font-size: 32px;
color: #e8e8e8;
margin-right: 10px;
line-height: 1;
}
.star.active {
color: #ffd700;
}
.rating-text {
font-size: 14px;
color: #666;
margin-top: 5px;
}
.sub-rating-section {
background-color: #fff;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.sub-rating-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.sub-rating-label {
font-size: 14px;
color: #333;
flex: 1;
}
.sub-rating-stars {
display: flex;
}
.sub-rating-stars .star {
font-size: 20px;
margin-right: 5px;
}
.content-section {
background-color: #fff;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.content-textarea {
width: 100%;
min-height: 120px;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 10px;
font-size: 14px;
color: #333;
resize: none;
}
.content-count {
font-size: 12px;
color: #999;
text-align: right;
display: block;
margin-top: 10px;
}
.image-upload-section {
background-color: #fff;
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.upload-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.uploaded-image {
position: relative;
width: 80px;
height: 80px;
border-radius: 4px;
overflow: hidden;
}
.image-item {
width: 100%;
height: 100%;
}
.remove-image {
position: absolute;
top: 5px;
right: 5px;
background-color: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 16px;
width: 20px;
height: 20px;
border-radius: 50%;
text-align: center;
line-height: 20px;
}
.add-image {
width: 80px;
height: 80px;
border: 1px dashed #e8e8e8;
border-radius: 4px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: #fafafa;
}
.add-icon {
font-size: 24px;
color: #999;
margin-bottom: 5px;
}
.add-text {
font-size: 12px;
color: #999;
}
.anonymous-section {
background-color: #fff;
padding: 15px;
border-radius: 8px;
margin-bottom: 30px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
}
.anonymous-checkbox {
transform: scale(1.2);
margin-right: 10px;
}
.anonymous-label {
font-size: 14px;
color: #333;
}
.submit-btn {
width: 100%;
height: 50px;
background-color: #1890ff;
color: #fff;
border: none;
border-radius: 25px;
font-size: 16px;
font-weight: bold;
margin-bottom: 30px;
}
.submit-btn:disabled {
background-color: #e8e8e8;
color: #999;
}
</style>评价展示组件
<template>
<view class="review-list">
<!-- 评价统计 -->
<view class="review-stats" v-if="stats">
<view class="stats-overall">
<text class="stats-rating">{{ stats.averageRating.toFixed(1) }}</text>
<view class="stats-stars">
<text
v-for="star in 5"
:key="star"
:class="['star', { 'active': Math.round(stats.averageRating) >= star }]"
>
★
</text>
</view>
<text class="stats-count">{{ stats.totalReviews }}条评价</text>
</view>
<view class="stats-details">
<view class="stats-item" v-for="(count, rating) in stats.ratingDistribution" :key="rating">
<text class="stats-rating-label">{{ rating }}星</text>
<view class="stats-bar-container">
<view
class="stats-bar"
:style="{ width: (count / stats.totalReviews * 100) + '%' }"
></view>
</view>
<text class="stats-bar-count">{{ Math.round(count / stats.totalReviews * 100) }}%</text>
</view>
</view>
</view>
<!-- 评价标签 -->
<view class="review-tags" v-if="tags && tags.length > 0">
<text
v-for="tag in tags"
:key="tag.id"
@click="filterByTag(tag.name)"
:class="['tag', { 'active': selectedTag === tag.name }]"
>
{{ tag.name }}({{ tag.count }})
</text>
</view>
<!-- 筛选排序 -->
<view class="filter-section">
<view class="filter-tabs">
<text
v-for="tab in filterTabs"
:key="tab.value"
@click="setFilter(tab.value)"
:class="['filter-tab', { 'active': activeFilter === tab.value }]"
>
{{ tab.label }}
</text>
</view>
<view class="sort-section">
<text class="sort-label">排序:</text>
<picker @change="sortChange" :value="sortIndex" :range="sortOptions" class="sort-picker">
<text class="sort-value">{{ sortOptions[sortIndex] }}</text>
</picker>
</view>
</view>
<!-- 评价列表 -->
<view class="reviews-container">
<view
v-for="review in reviews"
:key="review.id"
class="review-item"
>
<view class="review-header">
<view class="reviewer-info">
<image :src="review.userAvatar || '/static/default-avatar.png'" class="reviewer-avatar" mode="aspectFill"></image>
<view class="reviewer-details">
<text class="reviewer-name">{{ review.isAnonymous ? '匿名用户' : review.userName }}</text>
<view class="review-rating">
<text
v-for="star in 5"
:key="star"
:class="['star', { 'active': review.rating >= star }]"
>
★
</text>
</view>
</view>
</view>
<text class="review-time">{{ formatTime(review.createdAt) }}</text>
</view>
<text class="review-content">{{ review.content }}</text>
<!-- 评价图片 -->
<view class="review-images" v-if="review.images && review.images.length > 0">
<image
v-for="(image, index) in review.images"
:key="index"
:src="image"
class="review-image"
mode="aspectFill"
@click="previewImage(review.images, index)"
></image>
</view>
<!-- 评价标签 -->
<view class="review-tags-inline" v-if="review.tags && review.tags.length > 0">
<text
v-for="tag in review.tags"
:key="tag"
class="review-tag"
>
{{ tag }}
</text>
</view>
<!-- 评价互动 -->
<view class="review-actions">
<view @click="likeReview(review.id)" class="action-item">
<text :class="['action-icon', { 'active': review.isLiked }]">♥</text>
<text class="action-text">{{ review.likes }}</text>
</view>
<view @click="showReplies(review.id)" class="action-item">
<text class="action-icon">💬</text>
<text class="action-text">{{ review.replies.length }}</text>
</view>
</view>
<!-- 评价回复 -->
<view class="review-replies" v-if="review.showReplies && review.replies.length > 0">
<view
v-for="reply in review.replies"
:key="reply.id"
class="reply-item"
:class="{ 'merchant-reply': reply.userRole === 'merchant' }"
>
<text class="reply-author">{{ reply.userRole === 'merchant' ? '商家' : reply.userName }}:</text>
<text class="reply-content">{{ reply.content }}</text>
<text class="reply-time">{{ formatTime(reply.createdAt) }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="hasMore">
<text @click="loadMore" class="load-more-text">加载更多</text>
</view>
<view class="no-more" v-else-if="reviews.length > 0">
<text class="no-more-text">没有更多评价了</text>
</view>
<view class="no-reviews" v-else>
<text class="no-reviews-text">暂无评价,快来抢沙发吧!</text>
</view>
</view>
</template>
<script>
export default {
props: {
productId: {
type: String,
required: true
}
},
data() {
return {
reviews: [],
stats: null,
tags: [],
page: 1,
pageSize: 10,
hasMore: true,
activeFilter: 'all',
selectedTag: '',
sortIndex: 0,
sortOptions: ['最新', '评分从高到低', '评分从低到高', '有图'],
filterTabs: [
{ label: '全部', value: 'all' },
{ label: '好评', value: 'good' },
{ label: '中评', value: 'neutral' },
{ label: '差评', value: 'bad' }
]
};
},
mounted() {
this.loadReviewData();
},
methods: {
async loadReviewData() {
try {
// 获取评价统计
const statsResponse = await this.$api.review.getProductReviewStats(this.productId);
if (statsResponse.success) {
this.stats = statsResponse.data;
this.tags = statsResponse.data.tags || [];
}
// 获取评价列表
await this.loadReviews();
} catch (error) {
console.error('加载评价数据失败', error);
}
},
async loadReviews() {
try {
const params = {
productId: this.productId,
page: this.page,
pageSize: this.pageSize,
filter: this.activeFilter,
tag: this.selectedTag,
sort: this.getSortValue()
};
const response = await this.$api.review.getProductReviews(params);
if (response.success) {
const newReviews = response.data.reviews || [];
if (this.page === 1) {
this.reviews = newReviews;
} else {
this.reviews = [...this.reviews, ...newReviews];
}
this.hasMore = newReviews.length === this.pageSize;
}
} catch (error) {
console.error('加载评价列表失败', error);
}
},
loadMore() {
if (this.hasMore) {
this.page++;
this.loadReviews();
}
},
setFilter(filter) {
this.activeFilter = filter;
this.page = 1;
this.loadReviews();
},
filterByTag(tag) {
this.selectedTag = this.selectedTag === tag ? '' : tag;
this.page = 1;
this.loadReviews();
},
sortChange(e) {
this.sortIndex = e.detail.value;
this.page = 1;
this.loadReviews();
},
getSortValue() {
const sortMap = {
0: 'latest',
1: 'highest',
2: 'lowest',
3: 'image'
};
return sortMap[this.sortIndex];
},
likeReview(reviewId) {
const review = this.reviews.find(r => r.id === reviewId);
if (review) {
review.isLiked = !review.isLiked;
review.likes += review.isLiked ? 1 : -1;
// 实际项目中调用点赞接口
// this.$api.review.likeReview(reviewId);
}
},
showReplies(reviewId) {
const review = this.reviews.find(r => r.id === reviewId);
if (review) {
review.showReplies = !review.showReplies;
}
},
previewImage(images, current) {
uni.previewImage({
urls: images,
current: images[current]
});
},
formatTime(time) {
const date = new Date(time);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`;
}
}
};
</script>
<style scoped>
.review-list {
background-color: #f5f5f5;
min-height: 100vh;
}
.review-stats {
background-color: #fff;
padding: 20px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.stats-overall {
display: flex;
align-items: baseline;
margin-bottom: 20px;
}
.stats-rating {
font-size: 36px;
font-weight: bold;
color: #ff4d4f;
margin-right: 15px;
}
.stats-stars {
display: flex;
margin-right: 15px;
}
.stats-stars .star {
font-size: 16px;
color: #e8e8e8;
margin-right: 2px;
}
.stats-stars .star.active {
color: #ffd700;
}
.stats-count {
font-size: 14px;
color: #666;
}
.stats-details {
display: flex;
flex-direction: column;
gap: 10px;
}
.stats-item {
display: flex;
align-items: center;
}
.stats-rating-label {
width: 40px;
font-size: 12px;
color: #666;
}
.stats-bar-container {
flex: 1;
height: 8px;
background-color: #f0f0f0;
border-radius: 4px;
margin: 0 10px;
overflow: hidden;
}
.stats-bar {
height: 100%;
background-color: #ff4d4f;
border-radius: 4px;
}
.stats-bar-count {
width: 40px;
font-size: 12px;
color: #666;
text-align: right;
}
.review-tags {
background-color: #fff;
padding: 15px 20px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.tag {
font-size: 12px;
padding: 5px 12px;
border: 1px solid #e8e8e8;
border-radius: 15px;
color: #666;
background-color: #f5f5f5;
}
.tag.active {
border-color: #1890ff;
color: #1890ff;
background-color: #e6f7ff;
}
.filter-section {
background-color: #fff;
padding: 15px 20px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-tabs {
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 10px;
}
.filter-tab {
flex: 1;
text-align: center;
font-size: 14px;
color: #666;
padding-bottom: 5px;
border-bottom: 2px solid transparent;
}
.filter-tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
}
.sort-section {
display: flex;
align-items: center;
justify-content: flex-end;
}
.sort-label {
font-size: 14px;
color: #666;
margin-right: 10px;
}
.sort-picker {
font-size: 14px;
color: #333;
}
.reviews-container {
padding: 10px;
}
.review-item {
background-color: #fff;
padding: 20px;
margin-bottom: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.review-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.reviewer-info {
display: flex;
align-items: center;
}
.reviewer-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 15px;
}
.reviewer-details {
flex: 1;
}
.reviewer-name {
font-size: 14px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 5px;
}
.review-rating {
display: flex;
}
.review-rating .star {
font-size: 14px;
color: #e8e8e8;
margin-right: 2px;
}
.review-rating .star.active {
color: #ffd700;
}
.review-time {
font-size: 12px;
color: #999;
}
.review-content {
font-size: 14px;
color: #333;
line-height: 1.5;
margin-bottom: 15px;
}
.review-images {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 15px;
}
.review-image {
width: 80px;
height: 80px;
border-radius: 4px;
}
.review-tags-inline {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 15px;
}
.review-tag {
font-size: 12px;
padding: 3px 10px;
background-color: #f0f0f0;
border-radius: 12px;
color: #666;
}
.review-actions {
display: flex;
gap: 20px;
margin-bottom: 15px;
padding-top: 10px;
border-top: 1px solid #f0f0f0;
}
.action-item {
display: flex;
align-items: center;
font-size: 14px;
color: #666;
}
.action-icon {
margin-right: 5px;
font-size: 16px;
}
.action-icon.active {
color: #ff4d4f;
}
.review-replies {
background-color: #f9f9f9;
padding: 15px;
border-radius: 4px;
margin-top: 10px;
}
.reply-item {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.reply-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.reply-author {
font-size: 14px;
font-weight: bold;
color: #333;
margin-right: 5px;
}
.reply-content {
font-size: 14px;
color: #333;
line-height: 1.4;
}
.reply-time {
font-size: 12px;
color: #999;
display: block;
margin-top: 5px;
}
.load-more {
text-align: center;
padding: 20px;
background-color: #fff;
margin: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.load-more-text {
font-size: 14px;
color: #1890ff;
}
.no-more {
text-align: center;
padding: 20px;
background-color: #fff;
margin: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.no-more-text {
font-size: 14px;
color: #999;
}
.no-reviews {
text-align: center;
padding: 60px 20px;
background-color: #fff;
margin: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.no-reviews-text {
font-size: 14px;
color: #999;
}
</style>评价管理组件
<template>
<view class="review-management">
<view class="management-header">
<text class="header-title">评价管理</text>
<text class="header-desc">管理商品评价,及时回复用户反馈</text>
</view>
<!-- 筛选条件 -->
<view class="filter-section">
<view class="filter-tabs">
<text
v-for="tab in filterTabs"
:key="tab.value"
@click="setFilter(tab.value)"
:class="['filter-tab', { 'active': activeFilter === tab.value }]"
>
{{ tab.label }}
</text>
</view>
<view class="search-section">
<input
v-model="searchKeyword"
@input="handleSearch"
placeholder="搜索评价内容或用户"
class="search-input"
/>
<text class="search-icon">🔍</text>
</view>
</view>
<!-- 评价列表 -->
<view class="reviews-container">
<view
v-for="review in reviews"
:key="review.id"
class="review-item"
>
<!-- 评价状态 -->
<view class="review-status" :class="review.status">
{{ getStatusText(review.status) }}
</view>
<!-- 评价内容 -->
<view class="review-content-section">
<view class="review-header">
<view class="reviewer-info">
<image :src="review.userAvatar || '/static/default-avatar.png'" class="reviewer-avatar" mode="aspectFill"></image>
<view class="reviewer-details">
<text class="reviewer-name">{{ review.isAnonymous ? '匿名用户' : review.userName }}</text>
<text class="review-time">{{ formatTime(review.createdAt) }}</text>
</view>
</view>
<view class="review-rating">
<text
v-for="star in 5"
:key="star"
:class="['star', { 'active': review.rating >= star }]"
>
★
</text>
</view>
</view>
<!-- 商品信息 -->
<view class="product-info" v-if="review.product">
<image :src="review.product.image" class="product-image" mode="aspectFill"></image>
<text class="product-name">{{ review.product.name }}</text>
</view>
<!-- 评价内容 -->
<text class="review-content">{{ review.content }}</text>
<!-- 评价图片 -->
<view class="review-images" v-if="review.images && review.images.length > 0">
<image
v-for="(image, index) in review.images"
:key="index"
:src="image"
class="review-image"
mode="aspectFill"
@click="previewImage(review.images, index)"
></image>
</view>
</view>
<!-- 评价操作 -->
<view class="review-actions">
<!-- 回复按钮 -->
<button
@click="showReplyDialog(review)"
class="reply-btn"
>
回复
</button>
<!-- 审核按钮 -->
<template v-if="review.status === 'pending'">
<button
@click="approveReview(review.id)"
class="approve-btn"
>
通过
</button>
<button
@click="rejectReview(review.id)"
class="reject-btn"
>
拒绝
</button>
</template>
<!-- 已审核状态 -->
<template v-else>
<button
@click="deleteReview(review.id)"
class="delete-btn"
>
删除
</button>
</template>
</view>
<!-- 回复列表 -->
<view class="reply-list" v-if="review.replies && review.replies.length > 0">
<view
v-for="reply in review.replies"
:key="reply.id"
class="reply-item"
>
<text class="reply-author">{{ reply.userRole === 'merchant' ? '商家' : reply.userName }}:</text>
<text class="reply-content">{{ reply.content }}</text>
<text class="reply-time">{{ formatTime(reply.createdAt) }}</text>
</view>
</view>
</view>
</view>
<!-- 加载更多 -->
<view class="load-more" v-if="hasMore">
<text @click="loadMore" class="load-more-text">加载更多</text>
</view>
<view class="no-more" v-else-if="reviews.length > 0">
<text class="no-more-text">没有更多评价了</text>
</view>
<view class="no-reviews" v-else>
<text class="no-reviews-text">暂无评价</text>
</view>
<!-- 回复对话框 -->
<view class="reply-dialog" v-if="showReply">
<view class="dialog-content">
<view class="dialog-header">
<text class="dialog-title">回复评价</text>
<text @click="closeReplyDialog" class="dialog-close">×</text>
</view>
<textarea
v-model="replyContent"
placeholder="请输入回复内容"
class="reply-textarea"
maxlength="200"
></textarea>
<text class="reply-count">{{ replyContent.length }}/200</text>
<view class="dialog-buttons">
<button @click="closeReplyDialog" class="cancel-btn">取消</button>
<button
@click="submitReply"
class="submit-btn"
:disabled="!replyContent.trim()"
>
提交
</button>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
reviews: [],
page: 1,
pageSize: 10,
hasMore: true,
activeFilter: 'all',
searchKeyword: '',
showReply: false,
currentReview: null,
replyContent: '',
filterTabs: [
{ label: '全部', value: 'all' },
{ label: '待审核', value: 'pending' },
{ label: '已发布', value: 'published' },
{ label: '已拒绝', value: 'rejected' }
]
};
},
mounted() {
this.loadReviewData();
},
methods: {
async loadReviewData() {
try {
const params = {
page: this.page,
pageSize: this.pageSize,
status: this.activeFilter === 'all' ? '' : this.activeFilter,
keyword: this.searchKeyword
};
// 实际项目中调用获取评价列表接口
// const response = await this.$api.review.getReviews(params);
// 模拟数据
const response = {
success: true,
data: {
reviews: [
{
id: '1',
userId: 'user1',
userName: '张三',
userAvatar: '',
productId: 'prod1',
product: {
id: 'prod1',
name: 'iPhone 13 Pro',
image: 'https://example.com/iphone13.jpg'
},
rating: 5,
content: '手机非常好用,系统流畅,相机效果出色,值得购买!',
images: [],
isAnonymous: false,
likes: 10,
replies: [],
status: 'pending',
createdAt: new Date().toISOString()
},
{
id: '2',
userId: 'user2',
userName: '李四',
userAvatar: '',
productId: 'prod2',
product: {
id: 'prod2',
name: 'AirPods Pro',
image: 'https://example.com/airpods.jpg'
},
rating: 4,
content: '音质不错,降噪效果很好,就是价格有点贵。',
images: [],
isAnonymous: true,
likes: 5,
replies: [],
status: 'published',
createdAt: new Date().toISOString()
}
],
total: 2
}
};
if (response.success) {
const newReviews = response.data.reviews || [];
if (this.page === 1) {
this.reviews = newReviews;
} else {
this.reviews = [...this.reviews, ...newReviews];
}
this.hasMore = newReviews.length === this.pageSize;
}
} catch (error) {
console.error('加载评价列表失败', error);
}
},
loadMore() {
if (this.hasMore) {
this.page++;
this.loadReviewData();
}
},
setFilter(filter) {
this.activeFilter = filter;
this.page = 1;
this.loadReviewData();
},
handleSearch() {
this.page = 1;
this.loadReviewData();
},
getStatusText(status) {
const statusMap = {
pending: '待审核',
published: '已发布',
rejected: '已拒绝'
};
return statusMap[status] || status;
},
formatTime(time) {
const date = new Date(time);
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')} ${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
},
previewImage(images, current) {
uni.previewImage({
urls: images,
current: images[current]
});
},
showReplyDialog(review) {
this.currentReview = review;
this.replyContent = '';
this.showReply = true;
},
closeReplyDialog() {
this.showReply = false;
this.currentReview = null;
this.replyContent = '';
},
async submitReply() {
if (!this.replyContent.trim() || !this.currentReview) return;
try {
// 实际项目中调用回复接口
// const response = await this.$api.review.replyReview(this.currentReview.id, {
// content: this.replyContent.trim()
// });
// 模拟回复
const reply = {
id: `reply-${Date.now()}`,
reviewId: this.currentReview.id,
userId: 'merchant',
userName: '商家',
userRole: 'merchant',
content: this.replyContent.trim(),
createdAt: new Date().toISOString()
};
if (!this.currentReview.replies) {
this.currentReview.replies = [];
}
this.currentReview.replies.push(reply);
uni.showToast({ title: '回复成功', icon: 'success' });
this.closeReplyDialog();
} catch (error) {
console.error('回复失败', error);
uni.showToast({ title: '回复失败,请重试', icon: 'none' });
}
},
async approveReview(reviewId) {
try {
// 实际项目中调用审核通过接口
// const response = await this.$api.review.approveReview(reviewId);
// 模拟审核通过
const review = this.reviews.find(r => r.id === reviewId);
if (review) {
review.status = 'published';
}
uni.showToast({ title: '审核通过', icon: 'success' });
} catch (error) {
console.error('审核失败', error);
uni.showToast({ title: '审核失败,请重试', icon: 'none' });
}
},
async rejectReview(reviewId) {
try {
// 实际项目中调用审核拒绝接口
// const response = await this.$api.review.rejectReview(reviewId);
// 模拟审核拒绝
const review = this.reviews.find(r => r.id === reviewId);
if (review) {
review.status = 'rejected';
}
uni.showToast({ title: '已拒绝', icon: 'success' });
} catch (error) {
console.error('操作失败', error);
uni.showToast({ title: '操作失败,请重试', icon: 'none' });
}
},
async deleteReview(reviewId) {
uni.showModal({
title: '确认删除',
content: '确定要删除这条评价吗?',
success: async (res) => {
if (res.confirm) {
try {
// 实际项目中调用删除接口
// const response = await this.$api.review.deleteReview(reviewId);
// 模拟删除
this.reviews = this.reviews.filter(r => r.id !== reviewId);
uni.showToast({ title: '删除成功', icon: 'success' });
} catch (error) {
console.error('删除失败', error);
uni.showToast({ title: '删除失败,请重试', icon: 'none' });
}
}
}
});
}
}
};
</script>
<style scoped>
.review-management {
background-color: #f5f5f5;
min-height: 100vh;
}
.management-header {
background-color: #1890ff;
color: #fff;
padding: 30px 20px;
}
.header-title {
font-size: 18px;
font-weight: bold;
display: block;
margin-bottom: 10px;
}
.header-desc {
font-size: 14px;
opacity: 0.8;
display: block;
}
.filter-section {
background-color: #fff;
padding: 15px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-tabs {
display: flex;
margin-bottom: 15px;
border-bottom: 1px solid #f0f0f0;
padding-bottom: 10px;
}
.filter-tab {
flex: 1;
text-align: center;
font-size: 14px;
color: #666;
padding-bottom: 5px;
border-bottom: 2px solid transparent;
}
.filter-tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
}
.search-section {
position: relative;
}
.search-input {
width: 100%;
height: 40px;
padding: 0 40px 0 15px;
border: 1px solid #e8e8e8;
border-radius: 20px;
font-size: 14px;
}
.search-icon {
position: absolute;
right: 15px;
top: 50%;
transform: translateY(-50%);
font-size: 16px;
color: #999;
}
.reviews-container {
padding: 10px;
}
.review-item {
background-color: #fff;
border-radius: 8px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
position: relative;
overflow: hidden;
}
.review-status {
position: absolute;
top: 0;
right: 0;
padding: 5px 10px;
font-size: 12px;
color: #fff;
}
.review-status.pending {
background-color: #faad14;
}
.review-status.published {
background-color: #52c41a;
}
.review-status.rejected {
background-color: #ff4d4f;
}
.review-content-section {
padding: 20px;
}
.review-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 15px;
}
.reviewer-info {
display: flex;
align-items: center;
}
.reviewer-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
margin-right: 15px;
}
.reviewer-details {
flex: 1;
}
.reviewer-name {
font-size: 14px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 5px;
}
.review-time {
font-size: 12px;
color: #999;
}
.review-rating {
display: flex;
}
.review-rating .star {
font-size: 14px;
color: #e8e8e8;
margin-right: 2px;
}
.review-rating .star.active {
color: #ffd700;
}
.product-info {
display: flex;
align-items: center;
background-color: #f9f9f9;
padding: 10px;
border-radius: 4px;
margin-bottom: 15px;
}
.product-image {
width: 60px;
height: 60px;
border-radius: 4px;
margin-right: 10px;
}
.product-name {
flex: 1;
font-size: 14px;
color: #333;
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.review-content {
font-size: 14px;
color: #333;
line-height: 1.5;
margin-bottom: 15px;
}
.review-actions {
display: flex;
gap: 10px;
padding: 15px 20px;
border-top: 1px solid #f0f0f0;
justify-content: flex-end;
}
.reply-btn, .approve-btn, .reject-btn, .delete-btn {
padding: 6px 16px;
border-radius: 4px;
font-size: 14px;
border: none;
}
.reply-btn {
background-color: #1890ff;
color: #fff;
}
.approve-btn {
background-color: #52c41a;
color: #fff;
}
.reject-btn {
background-color: #ff4d4f;
color: #fff;
}
.delete-btn {
background-color: #ff4d4f;
color: #fff;
}
.reply-list {
padding: 0 20px 20px;
border-top: 1px solid #f0f0f0;
}
.reply-item {
margin-bottom: 10px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.reply-item:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.reply-author {
font-size: 14px;
font-weight: bold;
color: #333;
margin-right: 5px;
}
.reply-content {
font-size: 14px;
color: #333;
line-height: 1.4;
}
.reply-time {
font-size: 12px;
color: #999;
display: block;
margin-top: 5px;
}
.load-more {
text-align: center;
padding: 20px;
background-color: #fff;
margin: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.load-more-text {
font-size: 14px;
color: #1890ff;
}
.no-more {
text-align: center;
padding: 20px;
background-color: #fff;
margin: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.no-more-text {
font-size: 14px;
color: #999;
}
.no-reviews {
text-align: center;
padding: 60px 20px;
background-color: #fff;
margin: 10px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.no-reviews-text {
font-size: 14px;
color: #999;
}
.reply-dialog {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.dialog-content {
background-color: #fff;
border-radius: 8px;
width: 90%;
max-width: 400px;
padding: 20px;
}
.dialog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.dialog-title {
font-size: 16px;
font-weight: bold;
color: #333;
}
.dialog-close {
font-size: 20px;
color: #999;
}
.reply-textarea {
width: 100%;
min-height: 120px;
border: 1px solid #e8e8e8;
border-radius: 4px;
padding: 10px;
font-size: 14px;
color: #333;
resize: none;
margin-bottom: 10px;
}
.reply-count {
font-size: 12px;
color: #999;
text-align: right;
display: block;
margin-bottom: 20px;
}
.dialog-buttons {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.cancel-btn, .submit-btn {
padding: 8px 20px;
border-radius: 4px;
font-size: 14px;
border: none;
}
.cancel-btn {
background-color: #f0f0f0;
color: #333;
}
.submit-btn {
background-color: #1890ff;
color: #fff;
}
.submit-btn:disabled {
background-color: #e8e8e8;
color: #999;
}
</style>实现技巧与注意事项
跨端适配
- 图片上传:使用 uni-app 的
uni.chooseImageAPI 进行图片选择,确保在不同平台上的一致性 - UI 适配:使用 flex 布局和相对单位,确保在不同屏幕尺寸上的良好显示效果
- API 调用:统一封装 API 调用,处理不同平台的差异
- 数据存储:使用 uni-app 的本地存储功能,确保数据在不同平台上的持久化
性能优化
- 分页加载:评价列表使用分页加载,避免一次性加载大量数据
- 图片懒加载:评价图片使用懒加载,提升页面加载速度
- 缓存策略:缓存评价统计数据,减少重复请求
- 虚拟列表:对于长评价列表,考虑使用虚拟列表技术
数据安全
- 后端验证:所有评价操作都需要在后端进行验证,防止恶意操作
- 防刷机制:实现评价频率限制,防止用户刷评价
- 内容审核:实现评价内容审核机制,过滤不良内容
- 权限控制:严格控制评价管理权限,确保只有授权用户可以管理评价
用户体验
- 评价引导:在用户完成订单后,及时引导用户进行评价
- 评价激励:考虑为评价用户提供积分或其他奖励,提高评价率
- 评价互动:支持评价点赞和回复,增强用户参与感
- 评价筛选:提供多种评价筛选和排序方式,方便用户查找相关信息
常见问题与解决方案
问题:评价图片上传失败
解决方案:
- 检查网络连接状态
- 实现图片压缩,减少图片大小
- 添加上传失败重试机制
- 提供明确的错误提示
问题:评价审核效率低
解决方案:
- 实现自动审核机制,过滤明显的不良内容
- 建立审核优先级,优先审核有图片或详细内容的评价
- 提供批量审核功能,提高审核效率
- 分析审核数据,优化审核规则
问题:评价统计数据不准确
解决方案:
- 实现评价统计数据的定期更新
- 确保评价状态变更时,统计数据同步更新
- 建立数据一致性检查机制,定期验证统计数据
- 优化统计数据计算方式,减少计算误差
问题:评价系统性能下降
解决方案:
- 优化数据库查询,添加适当的索引
- 实现评价数据的缓存机制
- 考虑使用异步处理,减轻主流程压力
- 定期清理无效评价数据,减少数据量
总结
本教程详细介绍了 uni-app 评价系统的核心知识点和实现方法,包括评价系统架构设计、评价类型设计、评价内容结构、评价流程设计等内容。通过实用案例和代码示例,展示了如何在 uni-app 中实现完整的评价功能,包括评价发布、评价展示、评价管理等核心模块。
评价系统是电商应用和服务类应用中的重要组成部分,它不仅可以帮助用户了解商品或服务的真实情况,还可以为商家提供宝贵的反馈信息,促进产品和服务的改进。在实际开发中,需要根据应用的具体需求和用户群体,设计适合的评价系统,同时注重用户体验、性能优化和数据安全。
通过本教程的学习,开发者应该能够:
- 理解评价系统的基本架构和核心组件
- 掌握 uni-app 中实现评价功能的方法
- 学会处理评价系统中的各种场景和问题
- 优化评价系统的性能和用户体验
- 确保评价系统的数据安全和可靠性
希望本教程对开发者在 uni-app 中实现评价系统有所帮助,祝大家开发顺利!