Files
guilixu-admin/src/views/shop/shopOrder/index.vue
赵忠林 bef50c2f26 feat(order): 支持线下付款先发货后结款流程
- 后端移除确认收款时订单状态限制,已发货订单确认收款后自动完成订单
- 管理后台订单列表和操作按钮区分线下付款未付款不同发货状态
- 离线付款弹窗新增发货状态字段及提示文案动态展示
- 订单详情支付状态标签区分已发货待收款状态,更新步骤条显示逻辑
- 小程序端订单状态文本和操作按钮支持线下付款先发货后结款场景
2026-07-17 15:09:51 +08:00

901 lines
28 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-page-header :title="getPageTitle()" @back="() => $router.go(-1)">
<a-card style="margin-bottom: 20px">
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
/>
</a-card>
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<div class="order-notify-bar">
<a-space>
<span class="order-notify-label">新订单提醒</span>
<a-switch
v-model:checked="notifyEnabled"
checked-children=""
un-checked-children=""
@change="onNotifyToggle"
/>
<a-tooltip
title="开启后每60秒自动检测新订单检测到后播放叮声并语音播报"
>
<InfoCircleOutlined class="order-notify-tip" />
</a-tooltip>
<a-button
type="link"
size="small"
:disabled="!notifyEnabled"
@click="onTestNotify"
>
测试提醒
</a-button>
</a-space>
</div>
<a-tabs type="card" v-model:activeKey="activeKey" @change="onTabs">
<a-tab-pane key="all" tab="全部" />
<a-tab-pane key="unpaid" tab="待付款" />
<a-tab-pane key="undelivered" tab="待发货" />
<a-tab-pane key="unreceived" tab="待收货" />
<a-tab-pane key="completed" tab="已完成" />
<a-tab-pane key="refunded" tab="退货/售后" />
<a-tab-pane key="cancelled" tab="已关闭" />
</a-tabs>
<ele-pro-table
ref="tableRef"
row-key="orderId"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
cache-key="proShopOrderTable"
:toolbar="false"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar> </template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userInfo'">
<a-space :size="8">
<a-avatar
v-if="record.avatar"
:src="record.avatar"
:size="48"
shape="square"
/>
<div class="leading-tight">
<div class="cursor-pointer" @click.stop="onSearch(record)">
{{
record.nickname ||
record.realName ||
record.name ||
'匿名'
}}
</div>
<div
v-if="record.mobile || record.phone"
class="text-gray-500 text-xs"
>
{{ record.mobile || record.phone }}
</div>
<div v-if="record.userId" class="text-gray-400 text-xs">
(ID:{{ record.userId }})
</div>
</div>
</a-space>
</template>
<template v-if="column.key === 'statusInfo'">
<div class="py-1">
<a-space :size="6" wrap>
<!-- 货到付款标识 -->
<a-tag
v-if="record.payType === 8"
color="blue"
>货到付款</a-tag>
<!-- 线下付款标识 -->
<a-tag
v-if="record.payType === 9"
color="orange"
>线下付款</a-tag>
<!-- 支付状态 -->
<a-tag
v-if="record.payStatus == 1 && record.payType !== 8 && record.payType !== 9"
color="green"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>已付款</a-tag
>
<a-tag
v-else-if="record.payStatus == 1 && record.payType === 8"
color="green"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>待收货付款</a-tag
>
<a-tag
v-else-if="record.payStatus == 1 && record.payType === 9"
color="green"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>已确认收款</a-tag
>
<a-tag
v-else-if="record.payStatus == 0 && record.payType === 9"
color="orange"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>待确认收款</a-tag
>
<a-tag
v-else-if="record.payStatus == 0 || record.payStatus == null"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>未付款</a-tag
>
<a-tag
v-else-if="record.payStatus == 3"
color="orange"
@click.stop="updatePayStatus(record)"
class="cursor-pointer"
>占场中</a-tag
>
<!-- 发货状态 -->
<a-tag
v-if="record.deliveryStatus == 10"
class="cursor-pointer"
>未发货</a-tag
>
<a-tag v-if="record.deliveryStatus == 20" color="green"
>已发货</a-tag
>
<a-tag v-if="record.deliveryStatus == 30" color="blue"
>部分发货</a-tag
>
<!-- 开票状态 -->
<!-- <a-tag v-if="record.isInvoice == 0">未开具</a-tag>-->
<a-tag v-if="record.isInvoice == 1" color="green">已开具</a-tag>
<a-tag v-if="record.isInvoice == 2" color="blue">不能开具</a-tag>
<!-- 订单状态 -->
<a-tag v-if="record.orderStatus === 0">未完成</a-tag>
<a-tag v-if="record.orderStatus === 1" color="green"
>已完成</a-tag
>
<a-tag v-if="record.orderStatus === 2">已关闭</a-tag>
<a-tag v-if="record.orderStatus === 3" color="red"
>关闭中</a-tag
>
<a-tag v-if="record.orderStatus === 4" color="red"
>退款申请中</a-tag
>
<a-tag v-if="record.orderStatus === 5" color="red"
>退款被拒绝</a-tag
>
<a-tag v-if="record.orderStatus === 6" color="orange"
>已退款</a-tag
>
<a-tag v-if="record.orderStatus === 7" color="pink"
>客户端申请退款</a-tag
>
<!-- 结算状态 -->
<!-- <a-tag v-if="record.isSettled === 0">未结算</a-tag>-->
<!-- <a-tag v-if="record.isSettled === 1" color="green"-->
<!-- >已结算</a-tag-->
<!-- >-->
</a-space>
</div>
</template>
<template v-if="column.key === 'orderGoods'">
<template v-for="(item, index) in record.orderGoods" :key="index">
<div class="item py-1">
<a-space :id="`g-${index}`">
<a-avatar :src="getCompressedImageUrl(item.image,{ width: 100 })" shape="square" :size="50" />
<span>{{ item.goodsName }}</span>
</a-space>
</div>
</template>
</template>
<template v-if="column.key === 'payType'">
<!-- 货到付款特殊标识 -->
<template v-if="record.payType === 8">
<a-tag color="blue">货到付款</a-tag>
</template>
<!-- 线下付款特殊标识 -->
<template v-else-if="record.payType === 9">
<a-tag color="orange">线下付款</a-tag>
</template>
<template v-else>
<template v-for="item in getPayType()">
<template v-if="record.payStatus == 1">
<span v-if="item.value == record.payType">{{
item.label
}}</span>
</template>
<template v-else>
<span></span>
</template>
</template>
</template>
</template>
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50" />
</template>
<template v-if="column.key === 'sex'">
<a-tag v-if="record.sex === 1"></a-tag>
<a-tag v-if="record.sex === 2"></a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0" color="green">显示</a-tag>
<a-tag v-if="record.status === 1" color="red">隐藏</a-tag>
</template>
<template v-if="column.key === 'createTime'">
<div class="flex flex-col">
<a-tooltip title="创建时间">
<span class="text-gray-400">{{ record.createTime }}</span>
</a-tooltip>
<a-tooltip title="支付时间">
<span class="text-blue-400">{{ record.payTime }}</span>
</a-tooltip>
<a-tooltip title="发货时间">
<span class="text-green-400">{{ record.deliveryTime }}</span>
</a-tooltip>
<a-tooltip title="退款时间">
<span class="text-orange-400">{{ record.refundTime }}</span>
</a-tooltip>
</div>
</template>
<template v-if="column.key === 'action'">
<a-space>
<!-- 查看详情 - 所有状态都可以查看 -->
<a @click.stop="openEdit(record)"> <EyeOutlined /> 详情 </a>
<!-- 未付款状态的操作排除线下付款 -->
<template v-if="!record.payStatus && record.orderStatus === 0 && record.payType !== 9">
<a @click.stop="handleEditOrder(record)">
<EditOutlined /> 修改
</a>
<a @click.stop="handleCancelOrder(record)">
<span class="ele-text-warning"> <CloseOutlined /> 关闭 </span>
</a>
</template>
<!-- 线下付款·待确认收款状态的操作支持先发货后结款 -->
<template v-if="!record.payStatus && record.orderStatus === 0 && record.payType === 9 && record.deliveryStatus === 10">
<a @click.stop="handleConfirmOfflinePayment(record)" class="ele-text-success">
<CheckCircleOutlined /> 确认收款
</a>
<a @click.stop="handleDelivery(record)" class="ele-text-primary">
<SendOutlined /> 发货
</a>
<a @click.stop="handleCancelOrder(record)">
<span class="ele-text-warning"> <CloseOutlined /> 关闭 </span>
</a>
</template>
<!-- 线下付款·已发货未收款状态的操作先发货后结款 -->
<template v-if="!record.payStatus && record.orderStatus === 0 && record.payType === 9 && record.deliveryStatus === 20">
<a @click.stop="handleConfirmOfflinePayment(record)" class="ele-text-success">
<CheckCircleOutlined /> 确认收款
</a>
</template>
<!-- 已付款未发货状态的操作 -->
<template
v-if="
record.payStatus &&
record.deliveryStatus === 10 &&
record.orderStatus === 0 &&
!isCancelledStatus(record.orderStatus) &&
!isRefundStatus(record.orderStatus)
"
>
<a @click.stop="handleDelivery(record)" class="ele-text-primary">
<SendOutlined /> 发货
</a>
<a v-permission="'shop:shopOrder:refund'" @click.stop="handleApplyRefund(record)">
<UndoOutlined /> 退款
</a>
</template>
<!-- 已发货未完成状态的操作 -->
<template
v-if="
record.payStatus &&
record.deliveryStatus === 20 &&
record.orderStatus === 0
"
>
<a
@click.stop="handleConfirmReceive(record)"
class="ele-text-primary"
>
<CheckOutlined /> 确认收货
</a>
<a v-permission="'shop:shopOrder:refund'" @click.stop="handleApplyRefund(record)">
<UndoOutlined /> 退款
</a>
</template>
<!-- 退款相关状态的操作 -->
<template v-if="isRefundStatus(record.orderStatus)">
<template
v-if="record.orderStatus === 4 || record.orderStatus === 7"
>
<a
@click.stop="handleApproveRefund(record)"
class="ele-text-success"
>
<CheckCircleOutlined /> 同意退款
</a>
<a
@click.stop="handleRejectRefund(record)"
class="ele-text-danger"
>
<CloseCircleOutlined /> 拒绝退款
</a>
</template>
<template v-if="record.orderStatus === 5">
<a @click.stop="handleRetryRefund(record)">
<RedoOutlined /> 重新处理
</a>
</template>
</template>
<!-- 已完成状态的操作 -->
<template v-if="record.orderStatus === 1">
<a v-permission="'shop:shopOrder:refund'" @click.stop="handleApplyRefund(record)">
<UndoOutlined /> 退款
</a>
</template>
<!-- 删除操作 - 已完成、已关闭、退款成功的订单可以删除 -->
<template v-if="canDeleteOrder(record)">
<a-popconfirm
title="确定要删除此订单吗?删除后无法恢复。"
@confirm="remove(record)"
>
<a class="ele-text-danger" @click.stop>
<DeleteOutlined /> 删除
</a>
</a-popconfirm>
</template>
</a-space>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<OrderInfo v-model:visible="showEdit" :data="current" @done="reload" />
<!-- 发货弹窗 -->
<DeliveryModal
v-model:visible="showDelivery"
:data="current"
@done="reload"
/>
<!-- 确认线下收款弹窗 -->
<OfflinePaymentModal
v-model:visible="showOfflinePayment"
:data="current"
@done="reload"
/>
</a-page-header>
</template>
<script lang="ts" setup>
import { createVNode, ref, onMounted, onActivated } from 'vue';
import { useRoute } from 'vue-router';
import type { EleProTable } from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import {
ExclamationCircleOutlined,
EyeOutlined,
EditOutlined,
CloseOutlined,
SendOutlined,
UndoOutlined,
CheckOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
RedoOutlined,
DeleteOutlined,
InfoCircleOutlined
} from '@ant-design/icons-vue';
import Search from './components/search.vue';
import { getPageTitle } from '@/utils/common';
import { toDateString } from 'ele-admin-pro';
import OrderInfo from './components/orderInfo.vue';
import DeliveryModal from './components/deliveryModal.vue';
import OfflinePaymentModal from './components/OfflinePaymentModal.vue';
import { ShopOrder, ShopOrderParam } from '@/api/shop/shopOrder/model';
import {
pageShopOrder,
repairOrder,
removeShopOrder,
removeBatchShopOrder,
updateShopOrder, refundShopOrder
} from '@/api/shop/shopOrder';
import { updateUser } from '@/api/system/user';
import { getPayType } from '@/utils/shop';
import { getCompressedImageUrl } from '@/utils/image';
import { message, Modal } from 'ant-design-vue';
import { useOrderNotify } from './useOrderNotify';
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<ShopOrder[]>([]);
// 当前编辑数据
const current = ref<ShopOrder | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 是否显示发货弹窗
const showDelivery = ref(false);
// 是否显示确认线下收款弹窗
const showOfflinePayment = ref(false);
// 加载状态
const loading = ref(true);
// 激活的标签(支持从路由参数初始化,如 /shop/shopOrder?tab=all
const route = useRoute();
const validTabs = ['all', 'unpaid', 'undelivered', 'unreceived', 'completed', 'refunded', 'cancelled'];
const tabFromQuery = route.query.tab as string;
const activeKey = ref<string>(
validTabs.includes(tabFromQuery) ? tabFromQuery : 'all'
);
// ============ 新订单提醒 ============
const { enabled: notifyEnabled, toggle: onNotifyToggle, testNotify: onTestNotify, clearBadge } = useOrderNotify({
onNewOrder: () => reload()
});
// 进入订单列表页时清除菜单红点(标记已查看),并同步 lastOrderId 为当前最新订单
// 兼容 keep-aliveonMounted 处理首次进入onActivated 处理缓存再次激活
let orderBadgeMounted = false;
onMounted(() => {
clearBadge();
orderBadgeMounted = true;
});
onActivated(() => {
if (orderBadgeMounted) clearBadge();
});
// 根据tab key获取对应的statusFilter值
// undefined全部0待付款1待发货2待核销3待收货4待评价5已完成6已退款7已删除8已关闭
const getStatusFilterByTab = (key: string): number | undefined => {
const filterMap: Record<string, number> = {
unpaid: 0,
undelivered: 1,
unverified: 2,
unreceived: 3,
unevaluated: 4,
completed: 5,
refunded: 6,
deleted: 7,
cancelled: 8
};
return filterMap[key];
};
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where,
orders,
filters
}) => {
if (filters) {
where.status = filters.status;
}
where.type = 0;
// 确保初次加载时也按当前tab筛选statusFilter未设置时从activeKey推导
if (where.statusFilter === undefined) {
where.statusFilter = getStatusFilterByTab(activeKey.value);
}
return pageShopOrder({
...where,
...orders,
page,
limit
});
};
// 表格列配置
const columns = ref<ColumnItem[]>([
{
title: '订单编号',
dataIndex: 'orderNo',
key: 'orderNo',
width: 200,
align: 'center'
},
{
title: '用户信息',
dataIndex: 'userId',
key: 'userInfo'
},
{
title: '商品信息',
dataIndex: 'orderGoods',
key: 'orderGoods'
},
{
title: '实付金额',
dataIndex: 'payPrice',
key: 'payPrice',
align: 'center',
width: 120,
customRender: ({ text }) => '¥' + text
},
// {
// title: '支付方式',
// dataIndex: 'payType',
// key: 'payType',
// align: 'center',
// width: 120,
// },
{
title: '状态',
dataIndex: 'orderStatus',
key: 'statusInfo',
align: 'center'
},
// {
// title: '备注',
// dataIndex: 'comments',
// key: 'comments',
// align: 'center',
// },
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 180,
align: 'center',
sorter: true,
customRender: ({ text }) => toDateString(text)
},
{
title: '操作',
key: 'action',
width: 240,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
/* 搜索 */
const reload = (where?: ShopOrderParam) => {
selection.value = [];
tableRef?.value?.reload({ where: where });
};
const onTabs = () => {
const filterParams: Record<string, any> = {};
const sf = getStatusFilterByTab(activeKey.value);
if (sf !== undefined) {
filterParams.statusFilter = sf;
}
reload(filterParams);
};
const onSearch = (item: ShopOrder) => {
reload({ userId: item.userId });
};
/* 打开编辑弹窗 */
const openEdit = (row?: ShopOrder) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/**
* 修复订单支付状态
*/
const updatePayStatus = (record: ShopOrder) => {
// 修复订单数据
repairOrder({
...record
}).then(() => {
if (!record.realName) {
// 更新用户真实姓名
updateUser({
userId: record.userId,
realName: record.realName
}).then(() => {});
}
reload();
});
};
/* 查询 */
const query = () => {
loading.value = true;
};
/* 辅助判断函数 */
// 判断是否为关闭状态
const isCancelledStatus = (orderStatus?: number) => {
return [2, 3].includes(orderStatus || 0);
};
// 判断是否为退款相关状态
const isRefundStatus = (orderStatus?: number) => {
return [4, 5, 6, 7].includes(orderStatus || 0);
};
// 判断是否可以删除订单
const canDeleteOrder = (order: ShopOrder) => {
// 已完成、已关闭、退款成功的订单可以删除 (原来是[1, 2, 6],后面改成只有关闭的订单能删除)
return [2].includes(order.orderStatus || 0);
};
/* 订单操作方法 */
// 修改订单
const handleEditOrder = (record: ShopOrder) => {
message.info('订单修改功能开发中...');
// TODO: 实现订单修改功能
};
// 关闭订单
const handleCancelOrder = (record: ShopOrder) => {
Modal.confirm({
title: '确认关闭订单',
content: '确定要关闭此订单吗?关闭后无法恢复。',
onOk: async () => {
try {
await updateShopOrder({
...record,
orderStatus: 2 // 已关闭
});
message.success('订单已关闭');
reload();
} catch (error: any) {
message.error(error.message || '关闭订单失败');
}
}
});
};
// 确认线下付款收款
const handleConfirmOfflinePayment = (record: ShopOrder) => {
current.value = record;
showOfflinePayment.value = true;
};
// 发货处理
const handleDelivery = (record: ShopOrder) => {
current.value = record;
showDelivery.value = true;
};
// 点击"未发货"标签快速发货检查关联GltTicketOrder的riderId是否>0若是则直接标记为已发货
const handleQuickDelivery = async (record: ShopOrder) => {
if (!record.orderNo) {
message.warning('订单编号不存在,无法查询配送信息');
return;
}
try {
// riderId > 0自动标记为已发货
const now = new Date();
const deliveryTime = toDateString(now, 'yyyy-MM-dd HH:mm:ss');
await updateShopOrder({
...record,
deliveryStatus: 20,
deliveryTime: deliveryTime
});
reload();
} catch (e: any) {
message.error(e.message || '操作失败');
}
};
// 确认收货
const handleConfirmReceive = (record: ShopOrder) => {
Modal.confirm({
title: '确认收货',
content: '确定要将此订单标记为已收货并完成吗?',
onOk: async () => {
try {
await updateShopOrder({
...record,
deliveryStatus: 20, // 已收货
orderStatus: 1 // 已完成
});
message.success('确认收货成功');
reload();
} catch (error: any) {
message.error(error.message || '确认收货失败');
}
}
});
};
// 同意退款
const handleApproveRefund = (record: ShopOrder) => {
Modal.confirm({
title: '同意退款',
content: '确定要同意此订单的退款申请吗?',
onOk: async () => {
try {
const now = new Date();
const refundTime = toDateString(now, 'yyyy-MM-dd HH:mm:ss');
await refundShopOrder({
...record,
orderStatus: 6, // 退款成功
refundTime: refundTime
});
message.success('退款处理成功');
reload();
} catch (error: any) {
message.error(error.message || '退款处理失败');
}
}
});
};
// 拒绝退款
const handleRejectRefund = (record: ShopOrder) => {
Modal.confirm({
title: '拒绝退款',
content: '确定要拒绝此订单的退款申请吗?',
onOk: async () => {
try {
await refundShopOrder({
...record,
orderStatus: 5 // 退款被拒绝
});
message.success('已拒绝退款申请');
reload();
} catch (error: any) {
message.error(error.message || '操作失败');
}
}
});
};
// 重新处理退款
const handleRetryRefund = (record: ShopOrder) => {
Modal.confirm({
title: '重新处理退款',
content: '确定要重新处理此订单的退款吗?',
onOk: async () => {
try {
await refundShopOrder({
...record,
orderStatus: 4 // 退款申请中
});
message.success('已重新提交退款申请');
reload();
} catch (error: any) {
message.error(error.message || '操作失败');
}
}
});
};
// 申请退款
const handleApplyRefund = (record: ShopOrder) => {
Modal.confirm({
title: '申请退款',
content: '确定要为此订单申请退款吗?',
onOk: async () => {
try {
const now = new Date();
const refundApplyTime = toDateString(now, 'yyyy-MM-dd HH:mm:ss');
await refundShopOrder({
...record,
orderStatus: 4, // 退款申请中
refundApplyTime: refundApplyTime
});
message.success('退款申请已提交');
reload();
} catch (error: any) {
message.error(error.message || '申请退款失败');
}
}
});
};
/* 删除单个订单 */
const remove = (row: ShopOrder) => {
removeShopOrder(row.orderId)
.then(() => {
message.success('删除成功');
reload();
})
.catch((e) => {
message.error(e.message);
});
};
/* 批量删除订单 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const ids = selection.value.map((d) => d.orderId);
removeBatchShopOrder(ids)
.then(() => {
message.success('删除成功');
reload();
})
.catch((e) => {
message.error(e.message);
});
}
});
};
/* 自定义行属性 */
const customRow = (record: ShopOrder) => {
return {
// 行点击事件
onClick: () => {
// openEdit(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
import * as MenuIcons from '@/layout/menu-icons';
export default {
name: 'BszxOrder',
components: MenuIcons
};
</script>
<style lang="less" scoped>
.order-notify-bar {
display: flex;
align-items: center;
margin-bottom: 12px;
padding: 8px 12px;
background: var(--ant-color-fill-alter, #fafafa);
border-radius: 6px;
}
.order-notify-label {
font-size: 14px;
font-weight: 500;
}
.order-notify-tip {
color: var(--ant-color-text-secondary, #999);
cursor: help;
}
</style>