Compare commits
3 Commits
cdaff4999d
...
276a3c0017
| Author | SHA1 | Date | |
|---|---|---|---|
| 276a3c0017 | |||
| 8b2c154284 | |||
| 9650c891d9 |
@ -116,7 +116,7 @@
|
|||||||
<button v-if="order.rawStatus === 'shipped'" class="ghost-btn" @click="handleStatusChange('completed')">标记已完成</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 === '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>
|
<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>
|
<RouterLink v-if="order.rawStatus" class="ghost-btn" :to="`/orders/tracking?order_id=${route.params.id}`">查看物流轨迹</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@ -1,50 +1,106 @@
|
|||||||
<!--
|
<!--
|
||||||
OrderTrackingPage.vue - 物流轨迹查询页面
|
OrderTrackingPage.vue - 物流管理页面
|
||||||
职责:通过订单号或订单ID查询快递100物流轨迹,以时间线形式展示。
|
职责:展示所有订单的物流状态总览,支持按状态/快递公司/订单号筛选。
|
||||||
支持订单号自动转换为订单ID查询,显示关联订单信息。
|
点击"查看轨迹"弹窗显示该订单的物流时间线。
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<section class="page tracking-page">
|
<section class="page tracking-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>物流轨迹查询</h2>
|
<h2>物流管理</h2>
|
||||||
<p class="subline">输入订单号或订单ID查询物流轨迹。</p>
|
<p class="subline">查看所有订单的物流状态,点击查看详情。</p>
|
||||||
</div>
|
</div>
|
||||||
<RouterLink class="back-link" to="/orders">返回订单中心</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 查询表单 -->
|
<!-- 筛选工具栏 -->
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header"><h3>查询轨迹</h3></div>
|
<div class="panel-header"><h3>物流列表</h3><span>共 {{ filteredList.length }} 条</span></div>
|
||||||
<div class="query-row">
|
<section class="toolbar toolbar-4">
|
||||||
<label>
|
<label>
|
||||||
<span>订单号 / 订单ID</span>
|
<span>订单号 / 客户名</span>
|
||||||
<input v-model.trim="queryInput" type="text" placeholder="输入订单号(如 SO20260601001)或订单ID" @keyup.enter="queryTrace" />
|
<input v-model.trim="filters.keyword" type="text" placeholder="搜索订单号或客户名" />
|
||||||
</label>
|
</label>
|
||||||
<button class="primary-btn" :disabled="!queryInput || loading" @click="queryTrace">{{ loading ? '查询中...' : '查询' }}</button>
|
<label>
|
||||||
|
<span>物流状态</span>
|
||||||
|
<select v-model="filters.trace_status">
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="pending">待发货</option>
|
||||||
|
<option value="in_transit">运输中</option>
|
||||||
|
<option value="delivered">已签收</option>
|
||||||
|
<option value="exception">异常</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>快递公司</span>
|
||||||
|
<select v-model="filters.express_company">
|
||||||
|
<option value="">全部快递</option>
|
||||||
|
<option v-for="company in expressCompanies" :key="company" :value="company">{{ company }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<button class="primary-btn" @click="handleSearch">查询</button>
|
||||||
|
<button class="ghost-btn" @click="handleReset">重置</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- 关联订单信息 -->
|
<!-- 物流列表 -->
|
||||||
<section v-if="orderInfo" class="panel">
|
<p v-if="loading" class="loading">正在加载物流数据...</p>
|
||||||
<div class="panel-header"><h3>订单信息</h3></div>
|
<div v-else-if="!filteredList.length" class="empty-state">暂无物流数据</div>
|
||||||
<div class="info-grid">
|
<div v-else class="table-wrap">
|
||||||
<article><p>订单号</p><strong>{{ orderInfo.order_no }}</strong></article>
|
<table>
|
||||||
<article><p>客户</p><strong>{{ orderInfo.customer_name }}</strong></article>
|
<thead>
|
||||||
<article><p>状态</p><strong>{{ orderInfo.statusText }}</strong></article>
|
<tr>
|
||||||
<article><p>金额</p><strong>¥{{ orderInfo.sale_price_total }}</strong></article>
|
<th>订单号</th>
|
||||||
<article v-if="orderInfo.express_company"><p>快递公司</p><strong>{{ orderInfo.express_company }}</strong></article>
|
<th>客户</th>
|
||||||
<article v-if="orderInfo.tracking_number"><p>快递单号</p><strong>{{ orderInfo.tracking_number }}</strong></article>
|
<th>快递公司</th>
|
||||||
|
<th>快递单号</th>
|
||||||
|
<th>物流状态</th>
|
||||||
|
<th>最后更新</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="row in filteredList" :key="row.order_id">
|
||||||
|
<td>{{ row.order_no }}</td>
|
||||||
|
<td>{{ row.customer_name }}</td>
|
||||||
|
<td>{{ row.express_company || '-' }}</td>
|
||||||
|
<td>{{ row.tracking_number || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<span :class="['status-badge', row.trace_status_class]">{{ row.trace_status_text }}</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ row.last_update || '-' }}</td>
|
||||||
|
<td>
|
||||||
|
<button v-if="row.has_trace" class="link-btn" @click="openTraceModal(row)">查看轨迹</button>
|
||||||
|
<span v-else class="no-trace">暂无轨迹</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
<!-- 轨迹弹窗 -->
|
||||||
|
<div v-if="showTraceModal" class="modal-mask" @click.self="closeTraceModal">
|
||||||
|
<section class="modal-panel trace-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h4>物流轨迹</h4>
|
||||||
|
<span>{{ traceModalData.order_no }} · {{ traceModalData.customer_name }}</span>
|
||||||
|
</div>
|
||||||
|
<button class="modal-close" @click="closeTraceModal">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 订单物流摘要 -->
|
||||||
|
<div class="trace-summary">
|
||||||
|
<span>快递公司:{{ traceModalData.express_company || '-' }}</span>
|
||||||
|
<span>快递单号:{{ traceModalData.tracking_number || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 加载中 -->
|
||||||
|
<p v-if="traceLoading" class="loading">正在查询轨迹...</p>
|
||||||
|
|
||||||
<!-- 轨迹时间线 -->
|
<!-- 轨迹时间线 -->
|
||||||
<section v-if="traceList.length" class="panel">
|
<div v-else-if="traceList.length" class="trace-timeline">
|
||||||
<div class="panel-header">
|
|
||||||
<h3>物流轨迹</h3>
|
|
||||||
<span>共 {{ traceList.length }} 条记录</span>
|
|
||||||
</div>
|
|
||||||
<div class="trace-timeline">
|
|
||||||
<div v-for="(item, index) in traceList" :key="index" class="trace-item">
|
<div v-for="(item, index) in traceList" :key="index" class="trace-item">
|
||||||
<div class="trace-dot" :class="{ active: index === 0 }"></div>
|
<div class="trace-dot" :class="{ active: index === 0 }"></div>
|
||||||
<div class="trace-connector" v-if="index < traceList.length - 1"></div>
|
<div class="trace-connector" v-if="index < traceList.length - 1"></div>
|
||||||
@ -56,21 +112,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- 空状态 -->
|
<!-- 无轨迹 -->
|
||||||
<section v-if="!traceList.length && !loading && queried" class="panel">
|
<div v-else class="empty-state">
|
||||||
<div class="empty-state">
|
<p>暂无物流轨迹</p>
|
||||||
<p>该订单暂无物流轨迹记录</p>
|
<p class="subline">可能原因:未填写快递单号,或快递100尚未获取到轨迹</p>
|
||||||
<p class="subline">可能原因:订单未创建物流任务、未填写快递单号、或快递100尚未获取到轨迹</p>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
import { useRoute, RouterLink } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { useToast } from '../composables/useToast';
|
import { useToast } from '../composables/useToast';
|
||||||
import { fetchOrderList } from '../mockApi';
|
import { fetchOrderList } from '../mockApi';
|
||||||
|
|
||||||
@ -78,128 +133,206 @@ const route = useRoute();
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000';
|
const API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000';
|
||||||
|
|
||||||
const queryInput = ref('');
|
const loading = ref(true);
|
||||||
|
const orderList = ref([]);
|
||||||
|
const filters = reactive({ keyword: '', trace_status: '', express_company: '' });
|
||||||
|
|
||||||
|
// Trace modal
|
||||||
|
const showTraceModal = ref(false);
|
||||||
|
const traceLoading = ref(false);
|
||||||
const traceList = ref([]);
|
const traceList = ref([]);
|
||||||
const loading = ref(false);
|
const traceModalData = ref({});
|
||||||
const queried = ref(false);
|
|
||||||
const orderInfo = ref(null);
|
const expressCompanies = computed(() => {
|
||||||
|
const set = new Set(orderList.value.map(r => r.express_company).filter(Boolean));
|
||||||
|
return [...set].sort();
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredList = computed(() => {
|
||||||
|
return orderList.value.filter(row => {
|
||||||
|
if (filters.keyword) {
|
||||||
|
const kw = filters.keyword.toLowerCase();
|
||||||
|
if (!row.order_no.toLowerCase().includes(kw) && !row.customer_name.toLowerCase().includes(kw)) return false;
|
||||||
|
}
|
||||||
|
if (filters.express_company && row.express_company !== filters.express_company) return false;
|
||||||
|
if (filters.trace_status) {
|
||||||
|
if (filters.trace_status === 'pending' && row.trace_status !== 'pending') return false;
|
||||||
|
if (filters.trace_status === 'in_transit' && row.trace_status !== 'in_transit') return false;
|
||||||
|
if (filters.trace_status === 'delivered' && row.trace_status !== 'delivered') return false;
|
||||||
|
if (filters.trace_status === 'exception' && row.trace_status !== 'exception') return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function getToken() {
|
function getToken() {
|
||||||
return localStorage.getItem('admin_token') || '';
|
return localStorage.getItem('admin_token') || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function queryTrace() {
|
async function loadAllOrders() {
|
||||||
if (!queryInput.value.trim()) return;
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
traceList.value = [];
|
|
||||||
orderInfo.value = null;
|
|
||||||
queried.value = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let orderId = parseInt(queryInput.value, 10);
|
// Load orders that are in logistics stages
|
||||||
|
const statuses = ['pending_factory', 'pending_driver', 'accepted', 'picked_up', 'delivered', 'production', 'shipped'];
|
||||||
|
const allOrders = [];
|
||||||
|
|
||||||
// If not a number, try searching by order_no
|
for (const status of statuses) {
|
||||||
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 {
|
try {
|
||||||
const resp = await fetch(`${API_BASE}/api/orders/${orderId}`, {
|
const data = await fetchOrderList({ order_status: status, page_size: 100 });
|
||||||
headers: { 'Content-Type': 'application/json', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}) },
|
allOrders.push(...data.rows);
|
||||||
});
|
|
||||||
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 */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch trace
|
// Fetch logistics info for each order
|
||||||
const resp = await fetch(`${API_BASE}/api/logistics/${orderId}/trace`, {
|
const enriched = await Promise.all(allOrders.map(async (order) => {
|
||||||
|
const row = {
|
||||||
|
...order,
|
||||||
|
express_company: null,
|
||||||
|
tracking_number: null,
|
||||||
|
trace_status: 'pending',
|
||||||
|
trace_status_text: '待发货',
|
||||||
|
trace_status_class: 'status-pending',
|
||||||
|
last_update: '-',
|
||||||
|
has_trace: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/api/logistics/tasks?order_id=${order.orderId}`, {
|
||||||
headers: { 'Content-Type': 'application/json', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}) },
|
headers: { 'Content-Type': 'application/json', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}) },
|
||||||
});
|
});
|
||||||
const result = await resp.json();
|
const result = await resp.json();
|
||||||
if (resp.ok && result.code === 0) {
|
if (resp.ok && result.code === 0 && result.data?.list?.length) {
|
||||||
traceList.value = (result.data || {}).trace_list || [];
|
const task = result.data.list[0];
|
||||||
queried.value = true;
|
row.express_company = task.express_company || null;
|
||||||
if (traceList.value.length === 0) {
|
row.tracking_number = task.tracking_number || null;
|
||||||
toast.info('暂无物流轨迹');
|
row.trace_status = mapTaskStatus(task.task_status);
|
||||||
} else {
|
row.trace_status_text = mapTaskStatusText(task.task_status);
|
||||||
toast.success(`查询成功,共 ${traceList.value.length} 条轨迹`);
|
row.trace_status_class = mapTaskStatusClass(task.task_status);
|
||||||
|
row.last_update = task.updated_at || task.created_at || '-';
|
||||||
|
row.has_trace = Boolean(task.tracking_number);
|
||||||
}
|
}
|
||||||
} else {
|
} catch { /* ignore */ }
|
||||||
toast.error(result.message || '查询失败');
|
|
||||||
queried.value = true;
|
return row;
|
||||||
}
|
}));
|
||||||
} catch (err) {
|
|
||||||
toast.error(err.message || '网络请求失败');
|
orderList.value = enriched;
|
||||||
queried.value = true;
|
} catch (e) {
|
||||||
|
toast.error(e.message || '加载物流数据失败');
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
function mapTaskStatus(status) {
|
||||||
const orderIdParam = route.query.order_id;
|
const map = { pending: 'pending', assigned: 'in_transit', in_transit: 'in_transit', delivered: 'delivered', completed: 'delivered' };
|
||||||
if (orderIdParam) {
|
return map[status] || 'pending';
|
||||||
queryInput.value = orderIdParam;
|
}
|
||||||
queryTrace();
|
|
||||||
|
function mapTaskStatusText(status) {
|
||||||
|
const map = { pending: '待分配', assigned: '已分配', in_transit: '运输中', delivered: '已送达', completed: '已完成' };
|
||||||
|
return map[status] || status || '待发货';
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapTaskStatusClass(status) {
|
||||||
|
const map = { pending: 'status-pending', assigned: 'status-transit', in_transit: 'status-transit', delivered: 'status-delivered', completed: 'status-delivered' };
|
||||||
|
return map[status] || 'status-pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTraceModal(row) {
|
||||||
|
traceModalData.value = row;
|
||||||
|
traceList.value = [];
|
||||||
|
showTraceModal.value = true;
|
||||||
|
|
||||||
|
if (!row.has_trace) return;
|
||||||
|
|
||||||
|
traceLoading.value = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/api/logistics/${row.orderId}/trace`, {
|
||||||
|
headers: { 'Content-Type': 'application/json', ...(getToken() ? { Authorization: `Bearer ${getToken()}` } : {}) },
|
||||||
|
});
|
||||||
|
const result = await resp.json();
|
||||||
|
if (resp.ok && result.code === 0) {
|
||||||
|
traceList.value = (result.data || {}).trace_list || [];
|
||||||
}
|
}
|
||||||
});
|
} catch { /* ignore */ }
|
||||||
|
finally { traceLoading.value = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTraceModal() {
|
||||||
|
showTraceModal.value = false;
|
||||||
|
traceList.value = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSearch() { /* filters are reactive, auto-applied */ }
|
||||||
|
function handleReset() {
|
||||||
|
filters.keyword = '';
|
||||||
|
filters.trace_status = '';
|
||||||
|
filters.express_company = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadAllOrders);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page{padding:0;display:grid;gap:16px}
|
.page { padding: 0; display: grid; gap: 16px; }
|
||||||
.page-header{display:flex;justify-content:space-between;gap:12px;align-items:center}
|
.page-header { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
|
||||||
.subline{margin:0;color:#64748b}
|
.page-header h2 { margin: 0 0 4px; font-size: 24px; }
|
||||||
.back-link{padding:10px 14px;border:1px solid #cbd5e1;border-radius:12px;text-decoration:none;background:#fff;color:#334155}
|
.subline { margin: 0; color: #64748b; font-size: 14px; }
|
||||||
.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}
|
.panel { border: 1px solid #dbe3ef; border-radius: 18px; padding: 20px; background: #fff; }
|
||||||
.query-row{display:flex;gap:12px;align-items:flex-end}
|
.panel-header { display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 16px; }
|
||||||
.query-row label{display:flex;flex-direction:column;gap:4px;flex:1;max-width:400px}
|
.panel-header h3 { margin: 0; font-size: 16px; }
|
||||||
.query-row label span{font-size:13px;color:#64748b}
|
.panel-header span { color: #64748b; font-size: 13px; }
|
||||||
.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}
|
.toolbar { display: grid; gap: 12px; align-items: end; }
|
||||||
.primary-btn:disabled{opacity:0.5;cursor:not-allowed}
|
.toolbar-4 { grid-template-columns: repeat(3, minmax(0, 1fr)) auto; }
|
||||||
.info-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}
|
.toolbar label { display: grid; gap: 6px; }
|
||||||
.info-grid article{padding:12px;border-radius:14px;background:#f8fbff;border:1px solid #dbe3ef}
|
.toolbar span { color: #475569; font-size: 13px; font-weight: 600; }
|
||||||
.info-grid p{display:block;color:#64748b;margin:0 0 8px}
|
.toolbar input, .toolbar select { border: 1px solid #d1d5db; border-radius: 10px; padding: 10px 12px; background: #fff; font-size: 14px; }
|
||||||
.info-grid strong{font-size:16px}
|
.toolbar-actions { display: flex; gap: 10px; align-items: end; }
|
||||||
.trace-timeline{position:relative;padding-left:24px}
|
|
||||||
.trace-item{position:relative;padding-bottom:20px}
|
.table-wrap { overflow-x: auto; }
|
||||||
.trace-dot{position:absolute;left:-24px;top:4px;width:12px;height:12px;border-radius:50%;background:#cbd5e1;border:2px solid #fff;z-index:1}
|
table { width: 100%; border-collapse: collapse; min-width: 800px; }
|
||||||
.trace-dot.active{background:#2563eb}
|
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid #e5e7eb; }
|
||||||
.trace-connector{position:absolute;left:-19px;top:16px;width:2px;height:calc(100% - 12px);background:#e5e7eb}
|
th { background: #f8fafc; font-size: 13px; font-weight: 600; color: #334155; white-space: nowrap; }
|
||||||
.trace-card{padding:12px 16px;border:1px solid #dbe3ef;border-radius:14px;background:#f8fbff}
|
td { font-size: 14px; }
|
||||||
.trace-time{font-size:13px;color:#64748b;margin-bottom:4px}
|
|
||||||
.trace-desc{font-size:15px;color:#1e293b;line-height:1.5}
|
.status-badge { display: inline-flex; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||||
.trace-source{display:inline-block;margin-top:6px;padding:2px 8px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:11px}
|
.status-pending { background: #fef3c7; color: #92400e; }
|
||||||
.trace-location{display:inline-block;margin-top:6px;margin-left:8px;font-size:12px;color:#64748b}
|
.status-transit { background: #dbeafe; color: #1d4ed8; }
|
||||||
.empty-state{text-align:center;padding:32px 16px;color:#64748b}
|
.status-delivered { background: #dcfce7; color: #166534; }
|
||||||
.empty-state p{margin:0 0 8px}
|
.status-exception { background: #fee2e2; color: #b91c1c; }
|
||||||
|
|
||||||
|
.link-btn { background: none; border: none; color: #2563eb; cursor: pointer; font-size: 13px; padding: 4px 8px; }
|
||||||
|
.no-trace { color: #9ca3af; font-size: 13px; }
|
||||||
|
|
||||||
|
.loading { margin: 0; color: #6b7280; text-align: center; padding: 20px; }
|
||||||
|
.empty-state { padding: 32px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; text-align: center; }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.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); backdrop-filter: blur(2px); }
|
||||||
|
.trace-modal { width: min(640px, 100%); max-height: 85vh; overflow: auto; border-radius: 20px; border: 1px solid #dbe3ef; 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; color: #334155; }
|
||||||
|
|
||||||
|
.trace-summary { display: flex; gap: 20px; margin-bottom: 16px; padding: 12px 16px; background: #f8fafc; border-radius: 12px; font-size: 13px; color: #475569; }
|
||||||
|
|
||||||
|
.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; }
|
||||||
|
.trace-dot.active { background: #2563eb; }
|
||||||
|
.trace-connector { position: absolute; left: -19px; top: 16px; width: 2px; height: calc(100% - 12px); background: #e5e7eb; }
|
||||||
|
.trace-card { padding: 12px 16px; border: 1px solid #dbe3ef; border-radius: 14px; background: #f8fbff; }
|
||||||
|
.trace-time { font-size: 13px; color: #64748b; margin-bottom: 4px; }
|
||||||
|
.trace-desc { font-size: 15px; color: #1e293b; line-height: 1.5; }
|
||||||
|
.trace-source { display: inline-block; margin-top: 6px; padding: 2px 8px; border-radius: 999px; background: #eff6ff; color: #1d4ed8; font-size: 11px; }
|
||||||
|
.trace-location { display: inline-block; margin-top: 6px; margin-left: 8px; font-size: 12px; color: #64748b; }
|
||||||
|
|
||||||
|
.ghost-btn, .primary-btn { border-radius: 10px; border: 1px solid #d1d5db; padding: 8px 14px; background: #fff; font-size: 13px; cursor: pointer; color: #334155; }
|
||||||
|
.primary-btn { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||||
|
|
||||||
|
@media (max-width: 780px) { .toolbar { grid-template-columns: 1fr; } .page-header { flex-direction: column; align-items: flex-start; } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -214,5 +214,89 @@ onMounted(() => { handleSearch(); });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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}.status{display:inline-flex;align-items:center;padding:4px 10px;border-radius:999px;background:#eff6ff;color:#1d4ed8;font-size:12px;font-weight:600}.summary-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px}.summary-card,.panel{border:1px solid #dbe3ef;border-radius:18px;padding:16px;background:#fff}.summary-card span{display:block;color:#64748b;margin-bottom:8px}.summary-card strong{font-size:24px}.split-layout{display:grid;grid-template-columns:1fr 1.2fr;gap:16px}.panel-header{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:12px}.panel-header h3{margin:0}.panel-header span{color:#64748b;font-size:13px}.form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.full-width{grid-column:1/-1}.toolbar{display:grid;gap:12px;margin-bottom:16px;align-items:end}.toolbar-4{grid-template-columns:repeat(3,minmax(0,1fr)) auto}.toolbar label{display:grid;gap:6px}.toolbar span{color:#475569;font-size:13px;font-weight:600}.toolbar-actions{display:flex;gap:10px;align-items:end}.table-wrap{overflow:auto}.actions{display:flex;gap:8px;flex-wrap:wrap}.money{font-weight:700}.loading{margin:0;color:#6b7280}.empty-state{padding:24px;border:1px dashed #cbd5e1;border-radius:16px;color:#64748b;text-align:center}.link-btn,.ghost-btn,.primary-btn,input,select,textarea{border-radius:12px;border:1px solid #cbd5e1;padding:10px 12px;background:#fff;font-size:14px}.primary-btn{background:#2563eb;color:#fff;border-color:#2563eb}.ghost-btn,.link-btn{background:#f8fafc;color:#334155}.link-btn{cursor:pointer;padding:6px 10px}.link-btn:disabled{color:#9ca3af;cursor:not-allowed}.action-row{display:flex;justify-content:flex-end;gap:10px;margin-top:14px}.action-row.split{justify-content:space-between}.rule-note{margin-top:12px;padding:12px;border:1px solid #dbe3ef;border-radius:14px;background:#f8fbff}.rule-note strong{display:block;margin-bottom:4px}.rule-note p{margin:0;color:#475569;line-height:1.6}.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);backdrop-filter:blur(2px)}.modal-panel{width:min(560px,100%);max-height:min(80vh,600px);overflow:auto;border-radius:20px;border:1px solid #dbe3ef;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;line-height:1;color:#334155;border-radius:12px;cursor:pointer}@media(max-width:980px){.split-layout{grid-template-columns:1fr}.toolbar,.form-grid{grid-template-columns:1fr}.page-header,.panel-header{flex-direction:column;align-items:flex-start}}
|
.page { padding: 0; display: grid; gap: 16px; }
|
||||||
|
.page-header { display: flex; justify-content: space-between; gap: 12px; align-items: center; }
|
||||||
|
.page-header h2 { margin: 0 0 4px; font-size: 24px; }
|
||||||
|
.subline { margin: 0; color: #64748b; font-size: 14px; }
|
||||||
|
|
||||||
|
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
|
||||||
|
.summary-card {
|
||||||
|
border: 1px solid #dbe3ef; border-radius: 16px; padding: 18px 20px; background: #fff;
|
||||||
|
display: grid; gap: 6px;
|
||||||
|
}
|
||||||
|
.summary-card span { color: #64748b; font-size: 13px; }
|
||||||
|
.summary-card strong { font-size: 26px; color: #0f172a; margin: 0; }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
border: 1px solid #dbe3ef; border-radius: 18px; padding: 20px; background: #fff;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
display: flex; justify-content: space-between; gap: 12px; align-items: center; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.panel-header h3 { margin: 0; font-size: 16px; }
|
||||||
|
.panel-header span { color: #64748b; font-size: 13px; }
|
||||||
|
|
||||||
|
.toolbar { display: grid; gap: 12px; margin-bottom: 16px; align-items: end; }
|
||||||
|
.toolbar-4 { grid-template-columns: repeat(3, minmax(0, 1fr)) auto; }
|
||||||
|
.toolbar label { display: grid; gap: 6px; }
|
||||||
|
.toolbar span { color: #475569; font-size: 13px; font-weight: 600; }
|
||||||
|
.toolbar input, .toolbar select {
|
||||||
|
border: 1px solid #d1d5db; border-radius: 10px; padding: 10px 12px; background: #fff; font-size: 14px;
|
||||||
|
}
|
||||||
|
.toolbar-actions { display: flex; gap: 10px; align-items: end; }
|
||||||
|
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; min-width: 900px; }
|
||||||
|
th, td { text-align: left; padding: 12px 14px; border-bottom: 1px solid #e5e7eb; }
|
||||||
|
th { background: #f8fafc; font-size: 13px; font-weight: 600; color: #334155; white-space: nowrap; }
|
||||||
|
td { font-size: 14px; }
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-flex; align-items: center; padding: 4px 12px; border-radius: 999px;
|
||||||
|
background: #eff6ff; color: #1d4ed8; font-size: 12px; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.money { font-weight: 700; color: #0f172a; }
|
||||||
|
.actions { display: flex; gap: 8px; flex-wrap: wrap; white-space: nowrap; }
|
||||||
|
|
||||||
|
.link-btn, .ghost-btn, .primary-btn {
|
||||||
|
border-radius: 10px; border: 1px solid #d1d5db; padding: 8px 14px;
|
||||||
|
background: #fff; font-size: 13px; cursor: pointer; text-decoration: none; color: #334155;
|
||||||
|
}
|
||||||
|
.primary-btn { background: #2563eb; color: #fff; border-color: #2563eb; }
|
||||||
|
.link-btn { background: none; border: none; color: #2563eb; padding: 4px 8px; }
|
||||||
|
.link-btn:disabled { color: #9ca3af; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.loading { margin: 0; color: #6b7280; }
|
||||||
|
.empty-state { padding: 32px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; text-align: center; }
|
||||||
|
|
||||||
|
.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); backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
.modal-panel {
|
||||||
|
width: min(560px, 100%); max-height: min(80vh, 600px); overflow: auto;
|
||||||
|
border-radius: 20px; border: 1px solid #dbe3ef; 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; color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 { display: flex; justify-content: flex-end; gap: 10px; margin-top: 14px; }
|
||||||
|
.action-row.split { justify-content: space-between; }
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.toolbar { grid-template-columns: 1fr; }
|
||||||
|
.page-header, .panel-header { flex-direction: column; align-items: flex-start; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user