feat: 物流+评论

This commit is contained in:
zc 2025-11-17 23:46:09 +08:00
parent 580cd25221
commit f1f3b01081
5 changed files with 1114 additions and 6 deletions

View File

@ -6,7 +6,15 @@ const order = {
getOrderDetail: ['/order/detail'], // 获取订单详情
packOrder: ['/order/toShipping'], // 打包
unpackOrder: ['/order/unpack'], // 取消打包
finishDeliver: ['/order/delivered'] // 妥投
finishDeliver: ['/order/delivered'], // 妥投
getLogisticsInfo: ['/logistics/query'], // 获取物流信息
/**
*
*/
getUserReviews: ['/comment/list'], // 获取用户评论
agreeComment: ['/comment/agree'], // 同意
rejectComment: ['/comment/reject'] // 拒绝
}
export default order

View File

@ -200,7 +200,7 @@
<!-- 物流弹窗 -->
<logistics-dialog
v-model="logisticsDialogVisible"
:order-id="currentOrderId"
:track-number="curTrackNumber"
@close="handleCloseLogisticsDialog"
/>
<!-- 打包 -->
@ -412,11 +412,11 @@ const onButtonClick = (interfaceUri: string, packageData: PackageItem) => {
}
//
const logisticsDialogVisible = ref(false)
const currentOrderId = ref<number>(NaN)
const curTrackNumber = ref<number>(NaN)
//
const handleViewLogistics = (packageData: PackageItem) => {
currentOrderId.value = packageData.trackNumber
curTrackNumber.value = packageData.trackNumber
logisticsDialogVisible.value = true
}
@ -431,7 +431,7 @@ const handlePack = (orderLines: OrderItem[]) => {
//
const handleCloseLogisticsDialog = () => {
logisticsDialogVisible.value = false
currentOrderId.value = NaN
curTrackNumber.value = NaN
}
const orderStatusMap = {

View File

@ -0,0 +1,486 @@
<template>
<el-dialog
v-model="dialogVisible"
title="物流跟踪"
width="800px"
:before-close="handleClose"
@close="handleClose"
>
<div class="logistics-dialog" v-loading="loading">
<!-- 状态时间线 -->
<div class="status-timeline">
<div
v-for="(stage, index) in statusStages"
:key="stage.key"
class="timeline-item"
:class="{ active: stage.active, completed: stage.completed }"
>
<div class="timeline-icon">
<el-icon v-if="stage.icon" :size="24">
<component :is="stage.icon" />
</el-icon>
</div>
<div class="timeline-label">{{ stage.label }}</div>
<div v-if="index < statusStages.length - 1" class="timeline-line"></div>
</div>
</div>
<!-- 物流跟踪记录 -->
<div class="tracking-list">
<div
v-for="(item, index) in trackingRecords"
:key="index"
class="tracking-item"
:class="{ 'is-first': index === 0 }"
>
<div class="tracking-time">{{ item.time }}</div>
<div class="tracking-content">
<div class="tracking-status">{{ item.status }}</div>
<div class="tracking-message">{{ item.context }}</div>
</div>
<div v-if="index < trackingRecords.length - 1" class="tracking-line"></div>
</div>
<div v-if="!loading && trackingRecords.length === 0" class="empty-tracking">
<el-empty description="暂无物流信息" :image-size="80" />
</div>
</div>
<!-- 底部信息区域 -->
<div class="bottom-info">
<!-- 物流信息 -->
<div class="logistics-info">
<div class="info-item">
<span class="info-label">第三方物流:</span>
<span class="info-value">{{ logisticsInfo.logisticsCompany || '-' }}</span>
</div>
<div class="info-item">
<span class="info-label">发货人:</span>
<span class="info-value"
>{{ logisticsInfo.deliveryMan || '-' }}{{ logisticsInfo.deliveryPhone || '' }}</span
>
</div>
<div class="info-item">
<span class="info-label">派件人:</span>
<span class="info-value"
>{{ logisticsInfo.pickupMan || '-' }}{{ logisticsInfo.pickupPhone || '' }}</span
>
</div>
</div>
<!-- 包裹信息图标 -->
<!-- <div
v-if="packageInfo && (packageInfo.dimensions || packageInfo.num)"
class="package-icon-wrapper"
>
<div class="package-icon">
<img src="" alt="package" />
</div>
<div class="package-text">
<template v-if="packageInfo.dimensions && packageInfo.num">
{{ formatPackageInfo(packageInfo.dimensions, packageInfo.num) }}
</template>
<template v-else-if="packageInfo.dimensions">
{{ packageInfo.dimensions }}
</template>
<template v-else-if="packageInfo.num"> x {{ packageInfo.num }} </template>
</div>
</div> -->
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { Box, Goods, User } from '@element-plus/icons-vue'
interface LogisticsInfo {
logisticsCompany?: string
pickupMan?: string
pickupPhone?: string
deliveryMan?: string
deliveryPhone?: string
}
interface TrackingRecord {
time: string
status: string
context: string
}
/*
interface PackageInfo {
dimensions?: string
num?: string
} */
interface StatusStage {
key: string
label: string
icon?: any
active: boolean
completed: boolean
}
const props = defineProps<{
modelValue: boolean
trackNumber: number
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
close: []
}>()
const dialogVisible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
//
const loading = ref(false)
//
const logisticsInfo = ref<LogisticsInfo>({})
const trackingRecords = ref<TrackingRecord[]>([])
/* const packageInfo = ref<PackageInfo>({})
const currentStatus = ref<'' | 'pickup' | 'shipping' | 'delivered'>('') */
//
const statusStages = computed<StatusStage[]>(() => {
return [
{
key: 'pickup',
label: '提货',
icon: Box,
active: false,
completed: true
/* active: currentStatus.value === 'pickup',
completed: currentStatus.value === 'shipping' || currentStatus.value === 'delivered' */
},
{
key: 'shipping',
label: '运输中',
icon: Goods,
active: false,
completed: true
/* active: currentStatus.value === 'shipping',
completed: currentStatus.value === 'delivered' */
},
{
key: 'delivered',
label: '交付',
icon: User,
active: false,
completed: true
/* active: currentStatus.value === 'delivered',
completed: false */
}
]
})
//
const fetchLogisticsData = async (trackNumber: number) => {
loading.value = true
const res = await api.order.getLogisticsInfo.post!<any>({ trackNumber }).finally(() => {
loading.value = false
})
const data = res.data
logisticsInfo.value = {
logisticsCompany: data.logisticsCompany,
pickupPhone: data.courierInfo.deliveryManPhone,
pickupMan: data.courierInfo.pickupManName,
deliveryPhone: data.courierInfo.pickupManPhone,
deliveryMan: data.courierInfo.deliveryManName
}
trackingRecords.value = data.data
/* packageInfo.value = { dimensions: '0.01 x 2', num: '2' }
currentStatus.value = 'shipping' */
}
// trackNumber
watch(
() => dialogVisible.value,
(visible) => {
if (visible) {
fetchLogisticsData(props.trackNumber)
} else if (!visible) {
logisticsInfo.value = {
logisticsCompany: '',
pickupPhone: '',
pickupMan: '',
deliveryPhone: '',
deliveryMan: ''
}
trackingRecords.value = []
/* packageInfo.value = {}
currentStatus.value = 'pickup' */
}
}
)
const handleClose = () => {
emit('close')
}
</script>
<style scoped lang="scss">
.logistics-dialog {
padding: 8px 0;
.status-timeline {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 16px;
margin-bottom: 24px;
position: relative;
&::before {
// content: '';
position: absolute;
top: 50%;
left: 10%;
right: 10%;
height: 2px;
background: #ebeef5;
z-index: 0;
}
.timeline-item {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
flex: 1;
.timeline-icon {
width: 48px;
height: 48px;
border-radius: 50%;
background: #f5f7fa;
border: 2px solid #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
color: #909399;
transition: all 0.3s;
}
.timeline-label {
font-size: 13px;
color: #909399;
transition: color 0.3s;
}
&.active {
.timeline-icon {
background: #e6f4ff;
border-color: #409eff;
color: #409eff;
}
.timeline-label {
color: #409eff;
font-weight: 500;
}
}
&.completed {
.timeline-icon {
background: #f0f9ff;
border-color: #67c23a;
color: #67c23a;
}
.timeline-label {
color: #67c23a;
}
}
.timeline-line {
position: absolute;
top: 24px;
right: -50%;
width: 100%;
height: 2px;
background: #ebeef5;
z-index: -1;
.timeline-item.active ~ &,
.timeline-item.completed ~ & {
background: #67c23a;
}
}
}
}
.tracking-list {
max-height: 400px;
overflow-y: auto;
overflow-x: hidden;
padding: 0 24px 0 16px;
position: relative;
margin-bottom: 24px;
//
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #f5f7fa;
border-radius: 3px;
margin: 8px 0;
}
&::-webkit-scrollbar-thumb {
background: #c0c4cc;
border-radius: 3px;
transition: background 0.3s;
&:hover {
background: #909399;
}
}
// Firefox
scrollbar-width: thin;
scrollbar-color: #c0c4cc #f5f7fa;
.tracking-item {
position: relative;
padding-left: 150px;
padding-bottom: 24px;
min-height: 60px;
&.is-first {
.tracking-time {
color: #409eff;
font-weight: 500;
}
.tracking-status {
color: #409eff;
}
}
.tracking-time {
position: absolute;
left: 0;
top: 0;
font-size: 12px;
color: #909399;
min-width: 70px;
}
.tracking-content {
.tracking-status {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.tracking-message {
font-size: 13px;
color: #606266;
line-height: 1.5;
}
}
.tracking-line {
position: absolute;
left: 35px;
top: 20px;
bottom: 0;
width: 2px;
background: #ebeef5;
}
&:last-child {
.tracking-line {
display: none;
}
}
&::before {
content: '';
position: absolute;
left: 130px;
top: 6px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #dcdfe6;
border: 2px solid #fff;
}
&.is-first::before {
background: #409eff;
border-color: #409eff;
}
}
.empty-tracking {
padding: 40px 0;
}
}
.bottom-info {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 16px 0 16px 40px;
border-top: 1px solid #ebeef5;
margin-top: 16px;
.logistics-info {
display: flex;
flex-direction: column;
gap: 8px;
flex: 1;
.info-item {
display: flex;
align-items: center;
font-size: 13px;
gap: 4px;
.info-label {
color: #606266;
}
.info-value {
color: #303133;
font-weight: 400;
}
}
}
.package-icon-wrapper {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 60px;
.package-icon {
width: 40px;
height: 40px;
border: 1px solid #dcdfe6;
background: #ffffff;
border-radius: 4px;
flex-shrink: 0;
}
.package-text {
font-size: 12px;
color: #606266;
text-align: center;
white-space: nowrap;
}
}
}
}
</style>

View File

@ -221,8 +221,8 @@ const handleSubmit = async () => {
}
await api.order.packOrder.post!(submitData)
ElMessage.success('打包成功')
tableData.value = tableData.value.filter((item) => !selectedRows.value.includes(item))
handleClose(false)
selectedRows.value = []
} catch (error) {
ElMessage.error('打包失败')
} finally {

View File

@ -0,0 +1,614 @@
<template>
<div class="user-reviews-page">
<!-- 筛选栏 -->
<el-card class="filter-card" shadow="never">
<el-form :inline="true" class="filter-form">
<el-form-item label="商品id">
<el-input
v-model="filters.productId"
placeholder="请输入商品ID"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="订单号">
<el-input
v-model="filters.tradeOrderId"
placeholder="请输入订单ID"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="买家名称">
<el-input
v-model="filters.buyerName"
placeholder="请输入买家名称"
clearable
style="width: 200px"
/>
</el-form-item>
<el-form-item label="创建时间">
<el-date-picker
v-model="filters.dateRange"
type="datetimerange"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
搜索
</el-button>
<el-button type="danger" @click="handleReset" style="margin-left: 8px">
<el-icon><Refresh /></el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 评论列表 -->
<el-card class="order-table-card" shadow="never">
<!-- INSERT_YOUR_CODE -->
<el-tabs
class="bg-white"
v-model="filters.status"
@tab-change="handleTabChange"
style="margin-bottom: 18px"
>
<el-tab-pane label="全部" name=""></el-tab-pane>
<el-tab-pane label="同意" name="approval_pass"></el-tab-pane>
<el-tab-pane label="拒绝" name="approval_not_pass"></el-tab-pane>
</el-tabs>
<div class="order-table-header">
<div class="col goods">商品</div>
<div class="col total">评分</div>
<!-- <div class="col status">状态</div> -->
<div class="col actions">操作</div>
</div>
<div class="order-list">
<div v-for="review in reviews" :key="review.id" class="order-item">
<div class="order-top">
<div class="top-left">
<el-checkbox v-model="review.checked" />
<span class="platform">订单{{ review.tradeOrderId }}</span>
<el-divider direction="vertical" />
<div class="platform-name">购de着</div>
<!-- <el-link :underline="false" type="primary">{{ order.summary }}</el-link> -->
</div>
<div class="top-right">
<span class="seller">{{ review.buyerName }}</span>
<el-divider direction="vertical" />
<span class="order-meta">创建时间{{ review.createTime }}</span>
</div>
</div>
<div class="order-content">
<!-- 左侧评论详情 -->
<div class="review-left p-4 relative">
<div class="main-review">
<p class="mb-2">主评{{ review.productComment }}</p>
<el-image
class="mr-2"
v-for="img in review.vvCommentDetailEntities"
:key="img.id"
style="width: 80px; height: 80px"
:src="img.commentUrl"
:preview-src-list="review.vvCommentDetailEntities.map((it) => it.commentUrl)"
fit="cover"
/>
</div>
<div class="review-date text-xs">{{ review.modifyTime }}</div>
<div class="review-actions absolute bottom-2 right-2">
<span v-if="review.reason">拒绝原因{{ review.reason }}</span>
<!-- <el-button link type="primary" size="small" @click="handleViewReplies(review)">
<el-icon><ChatDotRound /></el-icon>
{{ review.replyCount }} 回复
</el-button>
<el-button link type="danger" size="small" @click="handleReport(review)">
<el-icon><WarningFilled /></el-icon>
举报
</el-button> -->
</div>
</div>
<!-- 中间评分区域 -->
<div class="review-middle p-3">
<div class="rating-item">
<span class="rating-label">总体评分:</span>
<el-rate
v-model="review.overallRating"
disabled
show-score
size="small"
text-color="#ff9900"
score-template="{value}"
/>
</div>
<div class="rating-item">
<span class="rating-label">商品分:</span>
<el-rate
v-model="review.descMatch"
disabled
show-score
size="small"
text-color="#ff9900"
score-template="{value}"
/>
</div>
<div class="rating-item">
<span class="rating-label">卖家服务评分:</span>
<el-rate
v-model="review.sellerService"
disabled
show-score
size="small"
text-color="#ff9900"
score-template="{value}"
/>
</div>
<div class="rating-item">
<span class="rating-label">物流评分:</span>
<el-rate
v-model="review.logisticsService"
disabled
show-score
size="small"
text-color="#ff9900"
score-template="{value}"
/>
</div>
</div>
<!-- 右侧商品信息和操作 -->
<div class="review-right">
<div class="product-section p-3">
<el-image class="product-image" :src="review.productImage" fit="cover">
<template #error>
<div class="image-fallback">无图</div>
</template>
</el-image>
<div class="product-info">
<div class="product-tag">{{ review.productTitle }}</div>
<div class="product-name">{{ review.skuInfo }}</div>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-center">
<el-button
v-for="(action, i) in review.commentActionList"
:key="action.interfaceUri"
:type="i === 0 ? 'primary' : ''"
class="!ml-0 !mt-2"
@click="onButtonClick(action.interfaceUri, review)"
>{{ action.desc }}</el-button
>
</div>
</div>
</div>
</div>
<div class="pagination">
<el-pagination
background
layout="prev, pager, next, jumper"
:page-size="20"
size="small"
:total="pagination.total"
@current-change="handleCurrentChange"
@size-change="handleSizeChange"
/>
</div>
</el-card>
<el-dialog v-model="dialogVisible" title="拒绝原因" width="500px">
<el-form :model="form" label-width="0">
<el-form-item label=" ">
<el-input v-model="form.reason" type="textarea" :rows="4" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="onRejectComment(form.reason)">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { Search, ChatDotRound, WarningFilled, Refresh } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
// import order from '@/api/order'
// import request from '@/utils/request'
const dialogVisible = ref(false)
const form = reactive({
reason: '',
id: NaN
})
//
const filters = reactive({
productId: '',
tradeOrderId: '',
buyerName: '',
dateRange: [],
status: ''
})
//
interface Review {
id: number
checked: boolean
tradeOrderId: string
buyerName: string
createTime: string
productComment: string
modifyTime: string
descMatch: number
sellerService: number
logisticsService: number
productImage: string
productTitle: string
skuInfo: string
reason: string
vvCommentDetailEntities: { id: string; commentUrl: string }[]
commentActionList: { desc: string; interfaceUri: string }[]
}
const reviews = ref<Review[]>([])
const loading = ref(false)
//
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
//
const handleSearch = async () => {
pagination.currentPage = 1
await fetchReviews()
}
//
const fetchReviews = async () => {
loading.value = true
const res = await api.order.getUserReviews.post!<{ rows: Review[]; total: number }>({
currentPage: pagination.currentPage,
pageSize: pagination.pageSize,
...filters,
minCreateTimestamp: filters.dateRange?.[0],
maxCreateTimestamp: filters.dateRange?.[1]
})
reviews.value = res.data.rows.map((item) => {
let skuInfo = ''
try {
skuInfo = JSON.parse(item.skuInfo)
.map((it: any) => `${it.propertyName}:${it.propertyValue}`)
.join(',')
} catch (error) {
skuInfo = item.skuInfo
}
return {
...item,
skuInfo,
createTime: new Date(item.createTime).toLocaleString(),
modifyTime: new Date(item.modifyTime).toLocaleString()
}
})
pagination.total = res.data.total
loading.value = false
}
const onButtonClick = (interfaceUri: string, review: Review) => {
if (interfaceUri === '/mm/comment/agree') {
onAgreeComment(review)
} else if (interfaceUri === '/app/comment/reject') {
form.reason = ''
form.id = review.id
dialogVisible.value = true
}
}
//
const onAgreeComment = (review: Review) => {
api.order.agreeComment.post!({
id: review.id
}).then(() => {
ElMessage.success('同意成功')
fetchReviews()
})
}
//
const onRejectComment = (review: Review) => {
api.order.rejectComment.post!(form).then(() => {
ElMessage.success('拒绝成功')
dialogVisible.value = false
fetchReviews()
})
}
const handleTabChange = () => {
fetchReviews()
}
//
const handleSizeChange = (size: number) => {
pagination.pageSize = size
pagination.currentPage = 1
fetchReviews()
}
//
const handleCurrentChange = (page: number) => {
pagination.currentPage = page
fetchReviews()
}
//
const handleReset = () => {
filters.productId = ''
filters.tradeOrderId = ''
filters.buyerName = ''
filters.dateRange = []
fetchReviews()
}
//
onMounted(() => {
fetchReviews()
})
</script>
<style scoped lang="scss">
.user-reviews-page {
background-color: #f5f7fa;
min-height: calc(100vh - 84px);
.filter-card {
margin-bottom: 20px;
.filter-hint {
font-size: 14px;
color: #909399;
margin-bottom: 16px;
}
.filter-form {
:deep(.el-form-item) {
margin-bottom: 16px;
}
}
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 20px;
padding: 20px 0;
}
}
.user-reviews-page {
.review-card {
.review-content {
flex-direction: column;
.review-middle {
border-left: none;
border-right: none;
border-top: 1px solid #ebeef5;
border-bottom: 1px solid #ebeef5;
padding: 16px 0;
}
}
}
}
.order-table-card {
padding: 0 0 16px;
.order-table-header {
display: grid;
grid-template-columns: minmax(400px, 1fr) 240px 300px 150px;
padding: 12px 24px;
background: #f9fafc;
border-bottom: 1px solid #ebeef5;
font-size: 13px;
color: #909399;
.col:first-child {
padding-left: 44px;
}
}
.order-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
}
.order-item {
border: 1px solid #ebeef5;
border-radius: 6px;
background: #fff;
overflow-x: scroll;
.order-top {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
background: #fafafa;
border-bottom: 1px solid #f0f2f5;
font-size: 13px;
color: #606266;
.top-left,
.top-right {
display: flex;
align-items: center;
gap: 8px;
}
.platform {
font-weight: 600;
color: #303133;
}
.seller {
color: #303133;
}
.order-meta {
color: #909399;
}
}
.order-content {
display: grid;
grid-template-columns: minmax(400px, 1fr) 240px 300px 150px;
gap: 12px;
padding: 0;
font-size: 13px;
color: #606266;
.goods {
display: flex;
flex-direction: column;
.goods-item + .goods-item {
border-top: 1px solid #f0f2f5;
}
.goods-item {
display: flex;
gap: 12px;
border-right: 1px solid #f0f2f5;
border-radius: 6px;
padding: 12px;
align-items: center;
.goods-image {
width: 60px;
height: 60px;
border-radius: 6px;
overflow: hidden;
}
.image-fallback {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: #f2f3f5;
color: #909399;
font-size: 12px;
}
.goods-info {
flex: 1;
.title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 6px;
}
.sub {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #909399;
}
}
.goods-price {
font-weight: 600;
color: #303133;
}
}
}
.total {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
.amount {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.remark {
font-size: 12px;
color: #909399;
}
}
.delivery {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
.delivery-type {
font-weight: 600;
color: #303133;
}
.delivery-warehouse {
font-size: 12px;
color: #909399;
}
}
.status {
display: flex;
flex-direction: column;
justify-content: center;
gap: 8px;
.status-label {
font-weight: 600;
color: #303133;
}
.status-detail {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #909399;
}
}
.actions {
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 8px;
}
}
}
.pagination {
display: flex;
justify-content: flex-end;
padding: 0 24px;
margin-top: 8px;
}
}
</style>