523 lines
17 KiB
Plaintext
523 lines
17 KiB
Plaintext
<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>
|