@ -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 : '' } ) ;
/ / T r a c e m o d a l
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 ) ;
/ / L o a d o r d e r s t h a t a r e i n l o g i s t i c s s t a g e s
const statuses = [ 'pending_factory' , 'pending_driver' , 'accepted' , 'picked_up' , 'delivered' , 'production' , 'shipped' ] ;
const allOrders = [ ] ;
/ / I f n o t a n u m b e r , t r y s e a r c h i n g b y o r d e r _ n o
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 ;
}
}
/ / F e t c h o r d e r d e t a i l f o r i n f o
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 */ }
}
/ / F e t c h t r a c e
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 ;
/ / F e t c h l o g i s t i c s i n f o f o r e a c h o r d e r
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 : 16 px }
. page - header { display : flex ; justify - content : space - between ; gap : 12 px ; align - items : center }
. subline { margin : 0 ; color : # 64748 b }
. back - link { padding : 10 px 14 px ; border : 1 px solid # cbd5e1 ; border - radius : 12 px ; text - decoration : none ; background : # fff ; color : # 334155 }
. panel { border : 1 px solid # dbe3ef ; border - radius : 18 px ; padding : 16 px ; background : # fff }
. panel - header { display : flex ; justify - content : space - between ; gap : 12 px ; align - items : center ; margin - bottom : 12 px }
. query - row { display : flex ; gap : 12 px ; align - items : flex - end }
. query - row label { display : flex ; flex - direction : column ; gap : 4 px ; flex : 1 ; max - width : 400 px }
. query - row label span { font - size : 13 px ; color : # 64748 b }
. query - row input , . ghost - btn , . primary - btn { border - radius : 12 px ; border : 1 px solid # cbd5e1 ; padding : 10 px 14 px ; background : # fff }
. primary - btn { background : # 2563 eb ; color : # fff ; border - color : # 2563 eb }
. primary - btn : disabled { opacity : 0.5 ; cursor : not - allowed }
. info - grid { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 160 px , 1 fr ) ) ; gap : 12 px }
. info - grid article { padding : 12 px ; border - radius : 14 px ; background : # f8fbff ; border : 1 px solid # dbe3ef }
. info - grid p { display : block ; color : # 64748 b ; margin : 0 0 8 px }
. info - grid strong { font - size : 16 px }
. trace - timeline { position : relative ; padding - left : 24 px }
. trace - item { position : relative ; padding - bottom : 20 px }
. trace - dot { position : absolute ; left : - 24 px ; top : 4 px ; width : 12 px ; height : 12 px ; border - radius : 50 % ; background : # cbd5e1 ; border : 2 px solid # fff ; z - index : 1 }
. trace - dot . active { background : # 2563 eb }
. trace - connector { position : absolute ; left : - 19 px ; top : 16 px ; width : 2 px ; height : calc ( 100 % - 12 px ) ; background : # e5e7eb }
. trace - card { padding : 12 px 16 px ; border : 1 px solid # dbe3ef ; border - radius : 14 px ; background : # f8fbff }
. trace - time { font - size : 13 px ; color : # 64748 b ; margin - bottom : 4 px }
. trace - desc { font - size : 15 px ; color : # 1 e293b ; line - height : 1.5 }
. trace - source { display : inline - block ; margin - top : 6 px ; padding : 2 px 8 px ; border - radius : 999 px ; background : # eff6ff ; color : # 1 d4ed8 ; font - size : 11 px }
. trace - location { display : inline - block ; margin - top : 6 px ; margin - left : 8 px ; font - size : 12 px ; color : # 64748 b }
. empty - state { text - align : center ; padding : 32 px 16 px ; color : # 64748 b }
. empty - state p { margin : 0 0 8 px }
. page { padding : 0 ; display : grid ; gap : 16 px ; }
. page - header { display : flex ; justify - content : space - between ; gap : 12 px ; align - items : center ; }
. page - header h2 { margin : 0 0 4 px ; font - size : 24 px ; }
. subline { margin : 0 ; color : # 64748 b ; font - size : 14 px ; }
. panel { border : 1 px solid # dbe3ef ; border - radius : 18 px ; padding : 20 px ; background : # fff ; }
. panel - header { display : flex ; justify - content : space - between ; gap : 12 px ; align - items : center ; margin - bottom : 16 px ; }
. panel - header h3 { margin : 0 ; font - size : 16 px ; }
. panel - header span { color : # 64748 b ; font - size : 13 px ; }
. toolbar { display : grid ; gap : 12 px ; align - items : end ; }
. toolbar - 4 { grid - template - columns : repeat ( 3 , minmax ( 0 , 1 fr ) ) auto ; }
. toolbar label { display : grid ; gap : 6 px ; }
. toolbar span { color : # 475569 ; font - size : 13 px ; font - weight : 600 ; }
. toolbar input , . toolbar select { border : 1 px solid # d1d5db ; border - radius : 10 px ; padding : 10 px 12 px ; background : # fff ; font - size : 14 px ; }
. toolbar - actions { display : flex ; gap : 10 px ; align - items : end ; }
. table - wrap { overflow - x : auto ; }
table { width : 100 % ; border - collapse : collapse ; min - width : 800 px ; }
th , td { text - align : left ; padding : 12 px 14 px ; border - bottom : 1 px solid # e5e7eb ; }
th { background : # f8fafc ; font - size : 13 px ; font - weight : 600 ; color : # 334155 ; white - space : nowrap ; }
td { font - size : 14 px ; }
. status - badge { display : inline - flex ; padding : 4 px 12 px ; border - radius : 999 px ; font - size : 12 px ; font - weight : 600 ; white - space : nowrap ; }
. status - pending { background : # fef3c7 ; color : # 92400 e ; }
. status - transit { background : # dbeafe ; color : # 1 d4ed8 ; }
. status - delivered { background : # dcfce7 ; color : # 166534 ; }
. status - exception { background : # fee2e2 ; color : # b91c1c ; }
. link - btn { background : none ; border : none ; color : # 2563 eb ; cursor : pointer ; font - size : 13 px ; padding : 4 px 8 px ; }
. no - trace { color : # 9 ca3af ; font - size : 13 px ; }
. loading { margin : 0 ; color : # 6 b7280 ; text - align : center ; padding : 20 px ; }
. empty - state { padding : 32 px ; border : 1 px dashed # cbd5e1 ; border - radius : 16 px ; color : # 64748 b ; text - align : center ; }
/* Modal */
. modal - mask { position : fixed ; inset : 0 ; z - index : 50 ; display : flex ; align - items : center ; justify - content : center ; padding : 24 px ; background : rgba ( 15 , 23 , 42 , 0.48 ) ; backdrop - filter : blur ( 2 px ) ; }
. trace - modal { width : min ( 640 px , 100 % ) ; max - height : 85 vh ; overflow : auto ; border - radius : 20 px ; border : 1 px solid # dbe3ef ; background : # fff ; padding : 24 px ; box - shadow : 0 24 px 80 px rgba ( 15 , 23 , 42 , 0.24 ) ; }
. modal - header { display : flex ; justify - content : space - between ; align - items : flex - start ; gap : 12 px ; margin - bottom : 16 px ; }
. modal - header h4 { margin : 0 0 6 px ; font - size : 18 px ; }
. modal - header span { color : # 64748 b ; font - size : 13 px ; }
. modal - close { width : 36 px ; height : 36 px ; border : 1 px solid # d1d5db ; background : # f8fafc ; font - size : 22 px ; border - radius : 12 px ; cursor : pointer ; color : # 334155 ; }
. trace - summary { display : flex ; gap : 20 px ; margin - bottom : 16 px ; padding : 12 px 16 px ; background : # f8fafc ; border - radius : 12 px ; font - size : 13 px ; color : # 475569 ; }
. trace - timeline { position : relative ; padding - left : 24 px ; }
. trace - item { position : relative ; padding - bottom : 20 px ; }
. trace - dot { position : absolute ; left : - 24 px ; top : 4 px ; width : 12 px ; height : 12 px ; border - radius : 50 % ; background : # cbd5e1 ; border : 2 px solid # fff ; z - index : 1 ; }
. trace - dot . active { background : # 2563 eb ; }
. trace - connector { position : absolute ; left : - 19 px ; top : 16 px ; width : 2 px ; height : calc ( 100 % - 12 px ) ; background : # e5e7eb ; }
. trace - card { padding : 12 px 16 px ; border : 1 px solid # dbe3ef ; border - radius : 14 px ; background : # f8fbff ; }
. trace - time { font - size : 13 px ; color : # 64748 b ; margin - bottom : 4 px ; }
. trace - desc { font - size : 15 px ; color : # 1 e293b ; line - height : 1.5 ; }
. trace - source { display : inline - block ; margin - top : 6 px ; padding : 2 px 8 px ; border - radius : 999 px ; background : # eff6ff ; color : # 1 d4ed8 ; font - size : 11 px ; }
. trace - location { display : inline - block ; margin - top : 6 px ; margin - left : 8 px ; font - size : 12 px ; color : # 64748 b ; }
. ghost - btn , . primary - btn { border - radius : 10 px ; border : 1 px solid # d1d5db ; padding : 8 px 14 px ; background : # fff ; font - size : 13 px ; cursor : pointer ; color : # 334155 ; }
. primary - btn { background : # 2563 eb ; color : # fff ; border - color : # 2563 eb ; }
@ media ( max - width : 780 px ) { . toolbar { grid - template - columns : 1 fr ; } . page - header { flex - direction : column ; align - items : flex - start ; } }
< / style >