refactor: 订单中心+详情+物流轨迹全面优化

- 订单中心:移除管理员录入表单,全宽列表,增加来源/交付方式列,修正详情链接
- 订单详情:增加产品明细表、审批记录、物流信息;按钮绑定真实后端接口
- 物流轨迹:支持订单号搜索,显示关联订单信息,支持URL参数自动查询
- mockApi:新增 fetchOrderDetailAdmin、changeOrderStatus、fetchOrderList stats

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
taiyi 2026-06-05 13:03:26 +08:00
parent a5820c596f
commit cdaff4999d
4 changed files with 423 additions and 386 deletions

View File

@ -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}>} 工厂待处理订单列表

View File

@ -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">&times;</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>

View File

@ -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}

View File

@ -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 - 要取消的订单行数据