Compare commits

...

3 Commits

Author SHA1 Message Date
276a3c0017 fix: 物流管理页面修复 reactive import 缺失导致白屏
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 14:12:42 +08:00
8b2c154284 fix: 修复订单详情跳转物流轨迹的路由路径错误
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 13:52:04 +08:00
9650c891d9 fix: 优化订单中心页面样式,表格增加最小宽度和间距
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-05 13:28:38 +08:00
3 changed files with 373 additions and 156 deletions

View File

@ -116,7 +116,7 @@
<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>
<RouterLink v-if="order.rawStatus" class="ghost-btn" :to="`/orders/tracking?order_id=${route.params.id}`">查看物流轨迹</RouterLink>
</div>
</section>

View File

@ -1,76 +1,131 @@
<!--
OrderTrackingPage.vue - 物流轨迹查询页面
职责通过订单号或订单ID查询快递100物流轨迹以时间线形式展示
支持订单号自动转换为订单ID查询显示关联订单信息
OrderTrackingPage.vue - 物流管理页面
职责展示所有订单的物流状态总览支持按状态/快递公司/订单号筛选
点击"查看轨迹"弹窗显示该订单的物流时间线
-->
<template>
<section class="page tracking-page">
<div class="page-header">
<div>
<h2>物流轨迹查询</h2>
<p class="subline">输入订单号或订单ID查询物流轨迹</p>
<h2>物流管理</h2>
<p class="subline">查看所有订单的物流状态点击查看详情</p>
</div>
<RouterLink class="back-link" to="/orders">返回订单中心</RouterLink>
</div>
<!-- 查询表单 -->
<!-- 筛选工具栏 -->
<section class="panel">
<div class="panel-header"><h3>查询轨迹</h3></div>
<div class="query-row">
<div class="panel-header"><h3>物流列表</h3><span> {{ filteredList.length }} </span></div>
<section class="toolbar toolbar-4">
<label>
<span>订单号 / 订单ID</span>
<input v-model.trim="queryInput" type="text" placeholder="输入订单号(如 SO20260601001或订单ID" @keyup.enter="queryTrace" />
<span>订单号 / 客户名</span>
<input v-model.trim="filters.keyword" type="text" placeholder="搜索订单号或客户名" />
</label>
<button class="primary-btn" :disabled="!queryInput || loading" @click="queryTrace">{{ loading ? '查询中...' : '查询' }}</button>
</div>
<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>
</section>
</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>
<!-- 物流列表 -->
<p v-if="loading" class="loading">正在加载物流数据...</p>
<div v-else-if="!filteredList.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>
</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>
<!-- 轨迹时间线 -->
<section v-if="traceList.length" class="panel">
<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 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">{{ item.source_platform }}</span>
<span v-if="item.remark" class="trace-location">{{ item.remark }}</span>
<!-- 轨迹弹窗 -->
<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">&times;</button>
</div>
<!-- 订单物流摘要 -->
<div class="trace-summary">
<span>快递公司{{ traceModalData.express_company || '-' }}</span>
<span>快递单号{{ traceModalData.tracking_number || '-' }}</span>
</div>
<!-- 加载中 -->
<p v-if="traceLoading" class="loading">正在查询轨迹...</p>
<!-- 轨迹时间线 -->
<div v-else-if="traceList.length" 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">{{ item.source_platform }}</span>
<span v-if="item.remark" class="trace-location">{{ item.remark }}</span>
</div>
</div>
</div>
</div>
</section>
<!-- 空状态 -->
<section v-if="!traceList.length && !loading && queried" class="panel">
<div class="empty-state">
<p>该订单暂无物流轨迹记录</p>
<p class="subline">可能原因订单未创建物流任务未填写快递单号或快递100尚未获取到轨迹</p>
</div>
</section>
<!-- 无轨迹 -->
<div v-else class="empty-state">
<p>暂无物流轨迹</p>
<p class="subline">可能原因未填写快递单号或快递100尚未获取到轨迹</p>
</div>
</section>
</div>
</section>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useRoute, RouterLink } from 'vue-router';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useToast } from '../composables/useToast';
import { fetchOrderList } from '../mockApi';
@ -78,128 +133,206 @@ const route = useRoute();
const toast = useToast();
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 loading = ref(false);
const queried = ref(false);
const orderInfo = ref(null);
const traceModalData = ref({});
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() {
return localStorage.getItem('admin_token') || '';
}
async function queryTrace() {
if (!queryInput.value.trim()) return;
async function loadAllOrders() {
loading.value = true;
traceList.value = [];
orderInfo.value = null;
queried.value = false;
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
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) {
for (const status of statuses) {
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,
};
}
const data = await fetchOrderList({ order_status: status, page_size: 100 });
allOrders.push(...data.rows);
} catch { /* ignore */ }
}
// Fetch trace
const resp = await fetch(`${API_BASE}/api/logistics/${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 || [];
queried.value = true;
if (traceList.value.length === 0) {
toast.info('暂无物流轨迹');
} else {
toast.success(`查询成功,共 ${traceList.value.length} 条轨迹`);
}
} else {
toast.error(result.message || '查询失败');
queried.value = true;
}
} catch (err) {
toast.error(err.message || '网络请求失败');
queried.value = true;
// Fetch logistics info for each order
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()}` } : {}) },
});
const result = await resp.json();
if (resp.ok && result.code === 0 && result.data?.list?.length) {
const task = result.data.list[0];
row.express_company = task.express_company || null;
row.tracking_number = task.tracking_number || null;
row.trace_status = mapTaskStatus(task.task_status);
row.trace_status_text = mapTaskStatusText(task.task_status);
row.trace_status_class = mapTaskStatusClass(task.task_status);
row.last_update = task.updated_at || task.created_at || '-';
row.has_trace = Boolean(task.tracking_number);
}
} catch { /* ignore */ }
return row;
}));
orderList.value = enriched;
} catch (e) {
toast.error(e.message || '加载物流数据失败');
} finally {
loading.value = false;
}
}
onMounted(() => {
const orderIdParam = route.query.order_id;
if (orderIdParam) {
queryInput.value = orderIdParam;
queryTrace();
}
});
function mapTaskStatus(status) {
const map = { pending: 'pending', assigned: 'in_transit', in_transit: 'in_transit', delivered: 'delivered', completed: 'delivered' };
return map[status] || 'pending';
}
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>
<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;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: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}
.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}
.empty-state{text-align:center;padding:32px 16px;color:#64748b}
.empty-state p{margin:0 0 8px}
.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; }
.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; 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: 800px; }
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-badge { display: inline-flex; padding: 4px 12px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; }
.status-pending { background: #fef3c7; color: #92400e; }
.status-transit { background: #dbeafe; color: #1d4ed8; }
.status-delivered { background: #dcfce7; color: #166534; }
.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>

View File

@ -214,5 +214,89 @@ onMounted(() => { handleSearch(); });
</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}.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>