refactor: 订单中心+详情+物流轨迹全面优化
- 订单中心:移除管理员录入表单,全宽列表,增加来源/交付方式列,修正详情链接 - 订单详情:增加产品明细表、审批记录、物流信息;按钮绑定真实后端接口 - 物流轨迹:支持订单号搜索,显示关联订单信息,支持URL参数自动查询 - mockApi:新增 fetchOrderDetailAdmin、changeOrderStatus、fetchOrderList stats Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a5820c596f
commit
cdaff4999d
@ -882,24 +882,28 @@ export async function updateSupplier(supplierId, payload) {
|
||||
export async function fetchOrderList(filters = {}) {
|
||||
const query = buildQuery(filters);
|
||||
const data = await request(`/api/orders${query ? `?${query}` : ""}`);
|
||||
return {
|
||||
total: data.total,
|
||||
pageNo: data.page_no,
|
||||
pageSize: data.page_size,
|
||||
rows: (data.list || []).map((item) => ({
|
||||
orderId: item.order_id,
|
||||
orderNo: item.order_no,
|
||||
customerName: item.customer_name,
|
||||
customerMobile: item.customer_mobile,
|
||||
orderStatus: item.order_status,
|
||||
statusText: mapOrderStatus(item.order_status),
|
||||
orderSource: item.order_source || "-",
|
||||
salePriceTotal: Number(item.sale_price_total || 0).toFixed(2),
|
||||
profitTotal: item.profit_total != null ? Number(item.profit_total).toFixed(2) : null,
|
||||
commissionAmount: item.commission_amount != null ? Number(item.commission_amount).toFixed(2) : null,
|
||||
createdAt: item.created_at || "-",
|
||||
})),
|
||||
};
|
||||
const rows = (data.list || []).map((item) => ({
|
||||
orderId: item.order_id,
|
||||
orderNo: item.order_no,
|
||||
customerName: item.customer_name,
|
||||
customerMobile: item.customer_mobile,
|
||||
orderStatus: item.order_status,
|
||||
statusText: mapOrderStatus(item.order_status),
|
||||
orderSource: item.order_source || "-",
|
||||
deliveryType: item.delivery_type || "-",
|
||||
salePriceTotal: Number(item.sale_price_total || 0).toFixed(2),
|
||||
profitTotal: item.profit_total != null ? Number(item.profit_total).toFixed(2) : null,
|
||||
commissionAmount: item.commission_amount != null ? Number(item.commission_amount).toFixed(2) : null,
|
||||
createdAt: item.created_at || "-",
|
||||
}));
|
||||
const stats = { draft: 0, pending: 0, cancelPending: 0, factory: 0 };
|
||||
rows.forEach((r) => {
|
||||
if (r.orderStatus === "draft") stats.draft++;
|
||||
else if (r.orderStatus === "pending_approve") stats.pending++;
|
||||
else if (r.orderStatus === "cancel_pending" || r.orderStatus === "cancel_fulfillment_pending") stats.cancelPending++;
|
||||
else if (r.orderStatus === "pending_factory" || r.orderStatus === "pending_driver") stats.factory++;
|
||||
});
|
||||
return { total: data.total, pageNo: data.page_no, pageSize: data.page_size, rows, stats };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -939,6 +943,28 @@ export async function cancelOrderById(orderId, payload) {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取订单详情(管理员版,含产品明细、审批记录、物流信息)
|
||||
* @param {number} orderId - 订单 ID
|
||||
* @returns {Promise<Object>} 完整订单数据
|
||||
*/
|
||||
export async function fetchOrderDetailAdmin(orderId) {
|
||||
return request(`/api/orders/${orderId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 变更订单状态
|
||||
* @param {number} orderId - 订单 ID
|
||||
* @param {Object} payload - { status: string, remark?: string }
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
export async function changeOrderStatus(orderId, payload) {
|
||||
return request(`/api/orders/${orderId}/status`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工厂待处理订单(已审批 + 待工厂处理状态的订单)
|
||||
* @returns {Promise<{rows: Array}>} 工厂待处理订单列表
|
||||
|
||||
@ -1,38 +1,37 @@
|
||||
<!--
|
||||
OrderDetailPage.vue - 订单详情页面
|
||||
职责:展示单个订单的完整信息,包括基础信息卡片、状态流转步骤、费用构成和可执行动作按钮。
|
||||
通过路由参数 id 获取订单详情数据,支持手动推进状态(标记生产中/已发货/已完成/已结算)。
|
||||
职责:展示单个订单的完整信息,包括基础信息、产品明细、费用构成、
|
||||
审批记录、物流信息和状态流转操作。按钮绑定真实后端接口。
|
||||
-->
|
||||
<template>
|
||||
<section class="page detail-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>订单详情</h2>
|
||||
<p class="subline">展示订单基础信息、状态流转、费用构成与后续动作入口。</p>
|
||||
<p class="subline">订单编号:{{ order.orderNo }}</p>
|
||||
</div>
|
||||
<RouterLink class="back-link" to="/orders">返回订单中心</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 顶部摘要卡片:当前状态/订单编号/销售金额/利润率 -->
|
||||
<!-- 顶部摘要卡片 -->
|
||||
<div class="summary-grid">
|
||||
<article class="summary-card"><span>当前状态</span><strong>{{ order.statusText }}</strong></article>
|
||||
<article class="summary-card"><span>订单编号</span><strong>{{ order.orderNo }}</strong></article>
|
||||
<article class="summary-card"><span>销售金额</span><strong>{{ order.salePriceTotal }}</strong></article>
|
||||
<article class="summary-card"><span>销售金额</span><strong>¥{{ order.salePriceTotal }}</strong></article>
|
||||
<article class="summary-card"><span>利润率</span><strong>{{ order.profitRate }}</strong></article>
|
||||
</div>
|
||||
|
||||
<!-- 状态流转步骤展示:草稿 -> 待审批 -> ... -> 已完成 -->
|
||||
<!-- 状态流转步骤 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>状态流转</h3><span>可用于追踪从草稿到履约完成的过程</span></div>
|
||||
<div class="panel-header"><h3>状态流转</h3></div>
|
||||
<div class="flow-list">
|
||||
<!-- 当前状态对应的步骤高亮显示 -->
|
||||
<span v-for="step in steps" :key="step" :class="['flow-step', { active: step === order.statusText }]">{{ step }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 基础信息卡片 -->
|
||||
<!-- 基础信息 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>基础信息</h3><span>订单详情卡片</span></div>
|
||||
<div class="panel-header"><h3>基础信息</h3></div>
|
||||
<div class="info-grid">
|
||||
<article><p>客户</p><strong>{{ order.customerName }}</strong></article>
|
||||
<article><p>手机号</p><strong>{{ order.customerMobile }}</strong></article>
|
||||
@ -43,63 +42,131 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 费用构成:销售金额/成本金额/利润金额/回扣金额 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>费用构成</h3><span>利润、成本与回扣明细</span></div>
|
||||
<div class="cost-grid">
|
||||
<article v-for="item in costs" :key="item.label">
|
||||
<p>{{ item.label }}</p>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</article>
|
||||
<!-- 订单明细(产品列表) -->
|
||||
<section v-if="orderItems.length" class="panel">
|
||||
<div class="panel-header"><h3>订单明细</h3><span>共 {{ orderItems.length }} 个产品</span></div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>产品名称</th><th>规格</th><th>单位</th><th>数量</th><th>销售单价</th><th>成本单价</th><th>小计</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in orderItems" :key="item.item_id">
|
||||
<td>{{ item.product_name }}</td>
|
||||
<td>{{ item.specification || '-' }}</td>
|
||||
<td>{{ item.unit || '-' }}</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>¥{{ Number(item.sale_price || 0).toFixed(2) }}</td>
|
||||
<td>¥{{ Number(item.cost_price || 0).toFixed(2) }}</td>
|
||||
<td class="money">¥{{ (Number(item.quantity || 0) * Number(item.sale_price || 0)).toFixed(2) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 建议动作按钮区域:根据当前状态显示可执行的操作 -->
|
||||
<!-- 费用构成 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>建议动作</h3><span>下一步可执行流程</span></div>
|
||||
<div class="action-row">
|
||||
<!-- 草稿/已驳回 -> 可提交审核 -->
|
||||
<button v-if="order.statusText === '草稿' || order.statusText === '已驳回'" class="ghost-btn" type="button">提交审核</button>
|
||||
<!-- 已审批 -> 可生成发厂文案 -->
|
||||
<button v-if="order.statusText === '已审批'" class="ghost-btn" type="button">生成发厂文案</button>
|
||||
<!-- 待工厂处理/待司机接单 -> 可创建运输任务 -->
|
||||
<button v-if="order.statusText === '待工厂处理' || order.statusText === '待司机接单'" class="ghost-btn" type="button">创建运输任务</button>
|
||||
<button v-if="order.statusText === '已接单'" class="ghost-btn" type="button">确认揽货</button>
|
||||
<button v-if="order.statusText === '已揽货'" class="ghost-btn" type="button">确认送达</button>
|
||||
<button v-if="order.statusText === '已送达'" class="ghost-btn" type="button" @click="handleStatusChange('production')">标记生产中</button>
|
||||
<button v-if="order.statusText === '生产中'" class="ghost-btn" type="button" @click="handleStatusChange('shipped')">标记已发货</button>
|
||||
<button v-if="order.statusText === '已发货'" class="ghost-btn" type="button" @click="handleStatusChange('completed')">标记已完成</button>
|
||||
<button v-if="order.statusText === '已完成'" class="ghost-btn" type="button" @click="handleStatusChange('settled')">标记已结算</button>
|
||||
<button v-if="order.statusText !== '已取消' && order.statusText !== '已结算'" class="ghost-btn" type="button">申请取消</button>
|
||||
<button class="ghost-btn" type="button">查看轨迹</button>
|
||||
<div class="panel-header"><h3>费用构成</h3></div>
|
||||
<div class="cost-grid">
|
||||
<article><p>销售金额</p><strong>¥{{ order.salePriceTotal }}</strong></article>
|
||||
<article><p>成本金额</p><strong>¥{{ Number(order.costPriceTotalNum || 0).toFixed(2) }}</strong></article>
|
||||
<article><p>利润金额</p><strong>¥{{ Number(order.profitTotalNum || 0).toFixed(2) }}</strong></article>
|
||||
<article><p>回扣金额</p><strong>¥{{ Number(order.rebateTotalNum || 0).toFixed(2) }}</strong></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 审批记录 -->
|
||||
<section v-if="approveLogs.length" class="panel">
|
||||
<div class="panel-header"><h3>审批记录</h3><span>共 {{ approveLogs.length }} 条</span></div>
|
||||
<div class="log-list">
|
||||
<div v-for="log in approveLogs" :key="log.log_id" class="log-card">
|
||||
<div class="log-row">
|
||||
<strong>{{ log.approve_time }}</strong>
|
||||
<span class="log-type">{{ log.approve_type }}</span>
|
||||
<span :class="['log-result', log.approve_result === 'approved' ? 'approved' : 'rejected']">{{ log.approve_result === 'approved' ? '通过' : '驳回' }}</span>
|
||||
</div>
|
||||
<p v-if="log.approve_opinion">{{ log.approve_opinion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 物流信息 -->
|
||||
<section v-if="logisticsInfo" class="panel">
|
||||
<div class="panel-header"><h3>物流信息</h3></div>
|
||||
<div class="info-grid">
|
||||
<article><p>快递公司</p><strong>{{ logisticsInfo.express_company || '-' }}</strong></article>
|
||||
<article><p>快递单号</p><strong>{{ logisticsInfo.tracking_number || '-' }}</strong></article>
|
||||
<article><p>任务状态</p><strong>{{ logisticsInfo.task_status || '-' }}</strong></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>操作</h3></div>
|
||||
<div class="action-row">
|
||||
<button v-if="order.rawStatus === 'draft' || order.rawStatus === 'rejected'" class="primary-btn" @click="handleSubmit">提交审核</button>
|
||||
<button v-if="order.rawStatus === 'approved'" class="ghost-btn" @click="handleStatusChange('pending_factory')">推进到待工厂处理</button>
|
||||
<button v-if="order.rawStatus === 'pending_factory'" class="ghost-btn" @click="handleStatusChange('pending_driver')">推进到待司机接单</button>
|
||||
<button v-if="order.rawStatus === 'pending_driver'" class="ghost-btn" @click="handleStatusChange('accepted')">确认接单</button>
|
||||
<button v-if="order.rawStatus === 'accepted'" class="ghost-btn" @click="handleStatusChange('picked_up')">确认揽货</button>
|
||||
<button v-if="order.rawStatus === 'picked_up'" class="ghost-btn" @click="handleStatusChange('delivered')">确认送达</button>
|
||||
<button v-if="order.rawStatus === 'delivered'" class="ghost-btn" @click="handleStatusChange('production')">标记生产中</button>
|
||||
<button v-if="order.rawStatus === 'production'" class="ghost-btn" @click="handleStatusChange('shipped')">标记已发货</button>
|
||||
<button v-if="order.rawStatus === 'shipped'" class="ghost-btn" @click="handleStatusChange('completed')">标记已完成</button>
|
||||
<button v-if="order.rawStatus === 'completed'" class="ghost-btn" @click="handleStatusChange('settled')">标记已结算</button>
|
||||
<button v-if="order.rawStatus !== 'canceled' && order.rawStatus !== 'settled'" class="ghost-btn danger-btn" @click="showCancelModal = true">申请取消</button>
|
||||
<RouterLink v-if="order.rawStatus" class="ghost-btn" :to="`/tracking?order_id=${route.params.id}`">查看物流轨迹</RouterLink>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 取消弹窗 -->
|
||||
<div v-if="showCancelModal" class="modal-mask" @click.self="showCancelModal = false">
|
||||
<section class="modal-panel">
|
||||
<div class="modal-header">
|
||||
<div><h4>申请取消订单</h4><span>订单号:{{ order.orderNo }}</span></div>
|
||||
<button class="modal-close" @click="showCancelModal = false">×</button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label class="full-width">
|
||||
<span>取消原因(必填)</span>
|
||||
<textarea v-model.trim="cancelReason" rows="3" placeholder="请输入取消原因"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div class="action-row split">
|
||||
<button class="ghost-btn" @click="showCancelModal = false">取消</button>
|
||||
<button class="primary-btn" :disabled="actionBusy" @click="handleCancel">{{ actionBusy ? '提交中...' : '确认取消' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* OrderDetailPage.vue - 订单详情页面脚本
|
||||
* 通过路由参数加载订单详情,管理状态流转展示和手动状态推进。
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RouterLink, useRoute, useRouter } from 'vue-router';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { fetchOrderDetailAdmin, submitOrderToReview, cancelOrderById, changeOrderStatus } from '../mockApi';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const loading = ref(true);
|
||||
const actionBusy = ref(false);
|
||||
const showCancelModal = ref(false);
|
||||
const cancelReason = ref('');
|
||||
|
||||
/** 订单详情数据(含费用数值字段用于计算) */
|
||||
const order = ref({
|
||||
orderNo: '-', statusText: '-', salePriceTotal: '0.00', profitRate: '0.00%',
|
||||
orderNo: '-', rawStatus: '', statusText: '-', salePriceTotal: '0.00', profitRate: '0.00%',
|
||||
customerName: '-', customerMobile: '-', source: '-', deliveryType: '-',
|
||||
factoryName: '-', remark: '-', salePriceTotalNum: 0, costPriceTotalNum: 0, profitTotalNum: 0, rebateTotalNum: 0,
|
||||
});
|
||||
const orderItems = ref([]);
|
||||
const approveLogs = ref([]);
|
||||
const logisticsInfo = ref(null);
|
||||
|
||||
/** 订单完整状态流转步骤列表(用于进度展示) */
|
||||
const steps = ['草稿', '待审批', '已审批', '待工厂处理', '待司机接单', '已接单', '已揽货', '已送达', '已完成'];
|
||||
const steps = ['草稿', '待审批', '已审批', '待工厂处理', '待司机接单', '已接单', '已揽货', '已送达', '生产中', '已发货', '已完成'];
|
||||
|
||||
/** 状态码到中文状态文本的映射表 */
|
||||
const statusTextMap = {
|
||||
draft: '草稿', pending_approve: '待审批', approved: '已审批', rejected: '已驳回',
|
||||
cancel_pending: '待取消审批', cancel_fulfillment_pending: '履约取消审批中',
|
||||
@ -107,61 +174,121 @@ const statusTextMap = {
|
||||
accepted: '已接单', picked_up: '已揽货', delivered: '已送达',
|
||||
production: '生产中', shipped: '已发货', completed: '已完成', settled: '已结算',
|
||||
};
|
||||
/** 状态中文文本到状态码的反向映射 */
|
||||
const statusReverseMap = Object.fromEntries(Object.entries(statusTextMap).map(([k, v]) => [v, k]));
|
||||
|
||||
/** 费用构成列表(销售/成本/利润/回扣) */
|
||||
const costs = computed(() => [
|
||||
{ label: '销售金额', value: order.value.salePriceTotal },
|
||||
{ label: '成本金额', value: Number(order.value.costPriceTotalNum || 0).toFixed(2) },
|
||||
{ label: '利润金额', value: Number(order.value.profitTotalNum || 0).toFixed(2) },
|
||||
{ label: '回扣金额', value: Number(order.value.rebateTotalNum || 0).toFixed(2) },
|
||||
]);
|
||||
|
||||
/**
|
||||
* 加载订单详情:通过路由参数 id 调用后端接口获取订单数据
|
||||
*/
|
||||
async function loadDetail() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const token = localStorage.getItem('admin_token') || '';
|
||||
const resp = await fetch(`${import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000'}/api/orders/${route.params.id}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.code === 0) {
|
||||
const d = result.data;
|
||||
order.value = {
|
||||
orderNo: d.order_no || '-',
|
||||
statusText: statusTextMap[d.order_status] || d.order_status || '-',
|
||||
salePriceTotal: Number(d.sale_price_total || 0).toFixed(2),
|
||||
profitRate: Number(d.profit_rate || 0).toFixed(2) + '%',
|
||||
customerName: d.customer_name || '-',
|
||||
customerMobile: d.customer_mobile || '-',
|
||||
source: d.order_source || '-',
|
||||
deliveryType: d.delivery_type || '-',
|
||||
factoryName: d.factory_name || '-',
|
||||
remark: d.remark || '-',
|
||||
salePriceTotalNum: Number(d.sale_price_total || 0),
|
||||
costPriceTotalNum: Number(d.cost_price_total || 0),
|
||||
profitTotalNum: Number(d.profit_total || 0),
|
||||
rebateTotalNum: Number(d.rebate_total || 0),
|
||||
};
|
||||
}
|
||||
} catch { /* keep defaults */ } finally { loading.value = false; }
|
||||
const d = await fetchOrderDetailAdmin(route.params.id);
|
||||
order.value = {
|
||||
orderNo: d.order_no || '-',
|
||||
rawStatus: d.order_status || '',
|
||||
statusText: statusTextMap[d.order_status] || d.order_status || '-',
|
||||
salePriceTotal: Number(d.sale_price_total || 0).toFixed(2),
|
||||
profitRate: Number(d.profit_rate || 0).toFixed(2) + '%',
|
||||
customerName: d.customer_name || '-',
|
||||
customerMobile: d.customer_mobile || '-',
|
||||
source: d.order_source || '-',
|
||||
deliveryType: d.delivery_type || '-',
|
||||
factoryName: d.factory_name || '-',
|
||||
remark: d.remark || '-',
|
||||
salePriceTotalNum: Number(d.sale_price_total || 0),
|
||||
costPriceTotalNum: Number(d.cost_price_total || 0),
|
||||
profitTotalNum: Number(d.profit_total || 0),
|
||||
rebateTotalNum: Number(d.rebate_total || 0),
|
||||
};
|
||||
orderItems.value = d.items || [];
|
||||
approveLogs.value = d.approve_logs || [];
|
||||
logisticsInfo.value = d.logistics_info?.task || null;
|
||||
} catch (e) {
|
||||
toast.error(e.message || '加载订单详情失败');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动推进订单状态(仅本地 UI 更新,实际应调用后端接口)
|
||||
* @param {string} targetStatus - 目标状态码(如 'production'、'shipped' 等)
|
||||
*/
|
||||
function handleStatusChange(targetStatus) {
|
||||
order.value.statusText = statusTextMap[targetStatus] || targetStatus;
|
||||
async function handleSubmit() {
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await submitOrderToReview(route.params.id);
|
||||
toast.success('已提交审核');
|
||||
await loadDetail();
|
||||
} catch (e) { toast.error(e.message || '提交失败'); }
|
||||
finally { actionBusy.value = false; }
|
||||
}
|
||||
|
||||
async function handleStatusChange(targetStatus) {
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await changeOrderStatus(route.params.id, { status: targetStatus });
|
||||
toast.success(`状态已更新为${statusTextMap[targetStatus] || targetStatus}`);
|
||||
await loadDetail();
|
||||
} catch (e) { toast.error(e.message || '状态更新失败'); }
|
||||
finally { actionBusy.value = false; }
|
||||
}
|
||||
|
||||
async function handleCancel() {
|
||||
if (!cancelReason.value.trim()) { toast.error('请输入取消原因'); return; }
|
||||
actionBusy.value = true;
|
||||
try {
|
||||
await cancelOrderById(route.params.id, { cancel_reason: cancelReason.value });
|
||||
showCancelModal.value = false;
|
||||
toast.success('已发起取消');
|
||||
await loadDetail();
|
||||
} catch (e) { toast.error(e.message || '取消失败'); }
|
||||
finally { actionBusy.value = false; }
|
||||
}
|
||||
|
||||
onMounted(loadDetail);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:0;display:grid;gap:16px}.page-header{display:flex;justify-content:space-between;gap:12px;align-items:center}.subline{margin:0;color:#64748b}.back-link{padding:10px 14px;border:1px solid #cbd5e1;border-radius:12px;text-decoration:none;background:#fff}.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.summary-card,.panel{border:1px solid #dbe3ef;border-radius:18px;padding:16px;background:#fff}.summary-card span,.info-grid p{display:block;color:#64748b;margin:0 0 8px}.summary-card strong{font-size:20px}.panel-header{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}.flow-list{display:flex;flex-wrap:wrap;gap:8px}.flow-step{padding:8px 12px;border-radius:999px;background:#f8fafc;border:1px solid #dbe3ef;color:#334155}.flow-step.active{background:#dbeafe;color:#1d4ed8;border-color:#93c5fd}.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}.info-grid article{padding:12px;border-radius:14px;background:#f8fbff;border:1px solid #dbe3ef}.info-grid strong{font-size:16px}@media (max-width: 780px){.page-header,.panel-header{flex-direction:column;align-items:flex-start}}
|
||||
</style>
|
||||
.page{padding:0;display:grid;gap:16px}
|
||||
.page-header{display:flex;justify-content:space-between;gap:12px;align-items:center}
|
||||
.subline{margin:0;color:#64748b}
|
||||
.back-link{padding:10px 14px;border:1px solid #cbd5e1;border-radius:12px;text-decoration:none;background:#fff;color:#334155}
|
||||
.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
|
||||
.summary-card,.panel{border:1px solid #dbe3ef;border-radius:18px;padding:16px;background:#fff}
|
||||
.summary-card span,.info-grid p{display:block;color:#64748b;margin:0 0 8px}
|
||||
.summary-card strong{font-size:20px}
|
||||
.panel-header{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}
|
||||
.flow-list{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.flow-step{padding:8px 12px;border-radius:999px;background:#f8fafc;border:1px solid #dbe3ef;color:#334155;font-size:13px}
|
||||
.flow-step.active{background:#dbeafe;color:#1d4ed8;border-color:#93c5fd;font-weight:600}
|
||||
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
||||
.info-grid article{padding:12px;border-radius:14px;background:#f8fbff;border:1px solid #dbe3ef}
|
||||
.info-grid strong{font-size:16px}
|
||||
.cost-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:12px}
|
||||
.cost-grid article{padding:12px;border-radius:14px;background:#f8fbff;border:1px solid #dbe3ef}
|
||||
.cost-grid strong{font-size:18px;color:#0f172a}
|
||||
.table-wrap{overflow-x:auto}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{text-align:left;padding:10px;border-bottom:1px solid #e5e7eb}
|
||||
th{background:#f8fafc;font-size:13px;color:#334155}
|
||||
.money{font-weight:700}
|
||||
.log-list{display:grid;gap:10px}
|
||||
.log-card{padding:12px;border:1px solid #dbe3ef;border-radius:14px;background:#f8fbff}
|
||||
.log-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap}
|
||||
.log-type{padding:2px 8px;border-radius:999px;background:#f1f5f9;font-size:12px;color:#475569}
|
||||
.log-result{padding:2px 8px;border-radius:999px;font-size:12px;font-weight:600}
|
||||
.log-result.approved{background:#dcfce7;color:#166534}
|
||||
.log-result.rejected{background:#fee2e2;color:#b91c1c}
|
||||
.log-card p{margin:6px 0 0;color:#475569;font-size:13px}
|
||||
.action-row{display:flex;gap:10px;flex-wrap:wrap}
|
||||
.ghost-btn,.primary-btn{padding:10px 16px;border-radius:12px;border:1px solid #cbd5e1;background:#f8fafc;color:#334155;cursor:pointer;text-decoration:none;font-size:14px}
|
||||
.primary-btn{background:#2563eb;color:#fff;border-color:#2563eb}
|
||||
.danger-btn{color:#dc2626;border-color:#fecaca}
|
||||
button:disabled{opacity:0.5;cursor:not-allowed}
|
||||
.modal-mask{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;padding:24px;background:rgba(15,23,42,0.48)}
|
||||
.modal-panel{width:min(560px,100%);max-height:80vh;overflow:auto;border-radius:20px;background:#fff;padding:24px;box-shadow:0 24px 80px rgba(15,23,42,0.24)}
|
||||
.modal-header{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:16px}
|
||||
.modal-header h4{margin:0 0 6px;font-size:18px}
|
||||
.modal-header span{color:#64748b;font-size:13px}
|
||||
.modal-close{width:36px;height:36px;border:1px solid #d1d5db;background:#f8fafc;font-size:22px;border-radius:12px;cursor:pointer}
|
||||
.form-grid{display:grid;gap:12px}
|
||||
.form-grid label{display:grid;gap:6px}
|
||||
.form-grid span{color:#475569;font-size:13px;font-weight:600}
|
||||
.full-width{grid-column:1/-1}
|
||||
textarea{border:1px solid #d1d5db;border-radius:12px;padding:10px 12px;width:100%;box-sizing:border-box}
|
||||
.action-row.split{justify-content:space-between}
|
||||
@media(max-width:780px){.page-header,.panel-header{flex-direction:column;align-items:flex-start}}
|
||||
</style>
|
||||
|
||||
@ -1,54 +1,64 @@
|
||||
<!--
|
||||
OrderTrackingPage.vue - 物流轨迹查询页面
|
||||
职责:通过订单 ID 查询快递100物流轨迹,以时间线形式展示轨迹节点。
|
||||
支持实时查询和加载状态展示,无轨迹时显示空状态提示。
|
||||
职责:通过订单号或订单ID查询快递100物流轨迹,以时间线形式展示。
|
||||
支持订单号自动转换为订单ID查询,显示关联订单信息。
|
||||
-->
|
||||
<template>
|
||||
<section class="page tracking-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>物流轨迹查询</h2>
|
||||
<p class="subline">通过订单号查询快递100物流轨迹,支持实时刷新。</p>
|
||||
<p class="subline">输入订单号或订单ID查询物流轨迹。</p>
|
||||
</div>
|
||||
<span class="mode-tag">快递100对接</span>
|
||||
<RouterLink class="back-link" to="/orders">返回订单中心</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 查询表单区域 -->
|
||||
<!-- 查询表单 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header"><h3>查询轨迹</h3></div>
|
||||
<div class="query-row">
|
||||
<label>
|
||||
<span>订单ID</span>
|
||||
<input v-model.trim="queryOrderId" type="number" placeholder="输入订单ID" @keyup.enter="queryTrace" />
|
||||
<span>订单号 / 订单ID</span>
|
||||
<input v-model.trim="queryInput" type="text" placeholder="输入订单号(如 SO20260601001)或订单ID" @keyup.enter="queryTrace" />
|
||||
</label>
|
||||
<button class="primary-btn" :disabled="!queryOrderId || loading" @click="queryTrace">查询</button>
|
||||
<button class="primary-btn" :disabled="!queryInput || loading" @click="queryTrace">{{ loading ? '查询中...' : '查询' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 轨迹结果展示:时间线形式,最新节点高亮 -->
|
||||
<!-- 关联订单信息 -->
|
||||
<section v-if="orderInfo" class="panel">
|
||||
<div class="panel-header"><h3>订单信息</h3></div>
|
||||
<div class="info-grid">
|
||||
<article><p>订单号</p><strong>{{ orderInfo.order_no }}</strong></article>
|
||||
<article><p>客户</p><strong>{{ orderInfo.customer_name }}</strong></article>
|
||||
<article><p>状态</p><strong>{{ orderInfo.statusText }}</strong></article>
|
||||
<article><p>金额</p><strong>¥{{ orderInfo.sale_price_total }}</strong></article>
|
||||
<article v-if="orderInfo.express_company"><p>快递公司</p><strong>{{ orderInfo.express_company }}</strong></article>
|
||||
<article v-if="orderInfo.tracking_number"><p>快递单号</p><strong>{{ orderInfo.tracking_number }}</strong></article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 轨迹时间线 -->
|
||||
<section v-if="traceList.length" class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>物流轨迹</h3>
|
||||
<span>订单 #{{ tracedOrderId }} · 共 {{ traceList.length }} 条记录</span>
|
||||
<span>共 {{ traceList.length }} 条记录</span>
|
||||
</div>
|
||||
<div class="trace-timeline">
|
||||
<!-- 遍历轨迹节点,每项显示时间、描述、来源平台和备注 -->
|
||||
<div v-for="(item, index) in traceList" :key="index" class="trace-item">
|
||||
<!-- 最新节点使用蓝色圆点,其余为灰色 -->
|
||||
<div class="trace-dot" :class="{ active: index === 0 }"></div>
|
||||
<!-- 节点间连接线(最后一个节点不显示) -->
|
||||
<div class="trace-connector" v-if="index < traceList.length - 1"></div>
|
||||
<div class="trace-card">
|
||||
<div class="trace-time">{{ item.node_time }}</div>
|
||||
<div class="trace-desc">{{ item.node_desc }}</div>
|
||||
<span v-if="item.source_platform" class="trace-source badge">{{ item.source_platform }}</span>
|
||||
<span v-if="item.source_platform" class="trace-source">{{ item.source_platform }}</span>
|
||||
<span v-if="item.remark" class="trace-location">{{ item.remark }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 空状态:已查询但无轨迹数据 -->
|
||||
<!-- 空状态 -->
|
||||
<section v-if="!traceList.length && !loading && queried" class="panel">
|
||||
<div class="empty-state">
|
||||
<p>该订单暂无物流轨迹记录</p>
|
||||
@ -59,63 +69,83 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* OrderTrackingPage.vue - 物流轨迹查询页面脚本
|
||||
* 管理查询输入、调用后端物流轨迹接口、展示轨迹时间线。
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, RouterLink } from 'vue-router';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { fetchOrderList } from '../mockApi';
|
||||
|
||||
const route = useRoute();
|
||||
const toast = useToast();
|
||||
/** 后端 API 基础地址 */
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000';
|
||||
|
||||
/** 用户输入的查询订单 ID */
|
||||
const queryOrderId = ref('');
|
||||
/** 轨迹列表数据 */
|
||||
const queryInput = ref('');
|
||||
const traceList = ref([]);
|
||||
/** 当前已查询的订单 ID(用于标题显示) */
|
||||
const tracedOrderId = ref('');
|
||||
/** 查询请求进行中标识 */
|
||||
const loading = ref(false);
|
||||
/** 是否已执行过查询(用于区分"未查询"和"无数据"两种状态) */
|
||||
const queried = ref(false);
|
||||
const orderInfo = ref(null);
|
||||
|
||||
/**
|
||||
* 获取本地存储的管理员 token
|
||||
* @returns {string} token 值
|
||||
*/
|
||||
function getToken() {
|
||||
return localStorage.getItem('admin_token') || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询物流轨迹:调用后端 /api/logistics/{orderId}/trace 接口
|
||||
* 校验订单 ID 有效性后发起请求,成功后填充轨迹列表
|
||||
*/
|
||||
async function queryTrace() {
|
||||
const orderId = parseInt(queryOrderId.value, 10);
|
||||
if (!orderId || orderId <= 0) {
|
||||
toast.error('请输入有效的订单ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!queryInput.value.trim()) return;
|
||||
loading.value = true;
|
||||
traceList.value = [];
|
||||
orderInfo.value = null;
|
||||
queried.value = false;
|
||||
|
||||
try {
|
||||
let orderId = parseInt(queryInput.value, 10);
|
||||
|
||||
// If not a number, try searching by order_no
|
||||
if (!orderId || orderId <= 0) {
|
||||
const orderData = await fetchOrderList({ order_no: queryInput.value.trim(), page_size: 10 });
|
||||
if (orderData.rows && orderData.rows.length > 0) {
|
||||
orderId = orderData.rows[0].orderId;
|
||||
orderInfo.value = {
|
||||
order_no: orderData.rows[0].orderNo,
|
||||
customer_name: orderData.rows[0].customerName,
|
||||
statusText: orderData.rows[0].statusText,
|
||||
sale_price_total: orderData.rows[0].salePriceTotal,
|
||||
};
|
||||
} else {
|
||||
toast.error('未找到匹配的订单');
|
||||
queried.value = true;
|
||||
loading.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch order detail for info
|
||||
if (!orderInfo.value) {
|
||||
try {
|
||||
const resp = await fetch(`${API_BASE}/api/orders/${orderId}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}) },
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.code === 0 && result.data) {
|
||||
const d = result.data;
|
||||
const task = d.logistics_info?.task;
|
||||
orderInfo.value = {
|
||||
order_no: d.order_no,
|
||||
customer_name: d.customer_name,
|
||||
statusText: d.order_status,
|
||||
sale_price_total: Number(d.sale_price_total || 0).toFixed(2),
|
||||
express_company: task?.express_company || null,
|
||||
tracking_number: task?.tracking_number || null,
|
||||
};
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Fetch trace
|
||||
const resp = await fetch(`${API_BASE}/api/logistics/${orderId}/trace`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}),
|
||||
},
|
||||
headers: { 'Content-Type': 'application/json', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}) },
|
||||
});
|
||||
const result = await resp.json();
|
||||
if (resp.ok && result.code === 0) {
|
||||
const data = result.data || {};
|
||||
traceList.value = data.trace_list || [];
|
||||
tracedOrderId.value = orderId;
|
||||
traceList.value = (result.data || {}).trace_list || [];
|
||||
queried.value = true;
|
||||
if (traceList.value.length === 0) {
|
||||
toast.info('暂无物流轨迹');
|
||||
@ -133,21 +163,33 @@ async function queryTrace() {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const orderIdParam = route.query.order_id;
|
||||
if (orderIdParam) {
|
||||
queryInput.value = orderIdParam;
|
||||
queryTrace();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page{padding:0;display:grid;gap:16px}
|
||||
.page-header{display:flex;justify-content:space-between;gap:12px;align-items:center}
|
||||
.subline{margin:0;color:#64748b}
|
||||
.mode-tag{display:inline-flex;align-items:center;padding:6px 10px;border-radius:999px;background:#dbeafe;color:#1d4ed8;font-size:12px;font-weight:600}
|
||||
.back-link{padding:10px 14px;border:1px solid #cbd5e1;border-radius:12px;text-decoration:none;background:#fff;color:#334155}
|
||||
.panel{border:1px solid #dbe3ef;border-radius:18px;padding:16px;background:#fff}
|
||||
.panel-header{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}
|
||||
.query-row{display:flex;gap:12px;align-items:flex-end}
|
||||
.query-row label{display:flex;flex-direction:column;gap:4px;flex:1;max-width:300px}
|
||||
.query-row label{display:flex;flex-direction:column;gap:4px;flex:1;max-width:400px}
|
||||
.query-row label span{font-size:13px;color:#64748b}
|
||||
.query-row input,.ghost-btn,.primary-btn{border-radius:12px;border:1px solid #cbd5e1;padding:10px 14px;background:#fff}
|
||||
.primary-btn{background:#2563eb;color:#fff;border-color:#2563eb}
|
||||
.primary-btn:disabled{opacity:0.5;cursor:not-allowed}
|
||||
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
||||
.info-grid article{padding:12px;border-radius:14px;background:#f8fbff;border:1px solid #dbe3ef}
|
||||
.info-grid p{display:block;color:#64748b;margin:0 0 8px}
|
||||
.info-grid strong{font-size:16px}
|
||||
.trace-timeline{position:relative;padding-left:24px}
|
||||
.trace-item{position:relative;padding-bottom:20px}
|
||||
.trace-dot{position:absolute;left:-24px;top:4px;width:12px;height:12px;border-radius:50%;background:#cbd5e1;border:2px solid #fff;z-index:1}
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
<!--
|
||||
OrdersPage.vue - 订单中心页面
|
||||
职责:订单录入(保存草稿/提交审核)、订单列表查询(按订单号/客户名/状态筛选)、
|
||||
订单取消申请(弹窗填写取消原因)。左右分栏布局,左侧录入表单,右侧列表。
|
||||
职责:管理员查看和管理订单,支持按订单号/客户名/状态筛选查询、
|
||||
订单详情查看和取消申请。订单由业务员端创建。
|
||||
-->
|
||||
<template>
|
||||
<section class="page orders-page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>订单中心</h2>
|
||||
<p class="subline">对接真实后端 API,支持订单录入、查询、提交审核和取消。</p>
|
||||
<p class="subline">查看和管理所有订单,订单由业务员在录单中心创建。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计卡片:草稿/待审批/待取消审批/已进入履约 -->
|
||||
<!-- 顶部统计卡片 -->
|
||||
<div class="summary-grid">
|
||||
<article class="summary-card"><span>草稿</span><strong>{{ stats.draft }}</strong></article>
|
||||
<article class="summary-card"><span>待审批</span><strong>{{ stats.pending }}</strong></article>
|
||||
@ -20,104 +20,69 @@
|
||||
<article class="summary-card"><span>已进入履约</span><strong>{{ stats.factory }}</strong></article>
|
||||
</div>
|
||||
|
||||
<!-- 左右分栏:左侧订单录入表单 + 右侧订单列表 -->
|
||||
<section class="split-layout">
|
||||
<!-- 左侧:订单录入表单 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>订单录入</h3>
|
||||
<span>支持新客户自动识别、提交审核和保存草稿</span>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<label><span>客户姓名</span><input v-model.trim="form.customer_name" type="text" placeholder="请输入客户姓名" /></label>
|
||||
<label><span>客户手机号</span><input v-model.trim="form.customer_mobile" type="text" placeholder="请输入客户手机号" /></label>
|
||||
<label><span>产品名称</span><input v-model.trim="form.product_name" type="text" placeholder="请输入产品名称" /></label>
|
||||
<label><span>规格</span><input v-model.trim="form.specification" type="text" placeholder="请输入规格" /></label>
|
||||
<label><span>单位</span><input v-model.trim="form.unit" type="text" placeholder="请输入单位" /></label>
|
||||
<label><span>数量</span><input v-model.number="form.quantity" type="number" min="0.01" step="0.01" /></label>
|
||||
<label><span>销售单价</span><input v-model.number="form.sale_price" type="number" min="0" step="0.01" /></label>
|
||||
<label><span>成本单价</span><input v-model.number="form.cost_price" type="number" min="0" step="0.01" /></label>
|
||||
<label><span>订单来源</span><select v-model="form.order_source"><option>线下拜访</option><option>电话下单</option><option>微信下单</option></select></label>
|
||||
<label><span>交付方式</span><select v-model="form.delivery_type"><option>工厂直送</option><option>自提</option></select></label>
|
||||
<label><span>回扣总额</span><input v-model.number="form.rebate_total" type="number" min="0" step="0.01" /></label>
|
||||
<label><span>运费总额</span><input v-model.number="form.freight_total" type="number" min="0" step="0.01" /></label>
|
||||
<label class="full-width"><span>订单备注</span><textarea v-model.trim="form.remark" rows="3" placeholder="请输入备注" /></label>
|
||||
</div>
|
||||
<!-- 录入操作按钮:保存草稿 / 提交审核 -->
|
||||
<div class="action-row">
|
||||
<button class="ghost-btn" :disabled="busy" @click="handleCreateDraft">保存草稿</button>
|
||||
<button class="primary-btn" :disabled="busy" @click="handleSubmitOrder">提交审核</button>
|
||||
</div>
|
||||
<!-- 新客户识别说明 -->
|
||||
<div class="rule-note">
|
||||
<strong>新客户识别说明</strong>
|
||||
<p>{{ autoSyncHint }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右侧:订单列表与筛选 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>订单列表</h3>
|
||||
<span>查询订单与状态</span>
|
||||
</div>
|
||||
<!-- 筛选工具栏:按订单号/客户名/状态 -->
|
||||
<section class="toolbar toolbar-4">
|
||||
<label>
|
||||
<span>订单号</span>
|
||||
<input v-model.trim="filters.order_no" type="text" placeholder="搜索订单号" />
|
||||
</label>
|
||||
<label>
|
||||
<span>客户姓名</span>
|
||||
<input v-model.trim="filters.customer_name" type="text" placeholder="搜索客户名" />
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model="filters.order_status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="pending_approve">待审批</option>
|
||||
<option value="approved">已审批</option>
|
||||
<option value="rejected">已退回</option>
|
||||
<option value="cancel_pending">待取消审批</option>
|
||||
<option value="pending_factory">待工厂处理</option>
|
||||
<option value="pending_driver">待司机接单</option>
|
||||
<option value="canceled">已取消</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="toolbar-actions">
|
||||
<button class="primary-btn" :disabled="loading" @click="handleSearch">{{ loading ? '查询中...' : '查询' }}</button>
|
||||
<button class="ghost-btn" :disabled="loading" @click="handleReset">重置</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 加载中状态 -->
|
||||
<p v-if="loading" class="loading">正在加载订单列表...</p>
|
||||
<!-- 无数据状态 -->
|
||||
<div v-else-if="!orders.length" class="empty-state">当前没有订单数据</div>
|
||||
<!-- 订单列表表格 -->
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>订单号</th><th>客户</th><th>状态</th><th>金额</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in orders" :key="row.orderId">
|
||||
<td>{{ row.orderNo }}</td>
|
||||
<td>{{ row.customerName }}</td>
|
||||
<td><span class="status">{{ row.statusText }}</span></td>
|
||||
<td class="money">{{ row.salePriceTotal }}</td>
|
||||
<td>{{ row.createdAt }}</td>
|
||||
<td class="actions">
|
||||
<RouterLink class="link-btn" :to="`/approvals/${row.orderId}`">详情</RouterLink>
|
||||
<!-- 取消按钮:草稿/待审批/已审批/待工厂状态可取消 -->
|
||||
<button class="link-btn" :disabled="!canRequestCancel(row.orderStatus)" @click="handleCancel(row)">{{ cancelLabel(row.orderStatus) }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- 订单列表与筛选 -->
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h3>订单列表</h3>
|
||||
<span>按条件筛选订单</span>
|
||||
</div>
|
||||
<!-- 筛选工具栏 -->
|
||||
<section class="toolbar toolbar-4">
|
||||
<label>
|
||||
<span>订单号</span>
|
||||
<input v-model.trim="filters.order_no" type="text" placeholder="搜索订单号" />
|
||||
</label>
|
||||
<label>
|
||||
<span>客户姓名</span>
|
||||
<input v-model.trim="filters.customer_name" type="text" placeholder="搜索客户名" />
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<select v-model="filters.order_status">
|
||||
<option value="">全部状态</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="pending_approve">待审批</option>
|
||||
<option value="approved">已审批</option>
|
||||
<option value="rejected">已退回</option>
|
||||
<option value="cancel_pending">待取消审批</option>
|
||||
<option value="pending_factory">待工厂处理</option>
|
||||
<option value="pending_driver">待司机接单</option>
|
||||
<option value="canceled">已取消</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="toolbar-actions">
|
||||
<button class="primary-btn" :disabled="loading" @click="handleSearch">{{ loading ? '查询中...' : '查询' }}</button>
|
||||
<button class="ghost-btn" :disabled="loading" @click="handleReset">重置</button>
|
||||
</div>
|
||||
</section>
|
||||
<!-- 加载中状态 -->
|
||||
<p v-if="loading" class="loading">正在加载订单列表...</p>
|
||||
<!-- 无数据状态 -->
|
||||
<div v-else-if="!orders.length" class="empty-state">当前没有订单数据</div>
|
||||
<!-- 订单列表表格 -->
|
||||
<div v-else class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>订单号</th><th>客户</th><th>订单来源</th><th>交付方式</th><th>状态</th><th>金额</th><th>创建时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="row in orders" :key="row.orderId">
|
||||
<td>{{ row.orderNo }}</td>
|
||||
<td>{{ row.customerName }}</td>
|
||||
<td>{{ row.orderSource }}</td>
|
||||
<td>{{ row.deliveryType }}</td>
|
||||
<td><span class="status">{{ row.statusText }}</span></td>
|
||||
<td class="money">{{ row.salePriceTotal }}</td>
|
||||
<td>{{ row.createdAt }}</td>
|
||||
<td class="actions">
|
||||
<RouterLink class="link-btn" :to="`/orders/${row.orderId}`">详情</RouterLink>
|
||||
<button class="link-btn" :disabled="!canRequestCancel(row.orderStatus)" @click="handleCancel(row)">{{ cancelLabel(row.orderStatus) }}</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 取消订单弹窗:填写取消原因和意见 -->
|
||||
<!-- 取消订单弹窗 -->
|
||||
<div v-if="showCancelModal" class="modal-mask" @click.self="showCancelModal = false">
|
||||
<section class="modal-panel">
|
||||
<div class="modal-header">
|
||||
@ -149,62 +114,24 @@
|
||||
<script setup>
|
||||
/**
|
||||
* OrdersPage.vue - 订单中心页面脚本
|
||||
* 管理订单录入表单、筛选条件、订单列表查询、草稿保存、提交审核和取消申请。
|
||||
* 管理订单列表查询、筛选和取消申请。
|
||||
*/
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { onMounted, reactive, ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useToast } from '../composables/useToast';
|
||||
import { fetchOrderList, createOrder, submitOrderToReview, cancelOrderById } from '../mockApi';
|
||||
import { fetchOrderList, cancelOrderById } from '../mockApi';
|
||||
|
||||
const toast = useToast();
|
||||
/** 列表加载中标识 */
|
||||
const loading = ref(true);
|
||||
/** 录入操作进行中标识(保存草稿/提交审核) */
|
||||
const busy = ref(false);
|
||||
/** 取消操作进行中标识 */
|
||||
const cancelBusy = ref(false);
|
||||
/** 取消订单弹窗是否显示 */
|
||||
const showCancelModal = ref(false);
|
||||
/** 当前要取消的订单行数据 */
|
||||
const cancelTarget = ref(null);
|
||||
|
||||
/** 订单筛选条件(订单号、客户名、状态) */
|
||||
const filters = reactive({ order_no: '', customer_name: '', order_status: '' });
|
||||
/** 订单录入表单数据 */
|
||||
const form = reactive({
|
||||
customer_name: '', customer_mobile: '', product_name: '', specification: '', unit: '',
|
||||
quantity: 1, sale_price: 0, cost_price: 0, order_source: '线下拜访', delivery_type: '工厂直送',
|
||||
rebate_total: 0, freight_total: 0, remark: '',
|
||||
});
|
||||
/** 取消订单表单(取消原因必填、取消意见可选) */
|
||||
const cancelForm = reactive({ cancel_reason: '', cancel_opinion: '' });
|
||||
/** 当前订单列表数据 */
|
||||
const orders = ref([]);
|
||||
const stats = ref({ draft: 0, pending: 0, cancelPending: 0, factory: 0 });
|
||||
|
||||
/**
|
||||
* 新客户识别提示语:根据是否已填写客户姓名和手机号动态切换
|
||||
*/
|
||||
const autoSyncHint = computed(() =>
|
||||
!form.customer_name || !form.customer_mobile
|
||||
? '请先填写客户姓名和手机号,系统将按姓名 + 手机号识别是否为新客户。'
|
||||
: '系统会在提交时自动识别新客户并进入同步流程。'
|
||||
);
|
||||
|
||||
/**
|
||||
* 订单统计汇总:按状态分组统计草稿/待审批/待取消审批/已进入履约的数量
|
||||
*/
|
||||
const stats = computed(() => ({
|
||||
draft: orders.value.filter((i) => i.orderStatus === 'draft').length,
|
||||
pending: orders.value.filter((i) => i.orderStatus === 'pending_approve').length,
|
||||
cancelPending: orders.value.filter((i) => i.orderStatus === 'cancel_pending' || i.orderStatus === 'cancel_fulfillment_pending').length,
|
||||
factory: orders.value.filter((i) => i.orderStatus === 'pending_factory' || i.orderStatus === 'pending_driver').length,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 判断给定状态是否允许发起取消操作
|
||||
* @param {string} status - 订单状态码
|
||||
* @returns {boolean} 是否可取消
|
||||
*/
|
||||
function canRequestCancel(status) {
|
||||
return ['draft', 'pending_approve', 'approved', 'pending_factory'].includes(status);
|
||||
}
|
||||
@ -219,46 +146,6 @@ function cancelLabel(status) {
|
||||
return '申请取消';
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建订单提交 payload
|
||||
* @param {boolean} draft - 是否为草稿模式
|
||||
* @returns {Object} 订单提交数据对象
|
||||
*/
|
||||
function buildPayload(draft = false) {
|
||||
return {
|
||||
customer_name: form.customer_name,
|
||||
customer_mobile: form.customer_mobile,
|
||||
order_source: form.order_source,
|
||||
delivery_type: form.delivery_type,
|
||||
rebate_total: form.rebate_total,
|
||||
freight_total: form.freight_total,
|
||||
tax_total: 0,
|
||||
other_fee_total: 0,
|
||||
remark: form.remark,
|
||||
items: [{
|
||||
product_name: form.product_name,
|
||||
specification: form.specification,
|
||||
unit: form.unit,
|
||||
quantity: form.quantity,
|
||||
sale_price: form.sale_price,
|
||||
cost_price: form.cost_price,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置录入表单到初始状态
|
||||
*/
|
||||
function resetForm() {
|
||||
form.customer_name = ''; form.customer_mobile = ''; form.product_name = '';
|
||||
form.specification = ''; form.unit = ''; form.quantity = 1;
|
||||
form.sale_price = 0; form.cost_price = 0; form.rebate_total = 0;
|
||||
form.freight_total = 0; form.remark = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 按筛选条件查询订单列表
|
||||
*/
|
||||
async function handleSearch() {
|
||||
loading.value = true;
|
||||
try {
|
||||
@ -269,6 +156,7 @@ async function handleSearch() {
|
||||
page_no: 1, page_size: 50,
|
||||
});
|
||||
orders.value = data.rows;
|
||||
if (data.stats) stats.value = data.stats;
|
||||
} catch (error) {
|
||||
toast.error(error.message || '加载订单列表失败');
|
||||
} finally {
|
||||
@ -284,52 +172,6 @@ function handleReset() {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存订单草稿:校验客户信息后调用创建接口
|
||||
*/
|
||||
async function handleCreateDraft() {
|
||||
if (!form.customer_name.trim() || !form.customer_mobile.trim()) {
|
||||
toast.error('请先填写客户姓名和手机号。'); return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
await createOrder(buildPayload(true));
|
||||
toast.success('订单草稿已保存。');
|
||||
resetForm();
|
||||
await handleSearch();
|
||||
} catch (error) {
|
||||
toast.error(error.message || '保存草稿失败');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 提交订单审核:先创建订单,再调用提交审核接口
|
||||
*/
|
||||
async function handleSubmitOrder() {
|
||||
if (!form.customer_name.trim() || !form.customer_mobile.trim()) {
|
||||
toast.error('请先填写客户姓名和手机号。'); return;
|
||||
}
|
||||
if (!form.product_name.trim()) {
|
||||
toast.error('请填写产品名称。'); return;
|
||||
}
|
||||
busy.value = true;
|
||||
try {
|
||||
const result = await createOrder(buildPayload(false));
|
||||
if (result.order_id) {
|
||||
await submitOrderToReview(result.order_id);
|
||||
}
|
||||
toast.success('订单已创建并提交审核。');
|
||||
resetForm();
|
||||
await handleSearch();
|
||||
} catch (error) {
|
||||
toast.error(error.message || '提交订单失败');
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 打开取消订单弹窗,初始化取消表单
|
||||
* @param {Object} row - 要取消的订单行数据
|
||||
|
||||
Loading…
Reference in New Issue
Block a user