fix(订单中心): 用户已删除优先展示,用户搜索与订单号分离,支付方式动态加载

普通订单批量发货弹窗与导出字段对齐列表口径。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
吴昊天 2026-10-26 15:17:04 +08:00
parent 02c11e3e19
commit ccdaecf982
8 changed files with 355 additions and 93 deletions

View File

@ -500,6 +500,17 @@ class OrderCenterServices extends BaseService implements OrderCenterSyncInterfac
// 确保 buildGroupedData 和前端引擎都能拿到正确的状态文案
$detail['status_text'] = OrderCenter::$statusMap[$detail['status_code'] ?? ''] ?? ($detail['status_code'] ?? '');
// 用户已删除:详情状态统一展示「已删除」(is_del 为软删标记,不改写 status_code)
if ((int)($detail['is_del'] ?? 0) === 1) {
$detail['status_text'] = '已删除';
$detail['business_status'] = [
'code' => 'deleted',
'label' => '已删除',
'text' => '已删除',
'type' => 'danger',
];
}
// 构建操作按钮(委托给产品类型 Handler)
$detail['order_buttons'] = $this->getOrderButtonsFromHandler($detail);
// 构建订单生命周期进度条(不同订单类型节点不同,委托给产品类型 Handler)
@ -2622,7 +2633,8 @@ class OrderCenterServices extends BaseService implements OrderCenterSyncInterfac
/**
* 构建关键词搜索闭包(后台订单列表/统计共用)
* 按 search_field 限定搜索字段:order_no=订单号前缀、contact_name=收货人、
* contact_phone=联系电话、uid=用户ID精确匹配,all/其它=订单号+联系人+电话
* contact_phone=联系电话、uid=用户ID精确匹配;
* all/其它=用户相关(收货人+电话,纯数字兼查 UID),订单号走独立 order_no 条件
* 收货人/电话存于 extra_info JSON,走 JSON 路径匹配
* @param string $keyword 关键词(调用方需先 trim)
* @param string $searchField 搜索字段标识
@ -2651,10 +2663,12 @@ class OrderCenterServices extends BaseService implements OrderCenterSyncInterfac
$q->where('uid', '=', (int) $keyword);
break;
default:
// 全部字段:订单号前缀 + 收货人 + 电话
$q->whereOr('order_no', 'LIKE', "{$escaped}%")
->whereOrRaw("extra_info->>'$.contact.name' LIKE ?", ["%{$escaped}%"])
// 用户搜索「全部」:收货人 + 电话;纯数字时兼查 UID(订单号由独立筛选项承担)
$q->whereRaw("extra_info->>'$.contact.name' LIKE ?", ["%{$escaped}%"])
->whereOrRaw("extra_info->>'$.contact.phone' LIKE ?", ["%{$escaped}%"]);
if (ctype_digit((string) $keyword)) {
$q->whereOr('uid', '=', (int) $keyword);
}
}
});
};
@ -2764,20 +2778,25 @@ class OrderCenterServices extends BaseService implements OrderCenterSyncInterfac
}
}
// 扩展表子状态(如待发货/待收货)经 _order_nos 两步筛选;不可因无 extra_conditions 而丢弃
$orderNos = $where['_order_nos'] ?? [];
if (empty($extraConditions)) {
// 无扩展条件:直接走 DAO 方法
// 无闭包扩展条件:直接走 DAO 方法
$finalWhere = $searchWhere;
if (!empty($orderNos)) {
$finalWhere[] = ['order_no', 'IN', $orderNos];
}
if (!empty($afterSaleOrderNos)) {
$finalWhere[] = ['order_no', 'IN', $afterSaleOrderNos];
}
$list = $this->dao->selectList($finalWhere, $fields, $page, $limit, 'id DESC');
$total = $this->dao->count($finalWhere);
} else {
// 有扩展条件:用 DAO 提供的查询构建器,避免直接 Db::table
// 有闭包扩展条件:用 DAO 提供的查询构建器,避免直接 Db::table
$query = $this->dao->getDb()->where($searchWhere);
// 扩展表条件用两步查询(先查 order_no,再 IN),不 JOIN
$orderNos = $where['_order_nos'] ?? [];
if (!empty($orderNos)) {
$query->whereIn('order_no', $orderNos);
}

View File

@ -1243,6 +1243,23 @@ class OrderController extends AuthController
]);
$where['order_category'] = 'goods';
$where['product_type'] = 'normal';
$where['is_deleted'] = !empty($where['is_deleted']);
// 订单类型映射:与 list 接口一致
$type = $where['type'] ?? '';
if (!empty($type)) {
$activityType = $this->mapOrderType($type);
if ($activityType !== '') {
$where['activity_type'] = $activityType;
}
}
unset($where['type']);
// 业务状态(待发货/待收货等)与列表同口径翻译
$businessStatus = (string)$this->request->get('business_status', '');
if ($businessStatus !== '') {
$this->translateBusinessStatus($businessStatus, $where);
}
try {
$result = $centerServices->getAdminList($where, (int)$where['page'], (int)$where['limit']);
@ -1259,12 +1276,18 @@ class OrderController extends AuthController
$exportData = [];
foreach ($list as $item) {
$items = $item['items'] ?? [];
$storeName = '';
if (!empty($items)) {
$first = $items[0];
$storeName = $first['store_name'] ?? $first['title'] ?? '';
// 列表接口无顶层 items,商品名在中台 title 或 extra_info.items[].store_name
$goodsItems = $item['extra_info']['items'] ?? ($item['items'] ?? []);
$goodsNames = [];
foreach ((array)$goodsItems as $goods) {
$name = trim((string)($goods['store_name'] ?? $goods['title'] ?? $goods['name'] ?? ''));
if ($name !== '') {
$goodsNames[] = $name;
}
}
$storeName = $goodsNames
? implode(' / ', $goodsNames)
: (string)($item['title'] ?? $item['subtitle'] ?? '');
$exportData[] = [
'order_no' => $item['order_no'] ?? '',

View File

@ -14,10 +14,11 @@
<li class="item">
<div class="title">订单状态</div>
<div>
<el-tag :type="getStatusClass(detailData.status_code)">{{
getStatusLabel(detailData.status_code)
}}</el-tag>
<span v-if="detailData.refund_status == 1" style="color: #ed4014; margin-left: 4px">
<el-tag :type="getStatusClass(detailData)">{{ getStatusLabel(detailData) }}</el-tag>
<span
v-if="Number(detailData.is_del) !== 1 && detailData.refund_status == 1"
style="color: #ed4014; margin-left: 4px"
>
退款中
</span>
</div>
@ -114,7 +115,11 @@ function getLogisticsData() {
});
}
function getStatusLabel(code) {
/** 详情状态:用户已删除优先展示「已删除」,否则按 status_code / status_text */
function getStatusLabel(detail) {
if (detail && Number(detail.is_del) === 1) return '已删除';
if (detail?.status_text) return detail.status_text;
const code = typeof detail === 'string' ? detail : detail?.status_code;
const map = {
pending_payment: '待支付',
processing: '待处理',
@ -124,12 +129,15 @@ function getStatusLabel(code) {
after_sale: '售后中',
refunding: '退款中',
refunded: '已退款',
deleted: '已删除',
};
return map[code] || code || '--';
}
/* 与列表 getStatusClass 同口径 */
function getStatusClass(code) {
function getStatusClass(detail) {
if (detail && Number(detail.is_del) === 1) return 'danger';
const code = typeof detail === 'string' ? detail : detail?.status_code;
const map = {
pending_payment: 'warning',
processing: 'primary',
@ -139,6 +147,7 @@ function getStatusClass(code) {
after_sale: 'warning',
refunding: 'warning',
refunded: 'info',
deleted: 'danger',
};
return map[code] || 'info';
}

View File

@ -204,8 +204,8 @@
</el-table-column>
<el-table-column label="订单状态" min-width="120">
<template #default="scope">
<el-tag :type="getStatusClass(scope.row.status_code)">{{ getStatusLabel(scope.row.status_code) }}</el-tag>
<div v-if="scope.row.business_status">
<el-tag :type="getStatusClass(scope.row)">{{ getStatusLabel(scope.row) }}</el-tag>
<div v-if="Number(scope.row.is_del) !== 1 && scope.row.business_status">
<span class="business-status">{{ scope.row.business_status.label }}</span>
</div>
</template>
@ -469,12 +469,18 @@ function getProductTypeLabel(type) {
return item ? item.label : type || '--';
}
function getStatusLabel(code) {
function getStatusLabel(rowOrCode) {
const row = typeof rowOrCode === 'object' && rowOrCode ? rowOrCode : null;
if (row && Number(row.is_del) === 1) return '已删除';
const code = row ? row.status_code : rowOrCode;
const item = statusCodeOptions.find((opt) => opt.key === code);
return item ? item.label : code || '--';
}
function getStatusClass(code) {
function getStatusClass(rowOrCode) {
const row = typeof rowOrCode === 'object' && rowOrCode ? rowOrCode : null;
if (row && Number(row.is_del) === 1) return 'danger';
const code = row ? row.status_code : rowOrCode;
const map = {
pending_payment: 'warning',
processing: 'primary',
@ -484,6 +490,7 @@ function getStatusClass(code) {
after_sale: 'warning',
refunding: 'warning',
refunded: 'info',
deleted: 'danger',
};
return map[code] || 'info';
}

View File

@ -189,7 +189,8 @@ export function getOrderExpress(orderNo) {
* 供详情弹窗等场景复用(列表/详情状态展示保持一致)
*/
export function getOrderBusinessStatus(row = {}) {
if (row.is_del === 1) {
// is_del 可能为数字 1 或字符串 "1",与列表口径一致按真值判断
if (Number(row.is_del) === 1) {
return { code: 'deleted', text: '已删除', type: 'danger' };
}
if (row.status_code === 'pending_payment') {

View File

@ -122,46 +122,73 @@
</el-dialog>
<!-- 批量发货对话框 -->
<el-dialog v-model="batchShipmentModal" title="批量发货" width="560px">
<div>
<p class="mb10">请上传 Excel 文件(<b>xls/xlsx</b>),格式如下:</p>
<el-table :data="templateData" border stripe class="mb10">
<el-table-column label="订单号" width="200">
<template>CP1234567890</template>
</el-table-column>
<el-table-column label="快递公司" width="150">
<template>顺丰速运</template>
</el-table-column>
<el-table-column label="快递单号" width="150">
<template>SF1234567890</template>
</el-table-column>
</el-table>
<p class="mb10" style="color: var(--prev-color-text-secondary); font-size: 12px">
第一行为表头,从第二行开始为数据
</p>
<el-dialog
v-model="batchShipmentModal"
title="批量发货"
width="560px"
append-to-body
:close-on-click-modal="false"
class="normal-batch-shipment-dialog"
@closed="onBatchDialogClosed"
>
<div class="batch-shipment">
<div class="batch-shipment__tip">
请按下方格式编辑 Excel(<b>xls / xlsx</b>)后上传,系统将按行完成发货。
</div>
<div class="batch-shipment__format">
<div class="batch-shipment__format-title">Excel 格式示例</div>
<el-table :data="templateData" border size="small" class="batch-shipment__table">
<el-table-column label="订单号" min-width="160" show-overflow-tooltip>
<template #default="{ row }">{{ row.order_no }}</template>
</el-table-column>
<el-table-column label="快递公司" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.delivery_name }}</template>
</el-table-column>
<el-table-column label="快递单号" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.delivery_id }}</template>
</el-table-column>
</el-table>
<div class="batch-shipment__format-note">第一行为表头,从第二行开始填写数据</div>
</div>
<el-upload
ref="upload"
:action="adminApi + 'normal/order/batch_shipment'"
:headers="headers"
:on-success="onBatchUploadSuccess"
ref="batchUploadRef"
class="batch-shipment__upload"
drag
:action="batchUploadUrl"
:headers="batchUploadHeaders"
:disabled="batchUploading"
:on-success="onBatchFileUploadSuccess"
:on-error="onBatchUploadError"
:before-upload="onBeforeUpload"
accept=".xls,.xlsx"
:show-file-list="false"
>
<template #trigger><el-button type="primary">选取文件上传</el-button></template>
<div class="batch-shipment__upload-inner">
<i class="el-icon-upload batch-shipment__upload-icon"></i>
<div class="batch-shipment__upload-text">
<template v-if="batchUploading">正在上传并处理,请稍候…</template>
<template v-else>将文件拖到此处,或<em>点击上传</em></template>
</div>
<div class="batch-shipment__upload-hint">仅支持 .xls、.xlsx</div>
</div>
</el-upload>
</div>
<div v-if="batchResult" class="batch-result mt10">
<p>{{ batchResult.msg }}</p>
<p v-if="batchResult.data && batchResult.data.errors && batchResult.data.errors.length">
<a v-db-click @click="showBatchErrors = !showBatchErrors">
{{ showBatchErrors ? '收起' : '查看' }}失败详情({{ batchResult.data.errors.length }}条)
</a>
</p>
<ul v-if="showBatchErrors" style="color: #ed4014; font-size: 12px">
<li v-for="(err, idx) in batchResult.data.errors" :key="idx">{{ err }}</li>
</ul>
<div v-if="batchResult" class="batch-shipment__result">
<div class="batch-shipment__result-msg">{{ batchResult.msg }}</div>
<div
v-if="batchResult.data && batchResult.data.errors && batchResult.data.errors.length"
class="batch-shipment__result-toggle"
>
<a v-db-click href="javascript:;" @click="showBatchErrors = !showBatchErrors">
{{ showBatchErrors ? '收起' : '查看' }}失败详情({{ batchResult.data.errors.length }}条)
</a>
</div>
<ul v-if="showBatchErrors" class="batch-shipment__result-errors">
<li v-for="(err, idx) in batchResult.data.errors" :key="idx">{{ err }}</li>
</ul>
</div>
</div>
</el-dialog>
</div>
@ -197,7 +224,9 @@ import {
getPrintPickList,
getOrderExpress,
exportOrderList as exportNormalOrder,
batchShipment,
} from '../normalOrder';
import { getCookies } from '@/libs/util';
import OrderStatistics from './components/OrderStatistics';
import OrderSearch from './components/OrderSearch';
@ -276,17 +305,24 @@ const editOrderData = ref(null);
const batchShipmentModal = ref(false);
const batchResult = ref(null);
const showBatchErrors = ref(false);
const templateData = [{}];
const batchUploading = ref(false);
const batchUploadRef = ref(null);
const templateData = [
{
order_no: 'CP1234567890',
delivery_name: '顺丰速运',
delivery_id: 'SF1234567890',
},
];
// 批量删除
const selectedIds = ref([]);
const adminApi = computed(() => {
return import.meta.env.VITE_APP_API_URL + '/adminapi/';
});
const headers = computed(() => {
return { 'Authori-zation': 'Bearer ' + localStorage.getItem('token') };
});
// 先上传到文件服务拿到路径,再调批量发货接口(与商品导入同口径)
const batchUploadUrl = computed(() => setting.apiBaseURL + '/file/upload/1');
const batchUploadHeaders = computed(() => ({
'Authori-zation': 'Bearer ' + (getCookies('token') || ''),
}));
/* ===== 数据加载 ===== */
@ -616,29 +652,61 @@ function openRecord(row) {
function openBatchShipment() {
batchResult.value = null;
showBatchErrors.value = false;
batchUploading.value = false;
batchShipmentModal.value = true;
}
function onBatchUploadSuccess(res) {
batchResult.value = res;
if (res.code === 200) {
ElMessage.success(res.msg || '批量发货完成');
getList();
} else {
ElMessage.error(res.msg || '批量发货失败');
function onBatchDialogClosed() {
batchResult.value = null;
showBatchErrors.value = false;
batchUploading.value = false;
}
/**
* 文件上传成功后,用返回路径调用批量发货接口
*/
function onBatchFileUploadSuccess(res) {
if (res.status !== 200 || !res.data || !res.data.src) {
batchUploading.value = false;
ElMessage.error(res.msg || '文件上传失败');
return;
}
batchShipment({ file: res.data.src })
.then((result) => {
batchResult.value = result;
ElMessage.success(result.msg || '批量发货完成');
getList();
getStatistics();
})
.catch((err) => {
ElMessage.error((err && err.msg) || '批量发货失败');
})
.finally(() => {
batchUploading.value = false;
});
}
function onBatchUploadError(err) {
ElMessage.error('批量发货失败:' + (err.msg || '文件上传异常'));
batchUploading.value = false;
let msg = '文件上传失败';
try {
const res = JSON.parse(err?.message || '');
if (res && res.msg) msg = res.msg;
} catch (e) {
// 非 JSON 响应,保留默认提示
}
ElMessage.error(msg);
}
function onBeforeUpload(file) {
var suffix = file.name.split('.').pop().toLowerCase();
const suffix = (file.name.split('.').pop() || '').toLowerCase();
if (!['xls', 'xlsx'].includes(suffix)) {
ElMessage.error('文件格式不正确,请上传xls或xlsx格式');
ElMessage.error('文件格式不正确,请上传 xls 或 xlsx 格式');
return false;
}
batchResult.value = null;
showBatchErrors.value = false;
batchUploading.value = true;
return true;
}
@ -750,9 +818,12 @@ function handleExport() {
page: 1,
limit: 200,
...searchParams.value,
status_code: statusTab.value === 'deleted' ? '' : statusTab.value,
is_deleted: statusTab.value === 'deleted' ? 1 : '',
};
// 与列表同口径:业务子状态走 business_status,勿把 pending_shipped 等塞进 status_code
if (statusTab.value && statusTab.value !== 'deleted') {
params.business_status = statusTab.value;
}
doExport(params);
})
.catch(() => {});
@ -805,13 +876,6 @@ onMounted(() => {
margin-top: 10px;
}
.batch-result {
padding: 10px;
background: #f5f7fa;
border-radius: 4px;
font-size: 14px;
}
.logistics-box {
.logistics-header {
display: flex;
@ -843,3 +907,136 @@ onMounted(() => {
}
}
</style>
<!-- append-to-body 弹窗挂到 body,需非 scoped 样式才能命中 -->
<style lang="scss">
.normal-batch-shipment-dialog {
.batch-shipment {
display: flex;
flex-direction: column;
gap: 16px;
}
.batch-shipment__tip {
margin: 0;
font-size: 13px;
line-height: 1.6;
color: var(--prev-color-text-regular);
b {
color: var(--prev-color-primary);
font-weight: 500;
}
}
.batch-shipment__format {
padding: 12px;
background: var(--prev-bg-color);
border: 1px solid var(--prev-border-color-lighter);
border-radius: 4px;
}
.batch-shipment__format-title {
margin-bottom: 10px;
font-size: 13px;
font-weight: 500;
color: var(--prev-color-text-regular);
}
.batch-shipment__table {
width: 100%;
.el-table__empty-block {
min-height: 48px;
}
}
.batch-shipment__format-note {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--prev-color-text-secondary);
}
.batch-shipment__upload {
width: 100%;
.el-upload {
width: 100%;
}
.el-upload-dragger {
width: 100%;
height: auto;
padding: 28px 16px;
border-radius: 4px;
border-color: var(--prev-border-color-light);
background: var(--prev-bg-white);
&:hover {
border-color: var(--prev-color-primary);
}
}
}
.batch-shipment__upload-inner {
display: flex;
flex-direction: column;
align-items: center;
}
.batch-shipment__upload-icon {
font-size: 40px;
color: var(--prev-color-primary);
margin-bottom: 8px;
}
.batch-shipment__upload-text {
font-size: 14px;
color: var(--prev-color-text-regular);
line-height: 1.5;
em {
font-style: normal;
color: var(--prev-color-primary);
}
}
.batch-shipment__upload-hint {
margin-top: 6px;
font-size: 12px;
color: var(--prev-color-text-secondary);
}
.batch-shipment__result {
padding: 12px;
background: var(--prev-bg-color);
border-radius: 4px;
border: 1px solid var(--prev-border-color-lighter);
}
.batch-shipment__result-msg {
font-size: 13px;
color: var(--prev-color-text-regular);
line-height: 1.5;
}
.batch-shipment__result-toggle {
margin-top: 8px;
a {
font-size: 12px;
color: var(--prev-color-primary);
text-decoration: none;
}
}
.batch-shipment__result-errors {
margin: 8px 0 0;
padding-left: 18px;
font-size: 12px;
line-height: 1.7;
color: var(--prev-color-danger, #f56c6c);
}
}
</style>

View File

@ -33,18 +33,17 @@
value-format="YYYY-MM-DD"
></date-range-picker>
</el-form-item>
<el-form-item label="订单搜索:">
<el-form-item label="用户搜索:">
<el-input
clearable
v-model="formData.keyword"
placeholder="订单号/联系人/UID"
placeholder="联系人/联系电话/UID"
class="form_content_width"
@keyup.enter="handleSearch"
>
<template #prepend
><el-select v-model="formData.search_field" style="width: 100px">
<el-option value="all" label="全部"></el-option>
<el-option value="order_no" label="订单号"></el-option>
<el-option value="contact_name" label="联系人"></el-option>
<el-option value="contact_phone" label="联系电话"></el-option>
<el-option value="uid" label="UID"></el-option> </el-select
@ -61,11 +60,12 @@
</template>
<script setup>
import { ref, reactive, watch } from 'vue';
import { ref, reactive, watch, onMounted } from 'vue';
import { orderCenterFilterOptions } from '@/api/orderCenter';
/**
* 实物商品订单 - 搜索栏组件
* 提供订单号/支付方式/创建时间/关键词等多维度搜索
* 提供订单号/支付方式/创建时间/用户关键词等多维度搜索
*/
defineOptions({ name: 'OrderSearch' });
@ -93,12 +93,8 @@ const defaultSearch = () => ({
const formData = reactive(defaultSearch());
const timeVal = ref([]);
const payTypeOptions = [
{ key: 'weixin', label: '微信支付' },
{ key: 'yue', label: '余额支付' },
{ key: 'alipay', label: '支付宝' },
{ key: 'offline', label: '线下支付' },
];
/** 支付方式下拉:按已安装且启用的支付渠道动态加载,不硬编码 */
const payTypeOptions = ref([]);
watch(
() => props.value,
@ -110,6 +106,16 @@ watch(
{ immediate: true },
);
onMounted(() => {
orderCenterFilterOptions()
.then((res) => {
payTypeOptions.value = res.data?.pay_types || [];
})
.catch(() => {
payTypeOptions.value = [];
});
});
/**
* 触发搜索,重置到第一页
*/

View File

@ -508,7 +508,7 @@ function onPageChange() {
/* ===== 工具方法 ===== */
function getBusinessStatus(row) {
if (row.is_del === 1) {
if (Number(row.is_del) === 1) {
return { code: 'deleted', text: '已删除', type: 'danger' };
}