Files
xzt_data_screen/src/views/OverviewScreenV2.vue

192 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed } from 'vue'
import FreightIcon from '@/components/base/FreightIcon.vue'
import KpiCard from '@/components/base/KpiCard.vue'
import MapRenderer from '@/components/map/MapRenderer.vue'
import TechPanel from '@/components/base/TechPanel.vue'
import type {
FreightOrder, FreightRoute, KpiMetric, MapScene, PlatformSnapshot, RiskAlert,
} from '@/types/freight'
const props = defineProps<{
snapshot: PlatformSnapshot
kpis: KpiMetric[]
routes: FreightRoute[]
orders: readonly FreightOrder[]
alerts: readonly RiskAlert[]
mapScene: MapScene
selectedRouteId: string
}>()
const emit = defineEmits<{ selectRoute: [routeId: string] }>()
const stages = computed(() => [
{ label: '待提货', value: props.snapshot.pendingPickupOrders, icon: 'warehouse' as const, tone: 'gold' as const },
{ label: '运输中', value: props.snapshot.inTransitOrders, icon: 'truck' as const, tone: 'cyan' as const },
{ label: '已完成', value: props.snapshot.completedOrders, icon: 'document' as const, tone: 'green' as const },
])
const featuredRoutes = computed(() => [props.routes[0], props.routes[1], props.routes[2], props.routes[5], props.routes[7]])
function routeName(routeId: string) {
const route = props.routes.find((item) => item.id === routeId)
return route ? `${route.from.name} → ${route.to.name}` : '未知线路'
}
function plate(order: FreightOrder) {
return `样本车 ${order.vehicleId.slice(-3)}`
}
function formatTime(value: string) {
return new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
</script>
<template>
<section class="overview-v2">
<div class="overview-kpis">
<KpiCard v-for="(metric,index) in kpis" :key="metric.label" :metric="metric" :index="index" />
</div>
<div class="overview-layout">
<aside class="overview-side overview-side--left">
<TechPanel title="货源与运力供需" tag="SUPPLY & CAPACITY">
<div class="supply-summary">
<div class="supply-summary__ratio">
<span>实时供需比</span><strong>1 : 1.18</strong><small>运力略充足</small>
</div>
<div class="supply-summary__values">
<div><FreightIcon kind="cargo" :size="37" /><span>待匹配货源</span><b>426</b><small>单</small></div>
<div><FreightIcon kind="truck" tone="green" :size="37" /><span>可调运力</span><b>503</b><small>辆</small></div>
</div>
</div>
<div class="supply-lines">
<div v-for="item in [['蒙陕晋',88,81],['云贵川',79,72],['港口钢厂',67,76]]" :key="String(item[0])">
<span>{{ item[0] }}</span>
<i><b :style="{ width: `${item[1]}%` }" /></i>
<i class="capacity-line"><b :style="{ width: `${item[2]}%` }" /></i>
</div>
<footer><span><i />货源指数</span><span><i />运力指数</span></footer>
</div>
</TechPanel>
<TechPanel title="今日订单节奏" tag="HOURLY ORDERS">
<div class="hourly-chart">
<svg viewBox="0 0 300 120" preserveAspectRatio="none">
<defs><linearGradient id="hourArea" x2="0" y2="1"><stop stop-color="#19d9ff" stop-opacity=".35"/><stop offset="1" stop-color="#19d9ff" stop-opacity="0"/></linearGradient></defs>
<path class="hourly-chart__grid" d="M0 25H300M0 55H300M0 85H300" />
<path class="hourly-chart__area" d="M0 101L0 88 25 92 50 98 75 83 100 57 125 43 150 54 175 38 200 46 225 22 250 12 275 27 300 39V101Z" />
<path class="hourly-chart__line" d="M0 88 25 92 50 98 75 83 100 57 125 43 150 54 175 38 200 46 225 22 250 12 275 27 300 39" />
</svg>
<div class="hourly-chart__labels"><span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>当前</span></div>
<div class="hourly-chart__fact"><span>高峰时段</span><b>18:00–20:00</b><span>峰值</span><b>612 单/时</b></div>
</div>
</TechPanel>
<article class="industry-photo">
<img src="@/assets/stock/coal-road-truck.jpg" alt="满载煤炭的公路运输车辆" />
<div class="industry-photo__shade" />
<header><span>煤炭公路运输场景</span><b>ROAD COAL FREIGHT</b></header>
<div class="industry-photo__metrics">
<div><strong>82.4%</strong><span>煤炭货量占比</span></div>
<div><strong>{{ snapshot.averageWeight }}t</strong><span>平均单重</span></div>
<div><strong>95.8%</strong><span>装货准时率</span></div>
</div>
</article>
</aside>
<main class="overview-center">
<div class="map-panel">
<div class="map-panel__title">全国公路货运实时网络 / NATIONAL ROAD FREIGHT NETWORK</div>
<MapRenderer :scene="mapScene" @select-route="emit('selectRoute', $event)" />
</div>
<div class="overview-center__bottom">
<TechPanel title="实时运单样本" tag="LIVE WAYBILLS">
<div class="live-orders">
<div class="live-orders__head"><span>时间</span><span>运输线路</span><span>货物 / 重量</span><span>承运车辆</span><span>状态</span></div>
<TransitionGroup name="order-live">
<div v-for="order in orders.slice(0,6)" :key="order.id" class="live-orders__row">
<time>{{ formatTime(order.createdAt) }}</time>
<span>{{ routeName(order.routeId) }}</span>
<span>{{ order.cargo }} · {{ order.weight }}t</span>
<span>{{ plate(order) }}</span>
<b>{{ order.status }}</b>
</div>
</TransitionGroup>
</div>
</TechPanel>
<TechPanel title="重点线路运价" tag="LANE PRICE">
<div class="lane-prices">
<button v-for="route in featuredRoutes" :key="route.id" :class="{ active: route.id === selectedRouteId }" @click="emit('selectRoute', route.id)">
<span>{{ route.from.name }} → {{ route.to.name }}</span>
<i><b :style="{ width: `${route.orders / 13}%` }" /></i>
<strong>{{ route.rate.toFixed(2) }}</strong><small>元/吨公里</small>
</button>
</div>
</TechPanel>
</div>
</main>
<aside class="overview-side overview-side--right">
<TechPanel title="今日运单履约" tag="ORDER FULFILLMENT">
<div class="stage-total"><span>有效运单</span><Transition name="data-pop" mode="out-in"><strong :key="snapshot.todayOrders">{{ snapshot.todayOrders.toLocaleString() }}</strong></Transition><small>单</small></div>
<div class="order-stages">
<div v-for="stage in stages" :key="stage.label">
<FreightIcon :kind="stage.icon" :tone="stage.tone" :size="42" />
<span>{{ stage.label }}</span><Transition name="data-pop" mode="out-in"><b :key="stage.value">{{ stage.value.toLocaleString() }}</b></Transition>
<small>{{ (stage.value / snapshot.todayOrders * 100).toFixed(1) }}%</small>
</div>
</div>
</TechPanel>
<TechPanel title="结算与开票进度" tag="SETTLEMENT">
<div class="settlement-amount">
<FreightIcon kind="money" tone="gold" :size="48" />
<div><span>今日应付运费</span><Transition name="data-pop" mode="out-in"><b :key="snapshot.freightAmount">{{ (snapshot.freightAmount / 10_000).toFixed(0) }}</b></Transition><small>万元</small></div>
</div>
<div class="settlement-bars">
<div><span>已完成结算</span><i><b :style="{ width: `${snapshot.settlementRate}%` }" /></i><strong>{{ snapshot.settlementRate }}%</strong></div>
<div><span>已申请开票</span><i><b :style="{ width: `${snapshot.invoiceRate}%` }" /></i><strong>{{ snapshot.invoiceRate }}%</strong></div>
<div><span>电子回单上传</span><i><b :style="{ width: `${snapshot.receiptRate}%` }" /></i><strong>{{ snapshot.receiptRate }}%</strong></div>
</div>
</TechPanel>
<TechPanel title="在途异常" tag="TRANSIT EXCEPTION">
<div class="exception-summary">
<div><strong>{{ (snapshot.todayOrders * snapshot.abnormalRate / 100).toFixed(0) }}</strong><span>今日异常运单</span></div>
<div><strong>{{ snapshot.abnormalRate }}%</strong><span>异常运单率</span></div>
</div>
<div class="compact-alerts">
<button v-for="alert in alerts.slice(0,4)" :key="alert.id" @click="emit('selectRoute', alert.routeId)">
<i :class="`level-${alert.level}`" /><span>{{ alert.type }}</span><b>{{ routeName(alert.routeId) }}</b><time>{{ formatTime(alert.time) }}</time>
</button>
</div>
</TechPanel>
</aside>
</div>
</section>
</template>
<style scoped>
.overview-v2{height:968px;padding-top:7px;position:relative;z-index:1;animation:screenInOverview .5s ease both}.overview-kpis{height:82px;display:grid;grid-template-columns:repeat(5,1fr);gap:10px}.overview-layout{height:858px;margin-top:8px;display:grid;grid-template-columns:320px 1fr 320px;gap:10px}.overview-side{display:grid;gap:9px;min-height:0}.overview-side--left{grid-template-rows:260px 230px 1fr}.overview-side--right{grid-template-rows:270px 267px 1fr}.overview-center{display:grid;grid-template-rows:590px 259px;gap:9px;min-width:0}.map-panel{position:relative;overflow:hidden;border:1px solid rgba(25,217,255,.12);background:rgba(2,15,26,.22)}.map-panel__title{position:absolute;z-index:6;left:17px;top:12px;color:#4ca6bd;font:600 10px Rajdhani;letter-spacing:3px}.overview-center__bottom{display:grid;grid-template-columns:1.35fr 1fr;gap:9px;min-height:0}
.supply-summary{height:108px;display:grid;grid-template-columns:1fr 1.4fr;gap:10px}.supply-summary__ratio{display:grid;place-content:center;text-align:center;border-right:1px solid rgba(25,217,255,.13)}.supply-summary__ratio span{color:#7199a8;font-size:9px}.supply-summary__ratio strong{color:var(--gold);font:600 24px Rajdhani}.supply-summary__ratio small{color:#55dcae;font-size:8px}.supply-summary__values{display:grid;gap:5px}.supply-summary__values>div{display:grid;grid-template-columns:42px 1fr auto auto;align-items:center;gap:4px}.supply-summary__values span{font-size:8px;color:#779fac}.supply-summary__values b{font:600 17px Rajdhani;color:#d9f9ff}.supply-summary__values small{font-size:7px;color:#658c99}.supply-lines>div{display:grid;grid-template-columns:32px 1fr 1fr;gap:6px;align-items:center;height:24px;font-size:8px;color:#7196a4}.supply-lines>div>i{height:4px;background:rgba(25,217,255,.08)}.supply-lines>div>i b{display:block;height:100%;background:#19d9ff}.supply-lines>div>i.capacity-line b{background:#4fe3b1}.supply-lines footer{display:flex;justify-content:flex-end;gap:14px;margin-top:5px;color:#527886;font-size:7px}.supply-lines footer i{display:inline-block;width:6px;height:3px;margin-right:3px;background:#19d9ff}.supply-lines footer span:last-child i{background:#4fe3b1}
.hourly-chart{height:100%;position:relative}.hourly-chart svg{width:100%;height:115px}.hourly-chart__grid{fill:none;stroke:rgba(77,153,177,.1)}.hourly-chart__area{fill:url(#hourArea)}.hourly-chart__line{fill:none;stroke:#27ddf3;stroke-width:2;filter:drop-shadow(0 0 4px #19d9ff)}.hourly-chart__labels{display:flex;justify-content:space-between;color:#4d7786;font:8px Rajdhani}.hourly-chart__fact{display:grid;grid-template-columns:auto auto auto auto;gap:5px;margin-top:12px;padding:8px;background:rgba(8,49,68,.28);font-size:8px;color:#658d9b}.hourly-chart__fact b{color:#d5f8ff}
.industry-photo{position:relative;overflow:hidden;border:1px solid rgba(25,217,255,.19);min-height:0}.industry-photo img{width:100%;height:100%;object-fit:cover;filter:saturate(.45) contrast(1.1)}.industry-photo__shade{position:absolute;inset:0;background:linear-gradient(180deg,rgba(2,14,24,.25),rgba(2,15,25,.9)),linear-gradient(90deg,rgba(5,59,79,.48),transparent)}.industry-photo header{position:absolute;left:14px;top:14px}.industry-photo header span{display:block;font-size:13px;letter-spacing:2px}.industry-photo header b{color:#4b91a5;font:9px Rajdhani;letter-spacing:2px}.industry-photo__metrics{position:absolute;left:12px;right:12px;bottom:12px;display:grid;grid-template-columns:repeat(3,1fr);background:rgba(3,18,29,.76);border-top:1px solid rgba(25,217,255,.24)}.industry-photo__metrics div{padding:10px 5px;text-align:center;border-right:1px solid rgba(25,217,255,.09)}.industry-photo__metrics strong{display:block;color:#6be5ef;font:600 16px Rajdhani}.industry-photo__metrics span{color:#7196a3;font-size:7px}
.live-orders__head,.live-orders__row{display:grid;grid-template-columns:55px 1.25fr 1fr 78px 48px;gap:7px;align-items:center;height:35px;font-size:8px}.live-orders__head{height:27px;color:#477788;border-bottom:1px solid rgba(25,217,255,.13)}.live-orders__row{border-bottom:1px solid rgba(25,217,255,.06);color:#83aab7}.live-orders__row time{font-family:Rajdhani;color:#52cce0}.live-orders__row b{color:#4fe3b1;font-weight:400}.lane-prices button{width:100%;height:37px;display:grid;grid-template-columns:1fr 62px 30px 53px;gap:5px;align-items:center;border:0;border-bottom:1px solid rgba(25,217,255,.06);background:transparent;color:#86aab6;text-align:left;font-size:8px;cursor:pointer}.lane-prices button.active{background:linear-gradient(90deg,rgba(255,200,87,.1),transparent);color:#fff}.lane-prices button>i{height:3px;background:rgba(25,217,255,.08)}.lane-prices button>i b{display:block;height:100%;background:linear-gradient(90deg,#167a9c,#25dff0)}.lane-prices button strong{color:var(--gold);font:600 12px Rajdhani}.lane-prices button small{color:#5d8391;font-size:7px}
.stage-total{text-align:center;height:62px;padding:5px;border-bottom:1px solid rgba(25,217,255,.1)}.stage-total span{display:block;color:#6f98a6;font-size:8px}.stage-total strong{color:#d9faff;font:600 25px Rajdhani}.stage-total small{margin-left:4px;color:#628c99;font-size:8px}.order-stages{display:grid;grid-template-columns:repeat(3,1fr);gap:5px;padding-top:15px}.order-stages>div{text-align:center;border-right:1px solid rgba(25,217,255,.08)}.order-stages :deep(.freight-icon){margin:0 auto 10px}.order-stages span{display:block;color:#7ba3af;font-size:8px}.order-stages b{display:block;color:#e1faff;font:600 18px Rajdhani}.order-stages small{color:#587f8c;font:8px Rajdhani}
.settlement-amount{height:83px;display:flex;align-items:center;justify-content:center;gap:17px;border-bottom:1px solid rgba(25,217,255,.09)}.settlement-amount span{display:block;color:#7198a6;font-size:8px}.settlement-amount b{color:var(--gold);font:600 25px Rajdhani}.settlement-amount small{margin-left:3px;color:#668d99;font-size:8px}.settlement-bars{padding-top:10px}.settlement-bars>div{display:grid;grid-template-columns:72px 1fr 34px;gap:6px;align-items:center;height:31px;font-size:8px;color:#759ca9}.settlement-bars i{height:4px;background:rgba(25,217,255,.08)}.settlement-bars i b{display:block;height:100%;background:linear-gradient(90deg,#147b9e,#43dfc1)}.settlement-bars strong{color:#58d9c0;font:10px Rajdhani}.exception-summary{height:65px;display:grid;grid-template-columns:1fr 1fr}.exception-summary div{text-align:center;padding:8px;border-right:1px solid rgba(25,217,255,.08);background:rgba(255,65,93,.035)}.exception-summary strong{display:block;color:#ff7d8f;font:600 21px Rajdhani}.exception-summary span{color:#7b929b;font-size:8px}.compact-alerts button{width:100%;height:37px;display:grid;grid-template-columns:5px 62px 1fr 34px;gap:6px;align-items:center;border:0;border-bottom:1px solid rgba(25,217,255,.05);background:transparent;color:#82a5b1;text-align:left;font-size:8px;cursor:pointer}.compact-alerts button>i{width:3px;height:18px;background:#ffc857}.compact-alerts button>i.level-高{background:#ff415d;box-shadow:0 0 6px #ff415d}.compact-alerts button b{font-weight:400;color:#a8cbd4}.compact-alerts time{font:8px Rajdhani;color:#537b89}
@keyframes screenInOverview{from{opacity:0;transform:translateX(24px);filter:blur(3px)}}
.overview-v2{animation:none}.hourly-chart__line{filter:none}.industry-photo img{object-fit:contain;object-position:center center;background:#020c13}
.stage-total{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center}
.order-stages{height:145px;align-items:stretch;padding-top:8px}
.order-stages>div{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;border-right:0}
.order-stages :deep(.freight-icon){display:grid;place-items:center;margin:0 auto 10px}
.settlement-amount{display:grid;grid-template-columns:48px auto;place-content:center;align-items:center;text-align:center}
.settlement-amount>div{min-width:92px;text-align:center}
.data-pop-enter-active,.data-pop-leave-active{display:inline-block;transition:opacity .2s ease,transform .2s ease,filter .2s ease}.data-pop-enter-from{opacity:0;transform:translateY(7px) scale(.92);filter:brightness(2)}.data-pop-leave-to{opacity:0;transform:translateY(-7px) scale(1.08)}
.supply-lines>div{grid-template-columns:42px 1fr 1fr}
.order-live-move,.order-live-enter-active,.order-live-leave-active{transition:all .38s ease}.order-live-enter-from{opacity:0;transform:translateX(-18px);background:rgba(25,217,255,.18)}.order-live-leave-to{opacity:0;transform:translateX(18px)}.order-live-leave-active{position:absolute}
.live-orders__head{height:25px}.live-orders__row{height:31px}.lane-prices button{height:41px;grid-template-columns:92px 1fr 30px 53px}.lane-prices button>i{width:100%}
</style>