Files
medical-mall/pages/mall/admin/product/reviews/index.uvue
2026-03-20 17:30:30 +08:00

523 lines
17 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="product-reply-page">
<!-- 1. 搜索筛选 -->
<view class="search-card">
<view class="search-row">
<view class="search-item">
<text class="label">评价时间:</text>
<view class="date-range-row">
<input class="mock-input date-input" v-model="startTime" placeholder="开始 2025-01-01" />
<text class="date-sep">~</text>
<input class="mock-input date-input" v-model="endTime" placeholder="结束 2025-12-31" />
</view>
</view>
<view class="search-item">
<text class="label">审核状态:</text>
<picker :range="statusLabels" :value="statusPickerIndex" @change="onStatusChange">
<view class="mock-select">
<text>{{ statusLabels[statusPickerIndex] }}</text>
<text class="arrow">▼</text>
</view>
</picker>
</view>
</view>
<view class="search-row mt-16">
<view class="search-item">
<text class="label">商品信息:</text>
<input class="mock-input" v-model="searchProduct" placeholder="请输入商品信息" />
</view>
<view class="search-item">
<text class="label">用户名称:</text>
<input class="mock-input" v-model="searchUser" placeholder="请输入" />
</view>
<button class="btn-primary" @click="onSearch">查询</button>
<button class="btn-white" @click="onReset">重置</button>
</view>
</view>
<!-- 2. 操作行 -->
<view class="action-bar">
<button class="btn-primary">添加自评</button>
<button class="btn-white">批量审核</button>
</view>
<!-- 3. 数据表格 -->
<view class="list-card">
<!-- 错误提示 -->
<view v-if="fetchError !== ''" class="error-tip">
<text class="error-txt">{{ fetchError }}</text>
</view>
<!-- 加载中 -->
<view v-if="loading" class="loading-tip">
<text class="loading-txt">加载中...</text>
</view>
<view class="table-v5">
<view class="th-row">
<view class="th col-check"><text>□</text></view>
<view class="th col-id"><text>评论ID</text></view>
<view class="th col-product"><text>商品信息</text></view>
<view class="th col-user"><text>用户名称</text></view>
<view class="th col-score"><text>评分</text></view>
<view class="th col-content"><text>评价内容</text></view>
<view class="th col-reply"><text>回复内容</text></view>
<view class="th col-status"><text>状态</text></view>
<view class="th col-time"><text>评价时间</text></view>
<view class="th col-op"><text>操作</text></view>
</view>
<!-- 空态 -->
<view v-if="reviewList.length === 0 && !loading" class="empty-row">
<text class="empty-txt">暂无评价数据</text>
</view>
<view v-for="item in reviewList" :key="item.id" class="tr-row">
<view class="td col-check"><text>□</text></view>
<view class="td col-id">
<text class="id-txt">{{ item.id.substring(0, 8) }}...</text>
</view>
<view class="td col-product">
<image v-if="item.product_image != null && item.product_image !== ''" class="p-img" :src="item.product_image" mode="aspectFill" />
<view v-else class="p-img-placeholder" />
<text class="p-name-txt">{{ item.product_name !== '' ? item.product_name : '—' }}</text>
</view>
<view class="td col-user">
<text>{{ item.username != null && item.username !== '' ? item.username : '匿名用户' }}</text>
</view>
<view class="td col-score"><text>{{ item.rating }}星</text></view>
<view class="td col-content">
<text class="blue-link">{{ item.content != null && item.content !== '' ? item.content : '—' }}</text>
</view>
<view class="td col-reply">
<text>{{ item.merchant_reply != null && item.merchant_reply !== '' ? item.merchant_reply : '无' }}</text>
</view>
<view class="td col-status">
<text class="status-tag" :class="getStatusClass(item.status)">
{{ getStatusText(item.status) }}
</text>
</view>
<view class="td col-time"><text>{{ formatTime(item.created_at) }}</text></view>
<view class="td col-op">
<text v-if="item.status !== 1" class="op-link" @click="handleApprove(item.id)">通过</text>
<text v-if="item.status === 1" class="op-link warn" @click="handleReject(item.id)">驳回</text>
<text class="op-link" @click="openReplyModal(item.id, item.merchant_reply)">回复</text>
<text class="op-link red" @click="handleDelete(item.id)">删除</text>
</view>
</view>
</view>
<!-- 分页 -->
<CommonPagination
v-if="total > 0"
:total="total"
:loading="loading"
:currentPage="currentPage"
:pageSize="pageSize"
:pageSizeOptionLabels="pageSizeOptionLabels"
:pageSizeIndex="pageSizeIndex"
:visiblePages="visiblePages"
:totalPage="totalPage"
:jumpPageInput="jumpPageInput"
@page-size-change="handlePageSizeChange"
@page-change="handlePageChange"
@update:jumpPageInput="(val : string) => { jumpPageInput = val }"
@jump-page="handleJumpPage"
/>
</view>
<!-- 回复弹窗 -->
<view class="reply-modal-mask" v-if="showReplyModal" @click="closeReplyModal">
<view class="reply-modal" @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-textarea" v-model="replyContent" placeholder="请输入回复内容..." :maxlength="500" />
</view>
<view class="modal-footer">
<button class="btn-white btn-sm" @click="closeReplyModal">取消</button>
<button class="btn-primary btn-sm" @click="submitReply">
{{ replySubmitting ? '提交中...' : '确认回复' }}
</button>
</view>
</view>
</view>
</view>
</template>
<script setup lang="uts">
import { ref, computed, onMounted } from 'vue'
import CommonPagination from '@/components/CommonPagination/CommonPagination.uvue'
import {
fetchAdminProductReviews,
approveProductReview,
rejectProductReview,
replyProductReview,
deleteProductReview,
type ProductReviewItem
} from '@/services/admin/productReviewService.uts'
// ========== 搜索筛选状态 ==========
const searchProduct = ref('')
const searchUser = ref('')
const startTime = ref('')
const endTime = ref('')
// 审核状态选项index 0 → 全部(null), 1 → 正常(1), 2 → 已删除(2), 3 → 已隐藏(3)
const statusLabels = ['全部', '正常', '已删除', '已隐藏']
const statusPickerIndex = ref(0)
function onStatusChange(e : any) {
statusPickerIndex.value = Number(e.detail.value)
}
// ========== 数据状态 ==========
const loading = ref(false)
const fetchError = ref('')
const reviewList = ref<ProductReviewItem[]>([])
// ========== 数据加载(服务端分页,按需请求) ==========
async function loadReviews(page : number) {
if (loading.value) return
loading.value = true
fetchError.value = ''
try {
// index 0 → null不过滤其余直接映射到真实 status 值 1/2/3
const selectedStatus : number | null = statusPickerIndex.value === 0 ? null : statusPickerIndex.value
const result = await fetchAdminProductReviews({
searchProduct: searchProduct.value !== '' ? searchProduct.value : null,
searchUser: searchUser.value !== '' ? searchUser.value : null,
status: selectedStatus,
startTime: startTime.value !== '' ? startTime.value : null,
endTime: endTime.value !== '' ? endTime.value : null,
page: page,
pageSize: pageSize.value
})
reviewList.value = result.items
total.value = result.total
currentPage.value = page
} catch (e) {
fetchError.value = '加载失败,请稍后重试'
console.error('加载评价列表失败:', e)
} finally {
loading.value = false
}
}
function onSearch() {
loadReviews(1)
}
function onReset() {
searchProduct.value = ''
searchUser.value = ''
startTime.value = ''
endTime.value = ''
statusPickerIndex.value = 0
loadReviews(1)
}
onMounted(() => {
loadReviews(1)
})
// ========== 字段格式化 ==========
function formatTime(t : string | null) : string {
if (t == null || t === '') return '—'
return t.replace('T', ' ').substring(0, 19)
}
// status: 1=正常 2=已删除 3=已隐藏
function getStatusText(status : number) : string {
if (status === 1) return '正常'
if (status === 2) return '已删除'
if (status === 3) return '已隐藏'
return '未知'
}
function getStatusClass(status : number) : string {
if (status === 1) return 'pass'
if (status === 2) return 'deleted'
return 'wait'
}
// ========== 操作通过status → 1 ==========
async function handleApprove(id : string) {
const ok = await approveProductReview(id)
if (ok) {
uni.showToast({ title: '已通过', icon: 'success' })
loadReviews(currentPage.value)
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
// ========== 操作驳回status → 3 ==========
async function handleReject(id : string) {
const ok = await rejectProductReview(id)
if (ok) {
uni.showToast({ title: '已驳回', icon: 'success' })
loadReviews(currentPage.value)
} else {
uni.showToast({ title: '操作失败', icon: 'none' })
}
}
// ========== 操作删除status → 2软删除 ==========
function handleDelete(id : string) {
uni.showModal({
title: '确认删除',
content: '确定要删除该评价吗?此操作将标记为已删除状态。',
success: async (res : any) => {
if (res.confirm === true) {
const ok = await deleteProductReview(id)
if (ok) {
uni.showToast({ title: '已删除', icon: 'success' })
loadReviews(currentPage.value)
} else {
uni.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
// ========== 回复弹窗 ==========
const showReplyModal = ref(false)
const replyTargetId = ref('')
const replyContent = ref('')
const replySubmitting = ref(false)
function openReplyModal(id : string, existingReply : string | null) {
replyTargetId.value = id
replyContent.value = existingReply ?? ''
showReplyModal.value = true
}
function closeReplyModal() {
showReplyModal.value = false
replyTargetId.value = ''
replyContent.value = ''
}
async function submitReply() {
if (replyContent.value.trim() === '') {
uni.showToast({ title: '请输入回复内容', icon: 'none' })
return
}
replySubmitting.value = true
try {
const ok = await replyProductReview(replyTargetId.value, replyContent.value.trim())
if (ok) {
uni.showToast({ title: '回复成功', icon: 'success' })
closeReplyModal()
loadReviews(currentPage.value)
} else {
uni.showToast({ title: '回复失败', icon: 'none' })
}
} finally {
replySubmitting.value = false
}
}
// ========== PAGINATION STATE服务端分页 ==========
const currentPage = ref(1)
const pageSize = ref(10)
const jumpPageInput = ref('')
const pageSizeOptions = [10, 20, 30, 50]
const pageSizeOptionLabels = computed(() => pageSizeOptions.map((n : number) => `${n}条/页`))
const pageSizeIndex = computed(() => { const idx = pageSizeOptions.indexOf(pageSize.value); return idx >= 0 ? idx : 0 })
const total = ref(0) // 来自服务端 total_count
const totalPage = computed(() => Math.max(1, Math.ceil(total.value / pageSize.value)))
const visiblePages = computed((): number[] => {
const t = totalPage.value; const cur = currentPage.value
if (t <= 7) return Array.from({ length: t }, (_ : any, i : number) => i + 1)
if (cur <= 4) return [1, 2, 3, 4, 5, -1, t]
if (cur >= t - 3) return [1, -1, t - 4, t - 3, t - 2, t - 1, t]
return [1, -1, cur - 1, cur, cur + 1, -1, t]
})
const handlePageChange = (p : number) => {
if (p < 1 || p > totalPage.value) return
loadReviews(p)
}
const handlePageSizeChange = (e : any) => {
const idx = Number(e.detail.value)
pageSize.value = pageSizeOptions[idx] ?? pageSizeOptions[0]
loadReviews(1)
}
const handleJumpPage = () => {
const p = parseInt(jumpPageInput.value)
if (!isNaN(p) && p >= 1 && p <= totalPage.value) {
loadReviews(p)
}
}
// ========== END PAGINATION STATE ==========
</script>
<style scoped lang="scss">
.product-reply-page {
padding: 0;
background-color: transparent;
min-height: auto;
}
.search-card {
background: #fff;
padding: var(--admin-card-padding);
border-radius: 4px;
margin-bottom: var(--admin-section-gap);
}
.search-row {
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
}
.mt-16 { margin-top: 16px; }
.search-item {
display: flex;
flex-direction: row;
align-items: center;
.label { font-size: 14px; color: #606266; width: 80px; text-align: right; }
}
.date-range-row {
display: flex; flex-direction: row; align-items: center; gap: 8px;
.date-sep { font-size: 13px; color: #606266; }
}
.mock-select {
width: 160px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px;
display: flex; flex-direction: row; align-items: center; justify-content: space-between;
padding: 0 12px; font-size: 13px; color: #606266;
.arrow { font-size: 10px; color: #c0c4cc; }
}
.mock-input {
width: 200px; height: 32px; border: 1px solid #dcdfe6; border-radius: 4px; padding: 0 12px; font-size: 13px;
}
.date-input { width: 140px; }
.btn-primary { background: #1890ff; color: #fff; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: none; }
.btn-white { background: #fff; color: #606266; height: 32px; padding: 0 16px; border-radius: 4px; font-size: 14px; border: 1px solid #dcdfe6; }
.btn-sm { height: 32px; padding: 0 20px; font-size: 13px; }
.action-bar {
margin-bottom: 20px;
display: flex;
flex-direction: row;
gap: 12px;
}
.list-card {
background: #fff;
border-radius: 4px;
padding: var(--admin-card-padding);
}
/* 错误 & 加载提示 */
.error-tip {
padding: 12px 16px; background: #fff2f0; border-radius: 4px; margin-bottom: 12px;
.error-txt { font-size: 13px; color: #f5222d; }
}
.loading-tip {
padding: 24px 0; display: flex; justify-content: center;
.loading-txt { font-size: 14px; color: #909399; }
}
.table-v5 { width: 100%; }
.th-row {
display: flex;
flex-direction: row;
background-color: #f8f9fa;
border-bottom: 1px solid #e8e8e8;
}
.th {
padding: 12px 8px; font-size: 13px; font-weight: 500; color: #333;
display: flex; align-items: center; justify-content: center;
}
.tr-row {
display: flex; flex-direction: row; border-bottom: 1px solid #f0f0f0;
&:hover { background-color: #fafafa; }
}
.td {
padding: 12px 8px; font-size: 13px; color: #606266;
display: flex; align-items: center; justify-content: center;
}
/* 空态行 */
.empty-row {
padding: 48px 0;
display: flex; justify-content: center;
.empty-txt { font-size: 14px; color: #c0c4cc; }
}
.col-check { width: 50px; }
.col-id { width: 100px; }
.col-product { flex: 1.5; justify-content: flex-start; gap: 12px; }
.col-user { width: 120px; }
.col-score { width: 80px; }
.col-content { flex: 1; }
.col-reply { flex: 1; }
.col-status { width: 100px; }
.col-time { width: 160px; }
.col-op { width: 180px; display: flex; flex-direction: row; }
.p-img { width: 40px; height: 40px; border-radius: 4px; }
.p-img-placeholder { width: 40px; height: 40px; border-radius: 4px; background: #f5f5f5; }
.p-name-txt { font-size: 13px; line-height: 1.4; color: #1890ff; }
.id-txt { font-size: 12px; color: #909399; }
.blue-link { color: #1890ff; }
.status-tag {
padding: 2px 8px; border-radius: 2px; font-size: 12px;
&.pass { background: rgba(82, 196, 26, 0.1); color: #52c41a; }
&.wait { background: rgba(250, 173, 20, 0.1); color: #faad14; }
&.deleted { background: rgba(245, 34, 45, 0.1); color: #f5222d; }
}
.op-link {
font-size: 13px; color: #1890ff; margin: 0 4px; cursor: pointer;
&.red { color: #f5222d; }
&.warn { color: #faad14; }
}
/* 回复弹窗 */
.reply-modal-mask {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.45);
display: flex; align-items: center; justify-content: center;
z-index: 1000;
}
.reply-modal {
background: #fff; border-radius: 6px; width: 480px; max-width: 90vw;
display: flex; flex-direction: column;
}
.modal-header {
padding: 16px 20px; border-bottom: 1px solid #f0f0f0;
display: flex; flex-direction: row; align-items: center; justify-content: space-between;
.modal-title { font-size: 16px; font-weight: 500; color: #333; }
.modal-close { font-size: 16px; color: #909399; cursor: pointer; padding: 4px; }
}
.modal-body {
padding: 20px;
.reply-textarea {
width: 100%; height: 100px; border: 1px solid #dcdfe6; border-radius: 4px;
padding: 8px 12px; font-size: 13px; resize: none;
}
}
.modal-footer {
padding: 12px 20px; border-top: 1px solid #f0f0f0;
display: flex; flex-direction: row; justify-content: flex-end; gap: 12px;
}
</style>