6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.vite
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
23
README.md
Normal file
23
README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 网络货运智慧运营中心
|
||||||
|
|
||||||
|
基于 Vue 3、TypeScript、Vite 与 ECharts 构建的 1920×1080 网络货运数据大屏。项目不依赖后台接口,使用关联模拟数据实时推进车辆、运单与风险状态。
|
||||||
|
|
||||||
|
## 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## 检查与构建
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 设计说明
|
||||||
|
|
||||||
|
完整产品定位、指标口径、素材规范与验收标准见 [`docs/网络货运数据大屏规划.md`](docs/网络货运数据大屏规划.md)。
|
||||||
|
|
||||||
|
地图通过 `MapRenderer` 接收统一的 `MapScene` 数据,当前由 ECharts 中国地图渲染全国煤运飞线网络。
|
||||||
23
docs/素材来源.md
Normal file
23
docs/素材来源.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# 数据大屏素材来源
|
||||||
|
|
||||||
|
以下图片均已下载到项目本地,仅用于网络货运场景展示。
|
||||||
|
|
||||||
|
- `src/assets/stock/coal-road-truck.jpg`
|
||||||
|
- 来源:[Wikimedia Commons - Coal Truck Tavan Tolgoi](https://commons.wikimedia.org/wiki/File:Coal_Truck_Tavan_Tolgoi.JPG)
|
||||||
|
- 作者:Brücke-Osteuropa(Dr. Bernd Gross)
|
||||||
|
- 场景:从塔旺陶勒盖煤矿驶往中国的满载公路煤车
|
||||||
|
- 许可:Public Domain
|
||||||
|
- `src/assets/stock/coal-truck-loading.png`
|
||||||
|
- 来源:[Wikimedia Commons - Liebherr T282C Coal Haul Truck](https://commons.wikimedia.org/wiki/File:Liebherr_T282C_Coal_Haul_Truck.png)
|
||||||
|
- 作者:Peabody Energy, Inc.
|
||||||
|
- 场景:采煤装载设备向矿用运输车辆装煤
|
||||||
|
- 许可:[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)
|
||||||
|
|
||||||
|
使用方式:本地静态资源、深色蒙版、裁切为场景背景;不使用第三方远程热链。
|
||||||
|
|
||||||
|
## 小图标
|
||||||
|
|
||||||
|
- 来源:[Font Awesome Free](https://fontawesome.com/)
|
||||||
|
- 安装包:`@fortawesome/vue-fontawesome`、`@fortawesome/free-solid-svg-icons`
|
||||||
|
- 许可:[Font Awesome Free License](https://fontawesome.com/license/free)
|
||||||
|
- 使用方式:由组件库直接渲染,替代项目内手绘 SVG 图标。
|
||||||
138
docs/网络货运数据大屏规划.md
Normal file
138
docs/网络货运数据大屏规划.md
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
# 网络货运智慧运营中心规划
|
||||||
|
|
||||||
|
## 1. 产品定位
|
||||||
|
|
||||||
|
本项目是一套无需后台接口、可离线演示的网络货运数据大屏。画面围绕“货源、运力、运输、履约、风控、效益”展开,用一个 1920×1080 驾驶舱讲清从货源发布到车辆履约的完整业务链路。
|
||||||
|
|
||||||
|
目标观众为平台管理者、企业客户及展示来宾。首版采用阿里 DataV 类工业科技视觉,但不复制具体模板;核心记忆点是中央全国货运态势图、贯穿全屏的线路流光,以及风险发生时地图与告警面板同步响应。
|
||||||
|
|
||||||
|
## 2. 信息架构
|
||||||
|
|
||||||
|
### 顶部全局区
|
||||||
|
|
||||||
|
- 平台累计运单:累计业务规模
|
||||||
|
- 今日运输吨位:当日承运货重
|
||||||
|
- 今日运费金额:当日运单含税运费
|
||||||
|
- 当前在途车辆:处于运输中的去重车辆
|
||||||
|
- 活跃司机:当日有接单、装卸或运输行为的司机
|
||||||
|
- 累计运输里程:已完成及在途运单里程
|
||||||
|
|
||||||
|
### 左侧供给区
|
||||||
|
|
||||||
|
1. 运单量趋势:最近 12 小时发布与完成运单
|
||||||
|
2. 货物类型:钢材、煤炭、建材、农产品、快消、机械、化工
|
||||||
|
3. 运力状态:空闲、待装、装货、运输、待卸、异常
|
||||||
|
4. 车辆构成:高栏、厢式、平板、冷藏、罐式
|
||||||
|
5. 货源城市排行:货源量前五城市
|
||||||
|
|
||||||
|
### 中央态势区
|
||||||
|
|
||||||
|
1. 伪 3D 中国地图与视觉地台
|
||||||
|
2. 重点物流枢纽脉冲
|
||||||
|
3. 城际货运飞线与流动光点
|
||||||
|
4. 车辆沿线路移动
|
||||||
|
5. 省份业务热度与货运光柱
|
||||||
|
6. 当前焦点线路图文浮层
|
||||||
|
|
||||||
|
### 右侧运营区
|
||||||
|
|
||||||
|
1. 实时风险预警
|
||||||
|
2. 准时装货、准时送达、回单上传履约仪表
|
||||||
|
3. 区域经营排行
|
||||||
|
4. 司机行为雷达
|
||||||
|
5. 吨公里运价、空驶率、车辆利用率、等待时长
|
||||||
|
|
||||||
|
### 底部调度区
|
||||||
|
|
||||||
|
1. 实时运单流水
|
||||||
|
2. 热门线路排行
|
||||||
|
3. 智能调度匹配过程
|
||||||
|
4. 节省空驶里程、碳减排、新能源车辆占比
|
||||||
|
|
||||||
|
## 3. 视觉规范
|
||||||
|
|
||||||
|
- 基准画布:1920×1080,整体等比缩放并居中
|
||||||
|
- 背景:`#020913` 至 `#061b2d`,叠加道路网格、噪点和径向辉光
|
||||||
|
- 主色:青蓝 `#19d9ff`,用于常规数据与线路
|
||||||
|
- 辅色:深蓝 `#1668ff`、紫青 `#6af2e7`
|
||||||
|
- 强调:金色 `#ffc857`,用于金额、重点线路和排名
|
||||||
|
- 风险:橙色 `#ff8a34`、红色 `#ff415d`
|
||||||
|
- 字体:标题使用窄体科技字族回退,正文使用中文黑体回退;数字使用等宽字族
|
||||||
|
- 面板:透明深蓝底、切角、细描边、四角短线与顶部扫描光,不使用通用圆角白卡
|
||||||
|
- 动画:首屏分层进入 800–1400ms;飞线 3–6s;扫描 6–10s;告警呼吸 1.8s
|
||||||
|
- 降级:系统开启减少动态效果时,停止粒子、扫描与循环位移动画
|
||||||
|
|
||||||
|
## 4. 数据模型与口径
|
||||||
|
|
||||||
|
### 运单
|
||||||
|
|
||||||
|
字段包括运单号、货物、重量、起点、终点、距离、单价、运费、车辆、司机、状态、计划及实际时间。
|
||||||
|
|
||||||
|
- 运费 = 重量 × 距离 × 吨公里单价
|
||||||
|
- 状态为待调度、待装、运输中、待卸、已完成、异常
|
||||||
|
- 总运单 = 各状态运单之和
|
||||||
|
|
||||||
|
### 车辆与司机
|
||||||
|
|
||||||
|
车辆字段包括车牌、车型、载重、状态、当前位置、线路进度和新能源标记。司机字段包括姓名、评分、接单数、准时率、安全分和活跃状态。
|
||||||
|
|
||||||
|
- 在途车辆数不大于活跃车辆数
|
||||||
|
- 一个运输中运单绑定一辆车和一名司机
|
||||||
|
- 车辆位置由线路起终点及进度插值得到
|
||||||
|
|
||||||
|
### 线路与区域
|
||||||
|
|
||||||
|
线路包含起终点、坐标、距离、运单量、运输吨位、平均运价、准时率和风险数。区域数据由线路和运单聚合,不单独随机生成。
|
||||||
|
|
||||||
|
### 风险
|
||||||
|
|
||||||
|
风险包括轨迹偏离、长时停留、疲劳驾驶、超速、人车不一致、围栏异常。每条风险绑定车辆、线路、等级、发生时间和处理状态。
|
||||||
|
|
||||||
|
### 效益
|
||||||
|
|
||||||
|
- 空驶率 = 空驶里程 ÷ 总行驶里程
|
||||||
|
- 车辆利用率 = 执行任务车辆 ÷ 可用车辆
|
||||||
|
- 回单上传率 = 已上传回单完成单 ÷ 已完成运单
|
||||||
|
- 碳减排为减少空驶里程乘以统一排放因子,仅作为演示估算值
|
||||||
|
|
||||||
|
## 5. 模拟策略
|
||||||
|
|
||||||
|
采用固定随机种子生成基础数据,确保刷新后业务规模稳定;运行时每 2 秒推进车辆位置,每 4 秒新增或完成一笔运单,每 6–12 秒触发一次可控风险。所有图表共享同一数据源,禁止各组件自行产生互相冲突的随机数。
|
||||||
|
|
||||||
|
演示规模为 30 个物流节点、10 条重点线路、约 180 辆车辆、240 名司机和 600–1000 笔运单。晚间线路活跃度、货物占比、地区规模与价格保持符合行业展示语境的差异。
|
||||||
|
|
||||||
|
## 6. 素材规范
|
||||||
|
|
||||||
|
- 生成式素材:夜间智慧物流园区、重卡侧前方透明主体、调度中心氛围背景
|
||||||
|
- 自制 SVG:车型剪影、货类图标、风险图标、科技边框、雷达和道路纹理
|
||||||
|
- 地图:使用项目内中国 GeoJSON,边界仅用于数据展示
|
||||||
|
- 图片统一转换为 WebP,压暗高光、叠加青蓝色调,不携带品牌、水印或文字
|
||||||
|
- 外部素材必须记录来源及商用许可;无法确认授权时不用
|
||||||
|
|
||||||
|
## 7. 组件与数据流
|
||||||
|
|
||||||
|
页面容器只负责布局。模拟器提供只读状态和明确操作;各面板通过 props 接收数据,通过事件上报选中的区域、线路或车辆。地图渲染层接受统一的 `MapScene`,首版连接 ECharts,二期可替换为 Three.js 而不改变业务组件。
|
||||||
|
|
||||||
|
## 8. 性能目标
|
||||||
|
|
||||||
|
- 生产构建无 TypeScript 错误
|
||||||
|
- 主流 Chromium 浏览器正常运行
|
||||||
|
- 1920×1080 展示机上常态动画目标 50–60 FPS
|
||||||
|
- 首屏本地资源加载目标低于 3 秒
|
||||||
|
- 1366×768 与 2560×1440 下完整等比显示,无裁切、无横向滚动
|
||||||
|
- 定时器和图表实例在组件卸载时全部销毁
|
||||||
|
|
||||||
|
## 9. 验收清单
|
||||||
|
|
||||||
|
1. 页面包含不少于 16 个独立信息模块,且图表、图像和数据文字比例均衡
|
||||||
|
2. 中央地图具备立体地台、枢纽、飞线、车辆点和焦点线路
|
||||||
|
3. 告警列表、地图异常线路与顶部风险提示能够联动
|
||||||
|
4. 模拟数据满足金额、运单状态、车辆及区域聚合关系
|
||||||
|
5. 运单列表、车辆位置和时间持续更新且长时间运行稳定
|
||||||
|
6. 背景、卡车主体及行业 SVG 素材风格统一,无版权风险
|
||||||
|
7. 支持全屏比例适配和减少动态效果
|
||||||
|
8. 完成类型检查、生产构建、浏览器截图及多分辨率验收
|
||||||
|
|
||||||
|
## 10. 二期真 3D
|
||||||
|
|
||||||
|
首版不引入重型模型。二期在现有 `MapRenderer` 边界内增加 Three.js 渲染器,加载压缩后的 GLTF 卡车、地形挤出、光带道路和镜头巡航。业务数据、指标面板及交互协议保持不变。
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#020913" />
|
||||||
|
<title>网络货运智慧运营中心</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1640
package-lock.json
generated
Normal file
1640
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "network-freight-datav",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"typecheck": "vue-tsc -b --pretty false",
|
||||||
|
"preview": "vite preview --host 0.0.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/free-solid-svg-icons": "^7.3.1",
|
||||||
|
"@fortawesome/vue-fontawesome": "^3.3.3",
|
||||||
|
"@vueuse/core": "^13.9.0",
|
||||||
|
"china-map-data": "^1.0.4",
|
||||||
|
"echarts": "^6.0.0",
|
||||||
|
"vue": "^3.5.21"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.20.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.1",
|
||||||
|
"typescript": "~5.9.2",
|
||||||
|
"vite": "^7.1.5",
|
||||||
|
"vue-tsc": "^3.0.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
src/App.vue
Normal file
7
src/App.vue
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import FreightDashboard from '@/views/FreightDashboardV2.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FreightDashboard />
|
||||||
|
</template>
|
||||||
BIN
src/assets/stock/coal-road-truck.jpg
Normal file
BIN
src/assets/stock/coal-road-truck.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
BIN
src/assets/stock/coal-truck-loading.png
Normal file
BIN
src/assets/stock/coal-truck-loading.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1010 KiB |
40
src/assets/visuals/logistics-night.svg
Normal file
40
src/assets/visuals/logistics-night.svg
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sky" x2="0" y2="1">
|
||||||
|
<stop stop-color="#010711"/>
|
||||||
|
<stop offset=".58" stop-color="#05243a"/>
|
||||||
|
<stop offset="1" stop-color="#020912"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="road" x2="0" y2="1">
|
||||||
|
<stop stop-color="#0a3046"/>
|
||||||
|
<stop offset="1" stop-color="#02070d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glow"><feGaussianBlur stdDeviation="9"/></filter>
|
||||||
|
</defs>
|
||||||
|
<rect width="1920" height="1080" fill="url(#sky)"/>
|
||||||
|
<g opacity=".36">
|
||||||
|
<path d="M0 625L230 535 452 574 452 790 0 850z" fill="#09263a" stroke="#137a99"/>
|
||||||
|
<path d="M72 596h294v123H72z" fill="#061522" stroke="#1d7893"/>
|
||||||
|
<path d="M100 620h42v68h-42zm67 0h42v68h-42zm67 0h42v68h-42zm67 0h42v68h-42z" fill="#28c6e8" opacity=".18"/>
|
||||||
|
<path d="M1467 558l251-67 202 112v238l-453-55z" fill="#09263a" stroke="#137a99"/>
|
||||||
|
<path d="M1520 578h310v138h-310z" fill="#061522" stroke="#1d7893"/>
|
||||||
|
<path d="M1550 606h51v71h-51zm81 0h51v71h-51zm81 0h51v71h-51z" fill="#ffc857" opacity=".18"/>
|
||||||
|
</g>
|
||||||
|
<g fill="none" stroke="#1bdcff" opacity=".15">
|
||||||
|
<path d="M0 810L960 635 1920 810"/>
|
||||||
|
<path d="M0 868L960 654 1920 868"/>
|
||||||
|
<path d="M0 946L960 678 1920 946"/>
|
||||||
|
<path d="M175 1080L960 635M490 1080L960 635M1430 1080L960 635M1745 1080L960 635"/>
|
||||||
|
</g>
|
||||||
|
<path d="M0 865Q960 660 1920 865v215H0z" fill="url(#road)" opacity=".72"/>
|
||||||
|
<path d="M0 929Q960 715 1920 929" fill="none" stroke="#13d9ff" stroke-width="4" opacity=".24" filter="url(#glow)"/>
|
||||||
|
<path d="M0 929Q960 715 1920 929" fill="none" stroke="#42e9ff" stroke-width="1.5" opacity=".44"/>
|
||||||
|
<g opacity=".23" stroke="#2ddffc" fill="none">
|
||||||
|
<path d="M164 513V271h139v242M164 326h139M233 271v-88M233 183l112 81"/>
|
||||||
|
<path d="M1638 522V302h132v220M1638 352h132M1704 302V211M1704 211l-109 72"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#42dff5">
|
||||||
|
<circle cx="252" cy="181" r="3"/><circle cx="1699" cy="210" r="3"/>
|
||||||
|
</g>
|
||||||
|
<ellipse cx="960" cy="800" rx="670" ry="180" fill="#097da7" opacity=".08" filter="url(#glow)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
68
src/components/base/FreightIcon.vue
Normal file
68
src/components/base/FreightIcon.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
|
||||||
|
import {
|
||||||
|
faBoxesStacked, faCircleDollarToSlot, faClipboardList, faClock,
|
||||||
|
faFileLines, faGaugeHigh, faLeaf, faLocationDot, faMicrochip,
|
||||||
|
faRoute, faShieldHalved, faTriangleExclamation, faTruckFast,
|
||||||
|
faUserTie, faWarehouse, faWeightHanging,
|
||||||
|
} from '@fortawesome/free-solid-svg-icons'
|
||||||
|
|
||||||
|
export type FreightIconKind =
|
||||||
|
| 'order' | 'weight' | 'money' | 'truck' | 'driver' | 'route'
|
||||||
|
| 'shield' | 'clock' | 'document' | 'location' | 'cargo' | 'warehouse'
|
||||||
|
| 'leaf' | 'ai' | 'speed' | 'warning'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ kind: FreightIconKind; size?: number; tone?: 'cyan' | 'gold' | 'red' | 'green' }>(), {
|
||||||
|
size: 32,
|
||||||
|
tone: 'cyan',
|
||||||
|
})
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
order: faClipboardList,
|
||||||
|
weight: faWeightHanging,
|
||||||
|
money: faCircleDollarToSlot,
|
||||||
|
truck: faTruckFast,
|
||||||
|
driver: faUserTie,
|
||||||
|
route: faRoute,
|
||||||
|
shield: faShieldHalved,
|
||||||
|
clock: faClock,
|
||||||
|
document: faFileLines,
|
||||||
|
location: faLocationDot,
|
||||||
|
cargo: faBoxesStacked,
|
||||||
|
warehouse: faWarehouse,
|
||||||
|
leaf: faLeaf,
|
||||||
|
ai: faMicrochip,
|
||||||
|
speed: faGaugeHigh,
|
||||||
|
warning: faTriangleExclamation,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span class="freight-icon" :class="`freight-icon--${tone}`" :style="{ width: `${size}px`, height: `${size}px` }">
|
||||||
|
<FontAwesomeIcon :icon="icons[kind]" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.freight-icon {
|
||||||
|
--icon: var(--cyan);
|
||||||
|
display: inline-grid;
|
||||||
|
flex: none;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--icon) 24%, transparent);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: linear-gradient(145deg, color-mix(in srgb, var(--icon) 15%, transparent), color-mix(in srgb, var(--icon) 4%, transparent));
|
||||||
|
line-height: 0;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: iconBreath 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
.freight-icon svg { position: absolute; left: 50%; top: 50%; display: block; width: 48%; height: 48%; margin: 0; color: var(--icon); transform: translate(-50%, -50%); }
|
||||||
|
.freight-icon--gold { --icon: var(--gold); }
|
||||||
|
.freight-icon--red { --icon: var(--red); }
|
||||||
|
.freight-icon--green { --icon: #4fe3b1; }
|
||||||
|
@keyframes iconBreath {
|
||||||
|
0%, 100% { filter: brightness(.88); box-shadow: inset 0 0 5px color-mix(in srgb, var(--icon) 5%, transparent); }
|
||||||
|
50% { filter: brightness(1.22); box-shadow: inset 0 0 15px color-mix(in srgb, var(--icon) 18%, transparent), 0 0 9px color-mix(in srgb, var(--icon) 20%, transparent); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
src/components/base/KpiCard.vue
Normal file
63
src/components/base/KpiCard.vue
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import FreightIcon, { type FreightIconKind } from './FreightIcon.vue'
|
||||||
|
import type { KpiMetric } from '@/types/freight'
|
||||||
|
|
||||||
|
const props = defineProps<{ metric: KpiMetric; index: number }>()
|
||||||
|
const iconKinds: FreightIconKind[] = ['order', 'weight', 'money', 'truck', 'driver', 'route']
|
||||||
|
const formattedValue = computed(() =>
|
||||||
|
props.metric.displayValue ?? props.metric.value.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: props.metric.digits ?? 0,
|
||||||
|
maximumFractionDigits: props.metric.digits ?? 0,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article class="kpi-card" :class="`kpi-card--${metric.accent ?? 'cyan'}`">
|
||||||
|
<span class="kpi-card__serial"><i />LIVE · 0{{ index + 1 }}</span>
|
||||||
|
<FreightIcon :kind="iconKinds[index] ?? 'order'" :tone="metric.accent === 'gold' ? 'gold' : 'cyan'" :size="36" />
|
||||||
|
<div class="kpi-card__content">
|
||||||
|
<span class="kpi-card__label">{{ metric.label }}</span>
|
||||||
|
<div>
|
||||||
|
<Transition name="number-roll" mode="out-in">
|
||||||
|
<strong :key="formattedValue">{{ formattedValue }}</strong>
|
||||||
|
</Transition>
|
||||||
|
<small>{{ metric.unit }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="kpi-card__trend">▲ {{ metric.change }}%</span>
|
||||||
|
<svg class="kpi-card__spark" viewBox="0 0 90 24" preserveAspectRatio="none">
|
||||||
|
<polyline points="0,20 12,17 23,19 35,10 47,14 58,6 70,9 90,2" />
|
||||||
|
</svg>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kpi-card {
|
||||||
|
position: relative;
|
||||||
|
height: 74px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(32, 178, 233, .27);
|
||||||
|
background: linear-gradient(110deg, rgba(5, 36, 58, .92), rgba(2, 16, 29, .62));
|
||||||
|
clip-path: polygon(0 0, calc(100% - 13px) 0, 100% 13px, 100% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
.kpi-card::before { content: ''; position: absolute; inset: auto 0 0; height: 2px; background: linear-gradient(90deg, transparent, var(--accent), transparent); }
|
||||||
|
.kpi-card--cyan { --accent: var(--cyan); }
|
||||||
|
.kpi-card--gold { --accent: var(--gold); }
|
||||||
|
.kpi-card__serial { position: absolute; right: 8px; top: 4px; color: rgba(98, 202, 230, .48); font: 700 9px Rajdhani; }
|
||||||
|
.kpi-card__serial i{display:inline-block;width:4px;height:4px;margin-right:4px;border-radius:50%;background:#4fe3b1;animation:livePulse 1.4s ease-in-out infinite}
|
||||||
|
.kpi-card :deep(.freight-icon) { margin-right: 12px; }
|
||||||
|
.kpi-card__content { min-width: 0; }
|
||||||
|
.kpi-card__label { color: #8fb7c5; font-size: 11px; letter-spacing: 1px; }
|
||||||
|
.kpi-card strong { color: var(--accent); font: 700 23px Rajdhani, sans-serif; letter-spacing: 1px; text-shadow: 0 0 12px color-mix(in srgb, var(--accent) 55%, transparent); }
|
||||||
|
.kpi-card small { margin-left: 4px; color: #85afbf; font-size: 10px; }
|
||||||
|
.kpi-card__trend { position: absolute; right: 7px; bottom: 8px; color: #53e6b3; font: 600 9px Rajdhani; }
|
||||||
|
.kpi-card__spark { position: absolute; right: 5px; top: 25px; width: 52px; height: 14px; opacity: .35; }
|
||||||
|
.kpi-card__spark polyline { fill: none; stroke: var(--accent); stroke-width: 1.3; stroke-dasharray: 12 5; animation:sparkFlow 2.4s linear infinite }
|
||||||
|
@keyframes livePulse{50%{opacity:.25;transform:scale(.7)}}@keyframes sparkFlow{to{stroke-dashoffset:-34}}
|
||||||
|
.number-roll-enter-active,.number-roll-leave-active{display:inline-block;transition:opacity .18s ease,transform .18s ease,filter .18s ease}.number-roll-enter-from{opacity:0;transform:translateY(9px) scale(.94);filter:brightness(2)}.number-roll-leave-to{opacity:0;transform:translateY(-9px) scale(1.06);filter:brightness(1.8)}
|
||||||
|
</style>
|
||||||
48
src/components/base/TechPanel.vue
Normal file
48
src/components/base/TechPanel.vue
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
title: string
|
||||||
|
tag?: string
|
||||||
|
dense?: boolean
|
||||||
|
}
|
||||||
|
defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="tech-panel" :class="{ 'tech-panel--dense': dense }">
|
||||||
|
<div class="tech-panel__beam" />
|
||||||
|
<header class="tech-panel__header">
|
||||||
|
<span class="tech-panel__mark" />
|
||||||
|
<h2 class="tech-panel__title">{{ title }}</h2>
|
||||||
|
<span v-if="tag" class="tech-panel__tag">{{ tag }}</span>
|
||||||
|
<span class="tech-panel__line" />
|
||||||
|
</header>
|
||||||
|
<div class="tech-panel__body">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<i class="tech-panel__corner tech-panel__corner--tl" />
|
||||||
|
<i class="tech-panel__corner tech-panel__corner--br" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tech-panel {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, rgba(6, 34, 56, .88), rgba(3, 15, 28, .72));
|
||||||
|
border: 1px solid rgba(51, 189, 244, .24);
|
||||||
|
box-shadow: inset 0 0 26px rgba(0, 132, 205, .08), 0 10px 35px rgba(0, 0, 0, .18);
|
||||||
|
clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 14px, 100% 100%, 12px 100%, 0 calc(100% - 12px));
|
||||||
|
}
|
||||||
|
.tech-panel__header { height: 32px; display: flex; align-items: center; gap: 8px; padding: 0 12px; }
|
||||||
|
.tech-panel__mark { width: 3px; height: 14px; background: var(--cyan); box-shadow: 0 0 10px var(--cyan); transform: skewX(-18deg); }
|
||||||
|
.tech-panel__title { margin: 0; font-size: 14px; letter-spacing: 2px; font-weight: 600; white-space: nowrap; }
|
||||||
|
.tech-panel__tag { color: var(--cyan); font: 10px Rajdhani, sans-serif; letter-spacing: 1px; }
|
||||||
|
.tech-panel__line { height: 1px; flex: 1; background: linear-gradient(90deg, rgba(25,217,255,.55), transparent); }
|
||||||
|
.tech-panel__body { height: calc(100% - 32px); padding: 2px 12px 10px; min-height: 0; }
|
||||||
|
.tech-panel--dense .tech-panel__body { padding: 0 9px 8px; }
|
||||||
|
.tech-panel__beam { position: absolute; left: 0; top: 0; width: 82px; height: 2px; background: linear-gradient(90deg, var(--cyan), transparent); }
|
||||||
|
.tech-panel__corner { position: absolute; width: 8px; height: 8px; pointer-events: none; }
|
||||||
|
.tech-panel__corner--tl { top: 3px; left: 3px; border-top: 1px solid var(--cyan); border-left: 1px solid var(--cyan); }
|
||||||
|
.tech-panel__corner--br { right: 3px; bottom: 3px; border-right: 1px solid var(--cyan); border-bottom: 1px solid var(--cyan); }
|
||||||
|
</style>
|
||||||
282
src/components/map/ChinaFreightMap.vue
Normal file
282
src/components/map/ChinaFreightMap.vue
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||||
|
import { init, registerMap, use } from 'echarts/core'
|
||||||
|
import { EffectScatterChart, LinesChart } from 'echarts/charts'
|
||||||
|
import { GeoComponent, TooltipComponent } from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import chinaGeoJson from 'china-map-data/china'
|
||||||
|
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||||
|
import type { GeoJSONSourceInput } from 'echarts/types/src/coord/geo/geoTypes.js'
|
||||||
|
import type { MapScene } from '@/types/freight'
|
||||||
|
import MapOperationsHud from './MapOperationsHud.vue'
|
||||||
|
|
||||||
|
use([GeoComponent, TooltipComponent, LinesChart, EffectScatterChart, CanvasRenderer])
|
||||||
|
registerMap('network-freight-china', chinaGeoJson as GeoJSONSourceInput)
|
||||||
|
|
||||||
|
const props = defineProps<{ scene: MapScene }>()
|
||||||
|
const emit = defineEmits<{ selectRoute: [routeId: string] }>()
|
||||||
|
const mapEl = useTemplateRef<HTMLDivElement>('map')
|
||||||
|
const chart = shallowRef<ECharts>()
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
|
||||||
|
const cityCoordinates: Record<string, [number, number]> = {
|
||||||
|
乌鲁木齐: [87.62, 43.82], 兰州: [103.83, 36.06], 成都: [104.07, 30.67],
|
||||||
|
西安: [108.94, 34.34], 重庆: [106.55, 29.56], 昆明: [102.71, 25.04],
|
||||||
|
北京: [116.41, 39.9], 天津: [117.2, 39.13], 济南: [117.12, 36.65],
|
||||||
|
青岛: [120.38, 36.07], 郑州: [113.63, 34.75], 临沂: [118.36, 35.1],
|
||||||
|
武汉: [114.31, 30.59], 南京: [118.8, 32.06], 上海: [121.47, 31.23],
|
||||||
|
杭州: [120.16, 30.27], 长沙: [112.94, 28.23], 南昌: [115.86, 28.68],
|
||||||
|
厦门: [118.09, 24.48], 广州: [113.27, 23.13], 深圳: [114.06, 22.55],
|
||||||
|
南宁: [108.37, 22.82], 哈尔滨: [126.64, 45.76], 沈阳: [123.43, 41.8],
|
||||||
|
鄂尔多斯: [109.78, 39.61], 榆林: [109.73, 38.29], 大同: [113.3, 40.08],
|
||||||
|
神木: [110.5, 38.84], 乌海: [106.82, 39.67], 包头: [109.84, 40.66],
|
||||||
|
晋城: [112.85, 35.49], 阳泉: [113.58, 37.86], 石家庄: [114.52, 38.05],
|
||||||
|
唐山: [118.18, 39.63], 邯郸: [114.49, 36.62],
|
||||||
|
贵阳: [106.63, 26.65], 六盘水: [104.83, 26.59], 毕节: [105.29, 27.3],
|
||||||
|
曲靖: [103.8, 25.49], 昭通: [103.72, 27.34], 达州: [107.47, 31.21],
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRoute = computed(() =>
|
||||||
|
props.scene.routes.find((route) => route.id === props.scene.selectedRouteId) ?? props.scene.routes[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
const auxiliaryFlows = [
|
||||||
|
['鄂尔多斯', '上海'], ['榆林', '武汉'], ['大同', '南京'],
|
||||||
|
['乌海', '济南'], ['六盘水', '广州'], ['毕节', '成都'],
|
||||||
|
['曲靖', '南宁'], ['昭通', '重庆'],
|
||||||
|
].map(([from, to], index) => ({
|
||||||
|
id: `A${index + 1}`,
|
||||||
|
name: `${from} → ${to} · 协同通道`,
|
||||||
|
coords: [cityCoordinates[from], cityCoordinates[to]],
|
||||||
|
lineStyle: {
|
||||||
|
color: index % 3 === 0 ? '#ffc857' : '#1fcce8',
|
||||||
|
width: index % 3 === 0 ? 1.25 : 0.9,
|
||||||
|
opacity: index % 3 === 0 ? 0.62 : 0.38,
|
||||||
|
curveness: 0.3 + index % 3 * 0.05,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const option = computed<EChartsCoreOption>(() => {
|
||||||
|
const activeRouteId = props.scene.selectedRouteId
|
||||||
|
const routeData = props.scene.routes.map((route) => ({
|
||||||
|
id: route.id,
|
||||||
|
name: `${route.from.name} → ${route.to.name}`,
|
||||||
|
coords: [cityCoordinates[route.from.name], cityCoordinates[route.to.name]],
|
||||||
|
lineStyle: {
|
||||||
|
color: route.id === activeRouteId ? '#ffc857' : route.riskCount > 2 ? '#ff415d' : '#27d8ff',
|
||||||
|
width: route.id === activeRouteId ? 2.4 : 1.2,
|
||||||
|
opacity: route.id === activeRouteId ? 1 : 0.66,
|
||||||
|
curveness: 0.24,
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const involvedCities = new Map<string, number>()
|
||||||
|
props.scene.routes.forEach((route) => {
|
||||||
|
involvedCities.set(route.from.name, route.from.volume)
|
||||||
|
involvedCities.set(route.to.name, route.to.volume)
|
||||||
|
})
|
||||||
|
auxiliaryFlows.forEach((flow) => {
|
||||||
|
const [from, to] = flow.name.split(' · ')[0].split(' → ')
|
||||||
|
if (!involvedCities.has(from)) involvedCities.set(from, 74)
|
||||||
|
if (!involvedCities.has(to)) involvedCities.set(to, 68)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDurationUpdate: 500,
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
backgroundColor: 'rgba(3,20,33,.94)',
|
||||||
|
borderColor: 'rgba(25,217,255,.42)',
|
||||||
|
textStyle: { color: '#d9f9ff', fontSize: 11 },
|
||||||
|
},
|
||||||
|
geo: [
|
||||||
|
{
|
||||||
|
map: 'network-freight-china',
|
||||||
|
roam: false,
|
||||||
|
layoutCenter: ['50%', '50%'],
|
||||||
|
layoutSize: '100%',
|
||||||
|
silent: true,
|
||||||
|
z: 2,
|
||||||
|
itemStyle: {
|
||||||
|
areaColor: {
|
||||||
|
type: 'linear',
|
||||||
|
x: 0, y: 0, x2: 1, y2: 1,
|
||||||
|
colorStops: [
|
||||||
|
{ offset: 0, color: '#0c5471' },
|
||||||
|
{ offset: 0.52, color: '#08344f' },
|
||||||
|
{ offset: 1, color: '#061b31' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
borderColor: '#32d8ff',
|
||||||
|
borderWidth: 1.1,
|
||||||
|
shadowColor: 'rgba(24,206,255,.48)',
|
||||||
|
shadowBlur: 12,
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
disabled: false,
|
||||||
|
itemStyle: { areaColor: '#0d6684', borderColor: '#b8f8ff', borderWidth: 1.6 },
|
||||||
|
label: { show: true, color: '#e9fdff', fontSize: 10 },
|
||||||
|
},
|
||||||
|
label: { show: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
map: 'network-freight-china',
|
||||||
|
layoutCenter: ['50%', '52.3%'],
|
||||||
|
layoutSize: '100%',
|
||||||
|
silent: true,
|
||||||
|
z: 1,
|
||||||
|
itemStyle: {
|
||||||
|
areaColor: '#020b16',
|
||||||
|
borderColor: '#0d6a8b',
|
||||||
|
borderWidth: 1.3,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowBlur: 20,
|
||||||
|
shadowOffsetY: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'lines',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
geoIndex: 0,
|
||||||
|
zlevel: 3,
|
||||||
|
data: routeData,
|
||||||
|
lineStyle: {
|
||||||
|
color: '#0fc8ef',
|
||||||
|
width: 5,
|
||||||
|
opacity: 0.11,
|
||||||
|
curveness: 0.24,
|
||||||
|
shadowColor: '#19d9ff',
|
||||||
|
shadowBlur: 12,
|
||||||
|
},
|
||||||
|
silent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'lines',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
geoIndex: 0,
|
||||||
|
zlevel: 4,
|
||||||
|
data: routeData,
|
||||||
|
polyline: false,
|
||||||
|
lineStyle: { curveness: 0.24 },
|
||||||
|
symbol: ['none', 'arrow'],
|
||||||
|
symbolSize: 6,
|
||||||
|
effect: {
|
||||||
|
show: true,
|
||||||
|
period: 3.7,
|
||||||
|
trailLength: 0.36,
|
||||||
|
symbol: 'arrow',
|
||||||
|
symbolSize: 7,
|
||||||
|
color: '#d8fbff',
|
||||||
|
},
|
||||||
|
emphasis: { lineStyle: { width: 3, opacity: 1 } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'lines',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
geoIndex: 0,
|
||||||
|
zlevel: 4,
|
||||||
|
data: auxiliaryFlows,
|
||||||
|
lineStyle: { curveness: 0.34, type: 'dashed' },
|
||||||
|
effect: {
|
||||||
|
show: true,
|
||||||
|
period: 5.8,
|
||||||
|
trailLength: 0.5,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 4,
|
||||||
|
color: '#ffe4a3',
|
||||||
|
},
|
||||||
|
silent: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'effectScatter',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
geoIndex: 0,
|
||||||
|
zlevel: 5,
|
||||||
|
symbolSize: (value: number[]) => Math.max(6, Math.min(13, value[2] / 18)),
|
||||||
|
rippleEffect: { period: 3.6, scale: 3, brushType: 'stroke' },
|
||||||
|
itemStyle: { color: '#8ff6ff', shadowColor: '#19d9ff', shadowBlur: 12 },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
formatter: '{b}',
|
||||||
|
color: '#c9f8ff',
|
||||||
|
fontSize: 9,
|
||||||
|
textBorderColor: '#02101a',
|
||||||
|
textBorderWidth: 3,
|
||||||
|
},
|
||||||
|
data: [...involvedCities].map(([name, volume]) => ({
|
||||||
|
name,
|
||||||
|
value: [...cityCoordinates[name], volume],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'effectScatter',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
geoIndex: 0,
|
||||||
|
zlevel: 6,
|
||||||
|
symbolSize: 15,
|
||||||
|
rippleEffect: { period: 2.8, scale: 4.2, brushType: 'stroke' },
|
||||||
|
itemStyle: { color: '#ffc857', shadowColor: '#ffc857', shadowBlur: 16 },
|
||||||
|
label: { show: false },
|
||||||
|
data: ['鄂尔多斯', '榆林', '大同', '神木', '六盘水', '毕节', '曲靖', '昭通', '达州'].map(name => ({
|
||||||
|
name: `${name}煤源`,
|
||||||
|
value: [...cityCoordinates[name], 180],
|
||||||
|
})),
|
||||||
|
silent: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleRouteClick(params: unknown) {
|
||||||
|
const data = (params as { data?: { id?: string } | null }).data
|
||||||
|
if (data?.id) emit('selectRoute', data.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!mapEl.value) return
|
||||||
|
chart.value = init(mapEl.value, undefined, { renderer: 'canvas' })
|
||||||
|
chart.value.setOption(option.value)
|
||||||
|
chart.value.on('click', handleRouteClick)
|
||||||
|
resizeObserver = new ResizeObserver(() => chart.value?.resize())
|
||||||
|
resizeObserver.observe(mapEl.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(option, (value) => chart.value?.setOption(value, { notMerge: true }))
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
chart.value?.off('click', handleRouteClick)
|
||||||
|
chart.value?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="china-freight-map">
|
||||||
|
<div ref="map" class="china-freight-map__canvas" />
|
||||||
|
<div class="china-freight-map__grid" />
|
||||||
|
<div class="china-freight-map__orbits" aria-hidden="true"><i /><i /><i /></div>
|
||||||
|
<MapOperationsHud :scene="scene" @select-route="emit('selectRoute', $event)" />
|
||||||
|
<div class="china-freight-map__caption">REAL-TIME NATIONAL FREIGHT NETWORK</div>
|
||||||
|
<div class="route-focus">
|
||||||
|
<span>重点通道 / {{ selectedRoute.id }}</span>
|
||||||
|
<strong>{{ selectedRoute.from.name }} <i>→</i> {{ selectedRoute.to.name }}</strong>
|
||||||
|
<div><b>{{ selectedRoute.orders.toLocaleString() }}</b> 单 <b>{{ selectedRoute.tonnage.toLocaleString() }}</b> 吨 <b>{{ selectedRoute.punctuality }}%</b> 准时</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.china-freight-map { position: relative; width: 100%; height: 100%; overflow: hidden; background: radial-gradient(ellipse at 50% 54%,rgba(7,91,126,.18),transparent 62%); }
|
||||||
|
.china-freight-map::before { content:''; position:absolute; left:14%; right:14%; bottom:1%; height:18%; border:1px solid rgba(25,217,255,.2); border-radius:50%; box-shadow:0 0 30px rgba(25,217,255,.12),inset 0 0 25px rgba(22,104,255,.1); }
|
||||||
|
.china-freight-map__canvas { position:absolute; inset:0; z-index:2; }
|
||||||
|
.china-freight-map__grid { position:absolute; z-index:1; left:8%; right:8%; bottom:1%; height:36%; transform:perspective(220px) rotateX(66deg); transform-origin:bottom; background-image:linear-gradient(rgba(25,217,255,.1) 1px,transparent 1px),linear-gradient(90deg,rgba(25,217,255,.1) 1px,transparent 1px); background-size:25px 25px; mask-image:radial-gradient(ellipse,black,transparent 70%); }
|
||||||
|
.china-freight-map__orbits{position:absolute;z-index:3;left:54%;top:38%;width:210px;height:120px;transform:translate(-50%,-50%);pointer-events:none}.china-freight-map__orbits i{position:absolute;inset:0;border:1px solid rgba(255,200,87,.23);border-radius:50%;animation:orbitPulse 4.5s ease-in-out infinite}.china-freight-map__orbits i:nth-child(2){inset:15px 28px;border-color:rgba(25,217,255,.28);animation-delay:-1.5s}.china-freight-map__orbits i:nth-child(3){inset:30px 56px;border-color:rgba(255,200,87,.35);animation-delay:-3s}
|
||||||
|
.china-freight-map__caption { position:absolute; z-index:3; left:18px; bottom:12px; color:rgba(76,221,255,.42); font:600 10px Rajdhani; letter-spacing:4px; }
|
||||||
|
.route-focus { position:absolute; z-index:4; right:15px; top:15px; width:195px; padding:10px 12px; border-left:2px solid var(--gold); background:linear-gradient(90deg,rgba(10,40,59,.94),rgba(4,18,31,.38)); pointer-events:none; }
|
||||||
|
.route-focus span{color:#7daab9;font-size:9px;letter-spacing:1px}.route-focus strong{display:block;margin:4px 0 6px;color:#f4fbff;font-size:14px}.route-focus i{color:var(--gold);font-style:normal}.route-focus div{display:flex;gap:7px;color:#729baa;font-size:8px}.route-focus b{color:var(--gold);font:600 12px Rajdhani}
|
||||||
|
@keyframes orbitPulse{0%,100%{opacity:.18;transform:scale(.88) rotate(-4deg)}50%{opacity:.72;transform:scale(1.08) rotate(4deg);box-shadow:0 0 18px rgba(25,217,255,.12)}}
|
||||||
|
</style>
|
||||||
137
src/components/map/MapOperationsHud.vue
Normal file
137
src/components/map/MapOperationsHud.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { FreightRoute, MapScene, Vehicle } from '@/types/freight'
|
||||||
|
|
||||||
|
const props = defineProps<{ scene: MapScene }>()
|
||||||
|
const emit = defineEmits<{ selectRoute: [routeId: string] }>()
|
||||||
|
|
||||||
|
interface RouteGroup {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
subline: string
|
||||||
|
routeIds: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceGroups: RouteGroup[] = [
|
||||||
|
{ id: 'north-mongolia', name: '鄂尔多斯煤源', subline: '蒙西动力煤', routeIds: ['R01'] },
|
||||||
|
{ id: 'north-shaanxi', name: '榆林煤源', subline: '陕北动力煤', routeIds: ['R02', 'R04'] },
|
||||||
|
{ id: 'southwest', name: '云贵煤源带', subline: '六盘水 · 毕节 · 曲靖', routeIds: ['R06', 'R07', 'R08', 'R09'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const corridorGroups: RouteGroup[] = [
|
||||||
|
{ id: 'north-corridor', name: '晋陕蒙 → 京津冀', subline: '煤炭北运主通道', routeIds: ['R01', 'R02', 'R03', 'R04'] },
|
||||||
|
{ id: 'southwest-corridor', name: '云贵川 → 成渝', subline: '西南煤炭保供通道', routeIds: ['R06', 'R07', 'R09', 'R10'] },
|
||||||
|
{ id: 'yunnan-corridor', name: '曲靖 → 昆明', subline: '滇中短驳走廊', routeIds: ['R08'] },
|
||||||
|
]
|
||||||
|
|
||||||
|
function routesIn(group: RouteGroup) {
|
||||||
|
return props.scene.routes.filter((route) => group.routeIds.includes(route.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
function vehiclesIn(group: RouteGroup) {
|
||||||
|
return props.scene.vehicles.filter((vehicle) => vehicle.routeId && group.routeIds.includes(vehicle.routeId))
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumRoutes(routes: FreightRoute[], key: 'orders' | 'tonnage' | 'distance') {
|
||||||
|
return routes.reduce((total, route) => total + route[key], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function averageProgress(vehicles: Vehicle[]) {
|
||||||
|
if (!vehicles.length) return 0
|
||||||
|
return vehicles.reduce((total, vehicle) => total + vehicle.progress, 0) / vehicles.length
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalOrders = computed(() => sumRoutes(props.scene.routes, 'orders'))
|
||||||
|
|
||||||
|
const sourceStatus = computed(() => sourceGroups.map((group, index) => {
|
||||||
|
const groupRoutes = routesIn(group)
|
||||||
|
const groupVehicles = vehiclesIn(group)
|
||||||
|
const orderShare = sumRoutes(groupRoutes, 'orders') / totalOrders.value
|
||||||
|
const progress = averageProgress(groupVehicles)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
routeId: group.routeIds[0],
|
||||||
|
waitingTonnage: Math.round(props.scene.snapshot.todayTonnage * orderShare * (0.055 + progress * 0.008)),
|
||||||
|
loadingRate: Number((93.4 + index * 0.7 + progress * 1.2).toFixed(1)),
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const corridorStatus = computed(() => corridorGroups.map((group) => {
|
||||||
|
const groupRoutes = routesIn(group)
|
||||||
|
const groupVehicles = vehiclesIn(group)
|
||||||
|
const orderShare = sumRoutes(groupRoutes, 'orders') / totalOrders.value
|
||||||
|
const averageDistance = sumRoutes(groupRoutes, 'distance') / Math.max(1, groupRoutes.length)
|
||||||
|
const progress = averageProgress(groupVehicles)
|
||||||
|
const riskCount = groupRoutes.reduce((total, route) => total + route.riskCount, 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
routeId: group.routeIds[0],
|
||||||
|
activeVehicles: Math.round(props.scene.snapshot.inTransitVehicles * orderShare),
|
||||||
|
etaHours: Math.max(2.6, averageDistance / (43 + progress * 5)).toFixed(1),
|
||||||
|
status: riskCount >= 5 ? '车流较密' : riskCount >= 3 ? '轻微缓行' : '通行顺畅',
|
||||||
|
statusTone: riskCount >= 5 ? 'warning' : 'normal',
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="map-hud map-hud--source">
|
||||||
|
<header class="map-hud__header">
|
||||||
|
<span>煤源基地动态</span>
|
||||||
|
<small>LOADING STATUS</small>
|
||||||
|
</header>
|
||||||
|
<button
|
||||||
|
v-for="source in sourceStatus"
|
||||||
|
:key="source.id"
|
||||||
|
class="source-card"
|
||||||
|
type="button"
|
||||||
|
@click="emit('selectRoute', source.routeId)"
|
||||||
|
>
|
||||||
|
<span class="source-card__dot" />
|
||||||
|
<span class="source-card__name"><b>{{ source.name }}</b><small>{{ source.subline }}</small></span>
|
||||||
|
<span class="source-card__metric"><b>{{ source.waitingTonnage.toLocaleString() }}</b><small>待装吨位</small></span>
|
||||||
|
<span class="source-card__rate"><b>{{ source.loadingRate }}%</b><small>装车效率</small></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="map-hud map-hud--corridor">
|
||||||
|
<header class="map-hud__header">
|
||||||
|
<span>运输走廊实况</span>
|
||||||
|
<small>CORRIDOR LIVE</small>
|
||||||
|
</header>
|
||||||
|
<button
|
||||||
|
v-for="corridor in corridorStatus"
|
||||||
|
:key="corridor.id"
|
||||||
|
class="corridor-card"
|
||||||
|
type="button"
|
||||||
|
@click="emit('selectRoute', corridor.routeId)"
|
||||||
|
>
|
||||||
|
<span class="corridor-card__title"><b>{{ corridor.name }}</b><small>{{ corridor.subline }}</small></span>
|
||||||
|
<span class="corridor-card__status" :class="`is-${corridor.statusTone}`"><i />{{ corridor.status }}</span>
|
||||||
|
<span class="corridor-card__facts">
|
||||||
|
<span><b>{{ corridor.activeVehicles }}</b> 辆在途</span>
|
||||||
|
<span>预计 <b>{{ corridor.etaHours }}</b>h</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.map-hud{position:absolute;z-index:5;width:196px;pointer-events:auto;color:#bdebf2}
|
||||||
|
.map-hud--source{left:16px;top:102px}.map-hud--corridor{right:16px;top:176px}
|
||||||
|
.map-hud__header{height:30px;display:flex;align-items:center;justify-content:space-between;padding:0 9px;border-left:2px solid #19d9ff;background:linear-gradient(90deg,rgba(8,48,66,.86),rgba(5,26,40,.24));box-shadow:0 7px 20px rgba(0,0,0,.14)}
|
||||||
|
.map-hud__header span{font-size:10px;letter-spacing:1px}.map-hud__header small{color:#417b8d;font:600 7px Rajdhani;letter-spacing:1px}
|
||||||
|
.source-card,.corridor-card{position:relative;width:100%;border:0;border-bottom:1px solid rgba(25,217,255,.09);background:linear-gradient(90deg,rgba(4,28,42,.76),rgba(3,17,29,.22));color:inherit;cursor:pointer;text-align:left;transition:background .2s ease,border-color .2s ease}
|
||||||
|
.source-card:hover,.corridor-card:hover{border-color:rgba(25,217,255,.38);background:linear-gradient(90deg,rgba(8,54,72,.9),rgba(4,23,37,.42))}
|
||||||
|
.source-card{height:55px;display:grid;grid-template-columns:10px 1fr 46px 43px;align-items:center;gap:5px;padding:0 7px}
|
||||||
|
.source-card__dot{width:5px;height:5px;border-radius:50%;background:#4fe3b1;box-shadow:0 0 8px #4fe3b1;animation:mapHudPulse 2.2s ease-in-out infinite}
|
||||||
|
.source-card__name b,.source-card__name small,.source-card__metric b,.source-card__metric small,.source-card__rate b,.source-card__rate small{display:block}
|
||||||
|
.source-card__name b{font-size:9px;font-weight:500}.source-card__name small{margin-top:3px;color:#557e8b;font-size:7px;white-space:nowrap}
|
||||||
|
.source-card__metric,.source-card__rate{text-align:right}.source-card__metric b{color:#d6f8ff;font:600 12px Rajdhani}.source-card__rate b{color:#4fe3b1;font:600 11px Rajdhani}.source-card__metric small,.source-card__rate small{margin-top:2px;color:#4e7684;font-size:6px}
|
||||||
|
.corridor-card{height:62px;padding:7px 9px}.corridor-card__title{display:block;padding-right:59px}.corridor-card__title b,.corridor-card__title small{display:block}.corridor-card__title b{font-size:9px;font-weight:500}.corridor-card__title small{margin-top:3px;color:#527b89;font-size:7px}
|
||||||
|
.corridor-card__status{position:absolute;right:8px;top:10px;color:#50dcb0;font-size:7px}.corridor-card__status i{display:inline-block;width:4px;height:4px;margin-right:4px;border-radius:50%;background:currentColor;box-shadow:0 0 6px currentColor}.corridor-card__status.is-warning{color:#ffc857}
|
||||||
|
.corridor-card__facts{display:flex;justify-content:space-between;margin-top:7px;padding-top:5px;border-top:1px solid rgba(25,217,255,.06);color:#688f9c;font-size:7px}.corridor-card__facts b{color:#8be9f2;font:600 10px Rajdhani}
|
||||||
|
@keyframes mapHudPulse{0%,100%{opacity:.35;transform:scale(.75)}50%{opacity:1;transform:scale(1.18)}}
|
||||||
|
</style>
|
||||||
11
src/components/map/MapRenderer.vue
Normal file
11
src/components/map/MapRenderer.vue
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ChinaFreightMap from './ChinaFreightMap.vue'
|
||||||
|
import type { MapRendererEvents, MapRendererProps } from '@/types/freight'
|
||||||
|
|
||||||
|
defineProps<MapRendererProps>()
|
||||||
|
const emit = defineEmits<MapRendererEvents>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ChinaFreightMap :scene="scene" @select-route="emit('selectRoute', $event)" />
|
||||||
|
</template>
|
||||||
238
src/components/map/RouteExecutionMap.vue
Normal file
238
src/components/map/RouteExecutionMap.vue
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, shallowRef, useTemplateRef, watch } from 'vue'
|
||||||
|
import { init, registerMap, use } from 'echarts/core'
|
||||||
|
import { EffectScatterChart, LinesChart, ScatterChart } from 'echarts/charts'
|
||||||
|
import { GeoComponent, TooltipComponent } from 'echarts/components'
|
||||||
|
import { CanvasRenderer } from 'echarts/renderers'
|
||||||
|
import chinaGeoJson from 'china-map-data/china'
|
||||||
|
import type { ECharts, EChartsCoreOption } from 'echarts/core'
|
||||||
|
import type { GeoJSONSourceInput } from 'echarts/types/src/coord/geo/geoTypes.js'
|
||||||
|
import type { MapScene } from '@/types/freight'
|
||||||
|
|
||||||
|
use([GeoComponent, TooltipComponent, LinesChart, ScatterChart, EffectScatterChart, CanvasRenderer])
|
||||||
|
registerMap('freight-route-china', chinaGeoJson as GeoJSONSourceInput)
|
||||||
|
|
||||||
|
const props = defineProps<{ scene: MapScene }>()
|
||||||
|
const mapEl = useTemplateRef<HTMLDivElement>('map')
|
||||||
|
const chart = shallowRef<ECharts>()
|
||||||
|
let resizeObserver: ResizeObserver | undefined
|
||||||
|
|
||||||
|
const cityCoordinates: Record<string, [number, number]> = {
|
||||||
|
乌鲁木齐: [87.62, 43.82], 兰州: [103.83, 36.06], 成都: [104.07, 30.67],
|
||||||
|
西安: [108.94, 34.34], 重庆: [106.55, 29.56], 北京: [116.41, 39.9],
|
||||||
|
济南: [117.12, 36.65], 郑州: [113.63, 34.75], 临沂: [118.36, 35.1],
|
||||||
|
武汉: [114.31, 30.59], 南京: [118.8, 32.06], 上海: [121.47, 31.23],
|
||||||
|
杭州: [120.16, 30.27], 厦门: [118.09, 24.48], 广州: [113.27, 23.13],
|
||||||
|
深圳: [114.06, 22.55], 沈阳: [123.43, 41.8],
|
||||||
|
鄂尔多斯: [109.78, 39.61], 榆林: [109.73, 38.29], 大同: [113.3, 40.08],
|
||||||
|
神木: [110.5, 38.84], 乌海: [106.82, 39.67], 包头: [109.84, 40.66],
|
||||||
|
晋城: [112.85, 35.49], 阳泉: [113.58, 37.86], 石家庄: [114.52, 38.05],
|
||||||
|
唐山: [118.18, 39.63], 邯郸: [114.49, 36.62],
|
||||||
|
贵阳: [106.63, 26.65], 六盘水: [104.83, 26.59], 毕节: [105.29, 27.3],
|
||||||
|
曲靖: [103.8, 25.49], 昭通: [103.72, 27.34], 达州: [107.47, 31.21],
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedRoute = computed(() =>
|
||||||
|
props.scene.routes.find((route) => route.id === props.scene.selectedRouteId) ?? props.scene.routes[0],
|
||||||
|
)
|
||||||
|
|
||||||
|
function interpolate(from: [number, number], to: [number, number], progress: number): [number, number] {
|
||||||
|
return [from[0] + (to[0] - from[0]) * progress, from[1] + (to[1] - from[1]) * progress]
|
||||||
|
}
|
||||||
|
|
||||||
|
const roadPaths: Record<string, [number, number][]> = {
|
||||||
|
R01: [cityCoordinates.鄂尔多斯, [112.43, 39.33], cityCoordinates.大同, [114.88, 40.77], [116.41, 39.9], cityCoordinates.唐山],
|
||||||
|
R02: [cityCoordinates.榆林, [111.14, 37.52], [112.55, 37.87], [113.11, 36.2], cityCoordinates.邯郸],
|
||||||
|
R03: [cityCoordinates.大同, [114.88, 40.77], [116.41, 39.9], cityCoordinates.天津],
|
||||||
|
R04: [cityCoordinates.神木, [111.14, 37.52], [112.55, 37.87], cityCoordinates.石家庄, cityCoordinates.济南],
|
||||||
|
R05: [cityCoordinates.晋城, [112.45, 34.62], [112.53, 32.99], [112.14, 32.04], [113.38, 31.69], cityCoordinates.武汉],
|
||||||
|
R06: [cityCoordinates.六盘水, [105.95, 26.25], cityCoordinates.贵阳],
|
||||||
|
R07: [cityCoordinates.毕节, [106.93, 27.73], cityCoordinates.重庆],
|
||||||
|
R08: [cityCoordinates.曲靖, [103.04, 25.34], cityCoordinates.昆明],
|
||||||
|
R09: [cityCoordinates.昭通, [104.64, 28.75], [103.77, 29.55], cityCoordinates.成都],
|
||||||
|
R10: [cityCoordinates.达州, [106.93, 30.33], cityCoordinates.重庆],
|
||||||
|
}
|
||||||
|
const roadNames: Record<string, string> = {
|
||||||
|
R01: 'G6京藏高速', R02: 'G20青银高速', R03: 'G7京新高速',
|
||||||
|
R04: 'G20青银高速', R05: 'G55二广高速', R06: 'G60沪昆高速',
|
||||||
|
R07: 'G56杭瑞高速', R08: 'G60沪昆高速', R09: 'G85银昆高速', R10: 'G65包茂高速',
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentLength(a: [number, number], b: [number, number]) {
|
||||||
|
return Math.hypot(b[0] - a[0], b[1] - a[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointAlongPath(path: [number, number][], progress: number): [number, number] {
|
||||||
|
const lengths = path.slice(1).map((point, index) => segmentLength(path[index], point))
|
||||||
|
const target = lengths.reduce((sum, length) => sum + length, 0) * progress
|
||||||
|
let travelled = 0
|
||||||
|
for (let index = 0; index < lengths.length; index += 1) {
|
||||||
|
if (travelled + lengths[index] >= target) {
|
||||||
|
return interpolate(path[index], path[index + 1], (target - travelled) / lengths[index])
|
||||||
|
}
|
||||||
|
travelled += lengths[index]
|
||||||
|
}
|
||||||
|
return path.at(-1)!
|
||||||
|
}
|
||||||
|
|
||||||
|
function completedPath(path: [number, number][], progress: number) {
|
||||||
|
const current = pointAlongPath(path, progress)
|
||||||
|
const lengths = path.slice(1).map((point, index) => segmentLength(path[index], point))
|
||||||
|
const target = lengths.reduce((sum, length) => sum + length, 0) * progress
|
||||||
|
const result: [number, number][] = [path[0]]
|
||||||
|
let travelled = 0
|
||||||
|
for (let index = 0; index < lengths.length; index += 1) {
|
||||||
|
if (travelled + lengths[index] < target) result.push(path[index + 1])
|
||||||
|
else break
|
||||||
|
travelled += lengths[index]
|
||||||
|
}
|
||||||
|
result.push(current)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const option = computed<EChartsCoreOption>(() => {
|
||||||
|
const route = selectedRoute.value
|
||||||
|
const from = cityCoordinates[route.from.name]
|
||||||
|
const to = cityCoordinates[route.to.name]
|
||||||
|
const path = roadPaths[route.id] ?? [from, to]
|
||||||
|
const current = pointAlongPath(path, 0.62)
|
||||||
|
const longitudes = path.map(point => point[0])
|
||||||
|
const latitudes = path.map(point => point[1])
|
||||||
|
const center: [number, number] = [
|
||||||
|
(Math.min(...longitudes) + Math.max(...longitudes)) / 2,
|
||||||
|
(Math.min(...latitudes) + Math.max(...latitudes)) / 2,
|
||||||
|
]
|
||||||
|
const zoom = Math.max(1.75, Math.min(5, 48 / Math.max(
|
||||||
|
Math.max(...longitudes) - Math.min(...longitudes),
|
||||||
|
Math.max(...latitudes) - Math.min(...latitudes),
|
||||||
|
)))
|
||||||
|
|
||||||
|
return {
|
||||||
|
animation: true,
|
||||||
|
animationDurationUpdate: 450,
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
backgroundColor: 'rgba(3,20,33,.96)',
|
||||||
|
borderColor: 'rgba(25,217,255,.36)',
|
||||||
|
textStyle: { color: '#d9f9ff', fontSize: 11 },
|
||||||
|
},
|
||||||
|
geo: {
|
||||||
|
map: 'freight-route-china',
|
||||||
|
roam: false,
|
||||||
|
center,
|
||||||
|
zoom,
|
||||||
|
silent: true,
|
||||||
|
itemStyle: {
|
||||||
|
areaColor: '#08283a',
|
||||||
|
borderColor: '#187a98',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
emphasis: { disabled: true },
|
||||||
|
label: { show: false },
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'lines',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
polyline: true,
|
||||||
|
data: [{ coords: path }],
|
||||||
|
lineStyle: { color: '#255d70', width: 8, opacity: 0.72 },
|
||||||
|
z: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'lines',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
polyline: true,
|
||||||
|
data: [{ coords: completedPath(path, 0.62) }],
|
||||||
|
lineStyle: { color: '#2de0ed', width: 4, opacity: 0.95 },
|
||||||
|
symbol: ['circle', 'none'],
|
||||||
|
symbolSize: 7,
|
||||||
|
effect: {
|
||||||
|
show: true,
|
||||||
|
period: 3.2,
|
||||||
|
trailLength: 0.18,
|
||||||
|
symbol: 'circle',
|
||||||
|
symbolSize: 5,
|
||||||
|
color: '#ecfeff',
|
||||||
|
},
|
||||||
|
z: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'scatter',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
symbolSize: 14,
|
||||||
|
itemStyle: { color: '#ffc857', borderColor: '#fff', borderWidth: 2 },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'top',
|
||||||
|
formatter: '{b}',
|
||||||
|
color: '#e8fbff',
|
||||||
|
fontSize: 10,
|
||||||
|
backgroundColor: 'rgba(2,18,29,.88)',
|
||||||
|
padding: [5, 8],
|
||||||
|
},
|
||||||
|
data: [
|
||||||
|
{ name: `${route.from.name} · 已发车`, value: [...from, 1] },
|
||||||
|
{ name: '途中检查 · 已通过', value: [...pointAlongPath(path, 0.34), 1], symbol: 'diamond', symbolSize: 10 },
|
||||||
|
{ name: `${route.to.name} · 预计22:40`, value: [...to, 1] },
|
||||||
|
],
|
||||||
|
z: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'effectScatter',
|
||||||
|
coordinateSystem: 'geo',
|
||||||
|
rippleEffect: { period: 3.5, scale: 3, brushType: 'stroke' },
|
||||||
|
symbol: 'pin',
|
||||||
|
symbolSize: 28,
|
||||||
|
itemStyle: { color: '#ffc857' },
|
||||||
|
label: {
|
||||||
|
show: true,
|
||||||
|
position: 'right',
|
||||||
|
formatter: '{b}',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 10,
|
||||||
|
backgroundColor: 'rgba(2,18,29,.92)',
|
||||||
|
padding: [5, 8],
|
||||||
|
},
|
||||||
|
data: [{ name: `${roadNames[route.id]} · 78km/h`, value: [...current, 2] }],
|
||||||
|
z: 5,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!mapEl.value) return
|
||||||
|
chart.value = init(mapEl.value, undefined, { renderer: 'canvas' })
|
||||||
|
chart.value.setOption(option.value)
|
||||||
|
resizeObserver = new ResizeObserver(() => chart.value?.resize())
|
||||||
|
resizeObserver.observe(mapEl.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(option, value => chart.value?.setOption(value, { notMerge: true }))
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
resizeObserver?.disconnect()
|
||||||
|
chart.value?.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="route-map">
|
||||||
|
<div ref="map" class="route-map__canvas" />
|
||||||
|
<div class="route-map__overview">
|
||||||
|
<span>线路 {{ selectedRoute.id }}</span>
|
||||||
|
<strong>{{ selectedRoute.from.name }} → {{ selectedRoute.to.name }}</strong>
|
||||||
|
<div><b>62%</b> 行程进度</div>
|
||||||
|
<div><b>{{ selectedRoute.distance }}km</b> 计划里程</div>
|
||||||
|
<div><b>{{ selectedRoute.riskCount }}</b> 未处理风险</div>
|
||||||
|
</div>
|
||||||
|
<div class="route-map__legend">
|
||||||
|
<span>已行驶 {{ Math.round(selectedRoute.distance * .62) }}km</span><i><b /></i><span>剩余 {{ Math.round(selectedRoute.distance * .38) }}km</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.route-map{position:relative;width:100%;height:100%;overflow:hidden;background:radial-gradient(circle at center,rgba(10,76,101,.18),rgba(2,13,23,.7))}.route-map__canvas{position:absolute;inset:0}.route-map__overview{position:absolute;z-index:2;left:16px;top:48px;width:190px;padding:13px 14px;background:rgba(2,18,29,.9);border-left:2px solid #19d9ff}.route-map__overview>span{color:#5e95a7;font-size:8px}.route-map__overview>strong{display:block;margin:4px 0 10px;color:#effdff;font-size:16px}.route-map__overview>div{display:flex;justify-content:space-between;padding:4px 0;color:#759eab;font-size:8px;border-top:1px solid rgba(25,217,255,.07)}.route-map__overview b{color:#54ddeb;font:600 11px Rajdhani}.route-map__legend{position:absolute;z-index:2;left:28%;right:18%;bottom:18px;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:center;padding:10px 14px;background:rgba(2,18,29,.9);color:#7199a6;font-size:8px}.route-map__legend>i{height:4px;background:rgba(25,217,255,.12)}.route-map__legend>i b{display:block;width:62%;height:100%;background:#35d9e7}
|
||||||
|
</style>
|
||||||
146
src/composables/useFreightSimulation.ts
Normal file
146
src/composables/useFreightSimulation.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, readonly, reactive, shallowRef } from 'vue'
|
||||||
|
import { drivers, initialAlerts, initialOrders, routes, vehicles } from '@/mock/freightData'
|
||||||
|
import type { FreightOrder, KpiMetric, PlatformSnapshot, RiskAlert } from '@/types/freight'
|
||||||
|
|
||||||
|
const cargoSequence = ['动力煤', '焦煤', '洗精煤', '无烟煤', '焦炭', '矿粉']
|
||||||
|
const alertTypes = ['轨迹偏离', '长时间停留', '疲劳驾驶', '电子围栏异常', '疑似超速']
|
||||||
|
|
||||||
|
function formatClock(date: Date) {
|
||||||
|
return date.toLocaleTimeString('zh-CN', { hour12: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFreightSimulation() {
|
||||||
|
const orderList = reactive<FreightOrder[]>(initialOrders.map((order) => ({ ...order })))
|
||||||
|
const vehicleList = reactive(vehicles.map((vehicle) => ({ ...vehicle })))
|
||||||
|
const alertList = reactive<RiskAlert[]>(initialAlerts.map((alert) => ({ ...alert })))
|
||||||
|
const selectedRouteId = shallowRef(routes[0].id)
|
||||||
|
const clock = shallowRef(new Date())
|
||||||
|
const tick = shallowRef(0)
|
||||||
|
const timers: number[] = []
|
||||||
|
|
||||||
|
const routeTotals = computed(() => routes.reduce((result, route) => {
|
||||||
|
result.orders += route.orders
|
||||||
|
result.tonnage += route.tonnage
|
||||||
|
result.tonKilometres += route.tonnage * route.distance
|
||||||
|
result.freight += route.tonnage * route.distance * route.rate
|
||||||
|
return result
|
||||||
|
}, { orders: 0, tonnage: 0, tonKilometres: 0, freight: 0 }))
|
||||||
|
|
||||||
|
const snapshot = computed<PlatformSnapshot>(() => ({
|
||||||
|
todayOrders: routeTotals.value.orders + Math.floor(tick.value / 2),
|
||||||
|
completedOrders: 6_917 + Math.floor(Math.floor(tick.value / 2) * 0.68),
|
||||||
|
inTransitOrders: routeTotals.value.orders + Math.floor(tick.value / 2)
|
||||||
|
- (6_917 + Math.floor(Math.floor(tick.value / 2) * 0.68))
|
||||||
|
- (426 + Math.floor(tick.value / 6) % 3),
|
||||||
|
pendingPickupOrders: 426 + Math.floor(tick.value / 6) % 3,
|
||||||
|
activeVehicles: 6_380,
|
||||||
|
inTransitVehicles: 1_089 + tick.value % 7,
|
||||||
|
todayTonnage: Math.round(routeTotals.value.tonnage + Math.floor(tick.value / 2) * 32.8),
|
||||||
|
freightAmount: Math.round(routeTotals.value.freight + Math.floor(tick.value / 2) * 32.8 * 612 * 0.31),
|
||||||
|
averageWeight: Number(((routeTotals.value.tonnage + Math.floor(tick.value / 2) * 32.8) / (routeTotals.value.orders + Math.floor(tick.value / 2))).toFixed(2)),
|
||||||
|
averageDistance: Math.round(routeTotals.value.tonKilometres / routeTotals.value.tonnage),
|
||||||
|
averageRate: Number((routeTotals.value.freight / routeTotals.value.tonKilometres).toFixed(3)),
|
||||||
|
responseSeconds: 164 + tick.value % 5,
|
||||||
|
onTimeRate: 94.7,
|
||||||
|
trackIntegrityRate: 98.3,
|
||||||
|
receiptRate: 92.6,
|
||||||
|
settlementRate: 88.4,
|
||||||
|
invoiceRate: 84.9,
|
||||||
|
abnormalRate: 0.74,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const kpis = computed<KpiMetric[]>(() => [
|
||||||
|
{ label: '今日有效运单', value: snapshot.value.todayOrders, unit: '单', change: 6.8 },
|
||||||
|
{ label: '今日运输吨位', value: snapshot.value.todayTonnage / 10_000, unit: '万吨', change: 4.2, digits: 2 },
|
||||||
|
{ label: '今日运费金额', value: snapshot.value.freightAmount / 10_000, unit: '万元', change: 5.6, accent: 'gold', digits: 0 },
|
||||||
|
{ label: '当前在途车辆', value: snapshot.value.inTransitVehicles, unit: '辆', change: 3.1 },
|
||||||
|
{
|
||||||
|
label: '平均报价响应',
|
||||||
|
value: snapshot.value.responseSeconds,
|
||||||
|
displayValue: `${Math.floor(snapshot.value.responseSeconds / 60)}分${snapshot.value.responseSeconds % 60}秒`,
|
||||||
|
unit: '',
|
||||||
|
change: -8.4,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const recentOrders = computed(() => orderList.slice(0, 8))
|
||||||
|
const clockText = computed(() => formatClock(clock.value))
|
||||||
|
const dateText = computed(() => clock.value.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
|
||||||
|
}))
|
||||||
|
|
||||||
|
function advanceVehicles() {
|
||||||
|
vehicleList.forEach((vehicle) => {
|
||||||
|
if (vehicle.status !== '运输中') return
|
||||||
|
vehicle.progress = (vehicle.progress + 0.008 + (Number(vehicle.id.slice(1)) % 5) * 0.001) % 1
|
||||||
|
})
|
||||||
|
tick.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOrder() {
|
||||||
|
const index = orderList.length + tick.value
|
||||||
|
const route = routes[index % routes.length]
|
||||||
|
const vehicle = vehicleList[index % vehicleList.length]
|
||||||
|
const weight = 20 + index % 14
|
||||||
|
const unitRate = route.rate + (index % 4) * 0.01
|
||||||
|
orderList.unshift({
|
||||||
|
id: `YD${clock.value.getFullYear()}${String(900000 + index)}`,
|
||||||
|
cargo: cargoSequence[index % cargoSequence.length],
|
||||||
|
weight,
|
||||||
|
routeId: route.id,
|
||||||
|
distance: route.distance,
|
||||||
|
unitRate,
|
||||||
|
fee: Math.round(weight * route.distance * unitRate),
|
||||||
|
vehicleId: vehicle.id,
|
||||||
|
driverId: drivers[index % drivers.length].id,
|
||||||
|
status: index % 5 === 0 ? '待装货' : '运输中',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
eta: new Date(Date.now() + (8 + index % 18) * 3_600_000).toISOString(),
|
||||||
|
})
|
||||||
|
if (orderList.length > 820) orderList.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateAlert() {
|
||||||
|
const index = tick.value % vehicleList.length
|
||||||
|
const route = routes[index % routes.length]
|
||||||
|
alertList.unshift({
|
||||||
|
id: `A${Date.now()}`,
|
||||||
|
type: alertTypes[index % alertTypes.length],
|
||||||
|
level: index % 5 === 0 ? '高' : index % 2 === 0 ? '中' : '低',
|
||||||
|
vehicleId: vehicleList[index].id,
|
||||||
|
routeId: route.id,
|
||||||
|
city: `${route.from.name}段`,
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
resolved: false,
|
||||||
|
})
|
||||||
|
if (alertList.length > 9) alertList.pop()
|
||||||
|
selectedRouteId.value = route.id
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRoute(routeId: string) {
|
||||||
|
selectedRouteId.value = routeId
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timers.push(window.setInterval(() => { clock.value = new Date() }, 1_000))
|
||||||
|
timers.push(window.setInterval(advanceVehicles, 2_000))
|
||||||
|
timers.push(window.setInterval(addOrder, 4_000))
|
||||||
|
timers.push(window.setInterval(rotateAlert, 9_000))
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => timers.forEach(window.clearInterval))
|
||||||
|
|
||||||
|
return {
|
||||||
|
orders: readonly(orderList),
|
||||||
|
vehicles: readonly(vehicleList),
|
||||||
|
alerts: readonly(alertList),
|
||||||
|
routes,
|
||||||
|
kpis,
|
||||||
|
snapshot,
|
||||||
|
recentOrders,
|
||||||
|
selectedRouteId: readonly(selectedRouteId),
|
||||||
|
clockText,
|
||||||
|
dateText,
|
||||||
|
selectRoute,
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './styles/main.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
132
src/mock/freightData.ts
Normal file
132
src/mock/freightData.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import type { CityNode, Driver, FreightOrder, FreightRoute, RiskAlert, Vehicle } from '@/types/freight'
|
||||||
|
|
||||||
|
export const cities: CityNode[] = [
|
||||||
|
{ id: 'urumqi', name: '乌鲁木齐', province: '新疆', x: 13, y: 29, volume: 68 },
|
||||||
|
{ id: 'lanzhou', name: '兰州', province: '甘肃', x: 35, y: 44, volume: 82 },
|
||||||
|
{ id: 'chengdu', name: '成都', province: '四川', x: 39, y: 61, volume: 108 },
|
||||||
|
{ id: 'xian', name: '西安', province: '陕西', x: 48, y: 49, volume: 126 },
|
||||||
|
{ id: 'chongqing', name: '重庆', province: '重庆', x: 47, y: 63, volume: 118 },
|
||||||
|
{ id: 'kunming', name: '昆明', province: '云南', x: 39, y: 78, volume: 72 },
|
||||||
|
{ id: 'beijing', name: '北京', province: '北京', x: 67, y: 27, volume: 156 },
|
||||||
|
{ id: 'tianjin', name: '天津', province: '天津', x: 71, y: 32, volume: 133 },
|
||||||
|
{ id: 'jinan', name: '济南', province: '山东', x: 69, y: 42, volume: 171 },
|
||||||
|
{ id: 'qingdao', name: '青岛', province: '山东', x: 78, y: 43, volume: 144 },
|
||||||
|
{ id: 'zhengzhou', name: '郑州', province: '河南', x: 59, y: 49, volume: 203 },
|
||||||
|
{ id: 'linyi', name: '临沂', province: '山东', x: 73, y: 48, volume: 231 },
|
||||||
|
{ id: 'wuhan', name: '武汉', province: '湖北', x: 59, y: 61, volume: 167 },
|
||||||
|
{ id: 'nanjing', name: '南京', province: '江苏', x: 74, y: 57, volume: 182 },
|
||||||
|
{ id: 'shanghai', name: '上海', province: '上海', x: 83, y: 58, volume: 228 },
|
||||||
|
{ id: 'hangzhou', name: '杭州', province: '浙江', x: 79, y: 64, volume: 194 },
|
||||||
|
{ id: 'changsha', name: '长沙', province: '湖南', x: 57, y: 70, volume: 121 },
|
||||||
|
{ id: 'nanchang', name: '南昌', province: '江西', x: 66, y: 69, volume: 97 },
|
||||||
|
{ id: 'xiamen', name: '厦门', province: '福建', x: 72, y: 80, volume: 91 },
|
||||||
|
{ id: 'guangzhou', name: '广州', province: '广东', x: 58, y: 86, volume: 212 },
|
||||||
|
{ id: 'shenzhen', name: '深圳', province: '广东', x: 62, y: 89, volume: 186 },
|
||||||
|
{ id: 'nanning', name: '南宁', province: '广西', x: 47, y: 87, volume: 76 },
|
||||||
|
{ id: 'haerbin', name: '哈尔滨', province: '黑龙江', x: 84, y: 11, volume: 89 },
|
||||||
|
{ id: 'shenyang', name: '沈阳', province: '辽宁', x: 78, y: 23, volume: 111 },
|
||||||
|
{ id: 'ordos', name: '鄂尔多斯', province: '内蒙古', x: 48, y: 35, volume: 238 },
|
||||||
|
{ id: 'yulin', name: '榆林', province: '陕西', x: 49, y: 40, volume: 226 },
|
||||||
|
{ id: 'datong', name: '大同', province: '山西', x: 59, y: 32, volume: 194 },
|
||||||
|
{ id: 'shenmu', name: '神木', province: '陕西', x: 51, y: 38, volume: 208 },
|
||||||
|
{ id: 'wuhai', name: '乌海', province: '内蒙古', x: 40, y: 34, volume: 116 },
|
||||||
|
{ id: 'baotou', name: '包头', province: '内蒙古', x: 50, y: 31, volume: 139 },
|
||||||
|
{ id: 'jincheng', name: '晋城', province: '山西', x: 60, y: 48, volume: 128 },
|
||||||
|
{ id: 'yangquan', name: '阳泉', province: '山西', x: 63, y: 40, volume: 104 },
|
||||||
|
{ id: 'shijiazhuang', name: '石家庄', province: '河北', x: 66, y: 39, volume: 146 },
|
||||||
|
{ id: 'tangshan', name: '唐山', province: '河北', x: 73, y: 31, volume: 213 },
|
||||||
|
{ id: 'handan', name: '邯郸', province: '河北', x: 65, y: 46, volume: 167 },
|
||||||
|
{ id: 'guiyang', name: '贵阳', province: '贵州', x: 47, y: 72, volume: 142 },
|
||||||
|
{ id: 'liupanshui', name: '六盘水', province: '贵州', x: 42, y: 72, volume: 196 },
|
||||||
|
{ id: 'bijie', name: '毕节', province: '贵州', x: 44, y: 68, volume: 181 },
|
||||||
|
{ id: 'qujing', name: '曲靖', province: '云南', x: 40, y: 76, volume: 173 },
|
||||||
|
{ id: 'zhaotong', name: '昭通', province: '云南', x: 40, y: 67, volume: 158 },
|
||||||
|
{ id: 'dazhou', name: '达州', province: '四川', x: 47, y: 58, volume: 164 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const routePairs = [
|
||||||
|
['ordos', 'tangshan'], ['yulin', 'handan'], ['datong', 'tianjin'],
|
||||||
|
['shenmu', 'jinan'], ['jincheng', 'wuhan'], ['liupanshui', 'guiyang'],
|
||||||
|
['bijie', 'chongqing'], ['qujing', 'kunming'], ['zhaotong', 'chengdu'], ['dazhou', 'chongqing'],
|
||||||
|
]
|
||||||
|
|
||||||
|
export const routes: FreightRoute[] = routePairs.map(([fromId, toId], index) => {
|
||||||
|
const from = cities.find((city) => city.id === fromId)!
|
||||||
|
const to = cities.find((city) => city.id === toId)!
|
||||||
|
const distance = [830, 620, 470, 710, 760, 240, 470, 150, 520, 230][index]
|
||||||
|
const orders = [1286, 1139, 986, 874, 821, 776, 703, 668, 624, 589][index]
|
||||||
|
const rates = [0.28, 0.30, 0.31, 0.29, 0.30, 0.42, 0.34, 0.46, 0.33, 0.40]
|
||||||
|
return {
|
||||||
|
id: `R${String(index + 1).padStart(2, '0')}`,
|
||||||
|
from, to, distance, orders,
|
||||||
|
tonnage: Math.round(orders * (32.2 + index * 0.18)),
|
||||||
|
rate: rates[index],
|
||||||
|
punctuality: Number((96.8 - index * 0.31).toFixed(1)),
|
||||||
|
riskCount: index % 4,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const plates = ['蒙K', '陕K', '晋B', '冀B', '晋E', '贵B', '贵F', '云D', '云C', '川S']
|
||||||
|
const vehicleTypes: Vehicle['type'][] = ['自卸车', '自卸车', '半挂牵引车', '自卸车', '罐式车']
|
||||||
|
const statuses: Vehicle['status'][] = ['运输中', '运输中', '运输中', '待装', '空闲', '待卸', '装货', '异常']
|
||||||
|
|
||||||
|
export const vehicles: Vehicle[] = Array.from({ length: 180 }, (_, index) => ({
|
||||||
|
id: `V${String(index + 1).padStart(3, '0')}`,
|
||||||
|
plate: `${plates[index % plates.length]}${String(63010 + index).slice(-5)}`,
|
||||||
|
type: vehicleTypes[index % vehicleTypes.length],
|
||||||
|
load: [32, 35, 33, 32, 34][index % 5],
|
||||||
|
status: statuses[index % statuses.length],
|
||||||
|
routeId: routes[index % routes.length].id,
|
||||||
|
progress: ((index * 13) % 100) / 100,
|
||||||
|
newEnergy: index % 7 === 0,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export const drivers: Driver[] = Array.from({ length: 240 }, (_, index) => ({
|
||||||
|
id: `D${String(index + 1).padStart(3, '0')}`,
|
||||||
|
name: `${['张', '王', '李', '赵', '刘', '陈', '杨', '周'][index % 8]}${['师傅', '建国', '海峰', '志强', '文斌'][index % 5]}`,
|
||||||
|
score: Number((4.72 + (index % 26) / 100).toFixed(2)),
|
||||||
|
punctuality: 92 + (index % 8),
|
||||||
|
safety: 90 + (index % 10),
|
||||||
|
active: index < 204,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const cargos = ['动力煤', '焦煤', '动力煤', '洗精煤', '无烟煤', '焦炭', '矿粉', '热轧钢卷']
|
||||||
|
const orderStatuses: FreightOrder['status'][] = ['运输中', '待装货', '已完成', '已完成', '待卸货', '待调度']
|
||||||
|
const now = new Date()
|
||||||
|
|
||||||
|
export const initialOrders: FreightOrder[] = Array.from({ length: 720 }, (_, index) => {
|
||||||
|
const route = routes[index % routes.length]
|
||||||
|
const weight = 30 + (index % 7)
|
||||||
|
const unitRate = Number((route.rate + (index % 5) * 0.01).toFixed(2))
|
||||||
|
return {
|
||||||
|
id: `YD${now.getFullYear()}${String(860000 + index)}`,
|
||||||
|
cargo: cargos[index % cargos.length],
|
||||||
|
weight,
|
||||||
|
routeId: route.id,
|
||||||
|
distance: route.distance,
|
||||||
|
unitRate,
|
||||||
|
fee: Math.round(weight * route.distance * unitRate),
|
||||||
|
vehicleId: vehicles[index % vehicles.length].id,
|
||||||
|
driverId: drivers[index % drivers.length].id,
|
||||||
|
status: orderStatuses[index % orderStatuses.length],
|
||||||
|
createdAt: new Date(now.getTime() - index * 167_000).toISOString(),
|
||||||
|
eta: new Date(now.getTime() + (index % 20 + 2) * 3_600_000).toISOString(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const initialAlerts: RiskAlert[] = [
|
||||||
|
['A01', '轨迹偏离', '高', 7, 0, '京唐港段'],
|
||||||
|
['A02', '疲劳驾驶', '高', 23, 3, '济阳段'],
|
||||||
|
['A03', '长时间停留', '中', 41, 6, '遵义服务区'],
|
||||||
|
['A04', '电子围栏异常', '中', 62, 7, '曲靖煤场'],
|
||||||
|
['A05', '疑似超速', '低', 89, 8, '昭通段'],
|
||||||
|
].map(([id, type, level, vehicleIndex, routeIndex, city], index) => ({
|
||||||
|
id: String(id),
|
||||||
|
type: String(type),
|
||||||
|
level: level as RiskAlert['level'],
|
||||||
|
vehicleId: vehicles[Number(vehicleIndex)].id,
|
||||||
|
routeId: routes[Number(routeIndex)].id,
|
||||||
|
city: String(city),
|
||||||
|
time: new Date(now.getTime() - index * 190_000).toISOString(),
|
||||||
|
resolved: false,
|
||||||
|
}))
|
||||||
35
src/styles/main.css
Normal file
35
src/styles/main.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Rajdhani:wght@500;600;700&family=Noto+Sans+SC:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: 'Noto Sans SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
color: #d9f7ff;
|
||||||
|
background: #020913;
|
||||||
|
font-synthesis: none;
|
||||||
|
--cyan: #19d9ff;
|
||||||
|
--cyan-soft: #6af2e7;
|
||||||
|
--blue: #1668ff;
|
||||||
|
--gold: #ffc857;
|
||||||
|
--red: #ff415d;
|
||||||
|
--orange: #ff8a34;
|
||||||
|
--panel: rgba(4, 25, 43, 0.78);
|
||||||
|
--line: rgba(49, 199, 255, 0.32);
|
||||||
|
--muted: #78a5b5;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body, #app { width: 100%; height: 100%; margin: 0; overflow: hidden; }
|
||||||
|
body {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 45%, rgba(0, 104, 164, .2), transparent 42%),
|
||||||
|
linear-gradient(150deg, #01060d 0%, #041421 55%, #020913 100%);
|
||||||
|
}
|
||||||
|
button, input { font: inherit; }
|
||||||
|
|
||||||
|
@keyframes scan { from { transform: translateX(-120%); } to { transform: translateX(240%); } }
|
||||||
|
@keyframes breathe { 50% { opacity: .42; filter: brightness(1.7); } }
|
||||||
|
@keyframes float { 50% { transform: translateY(-6px); } }
|
||||||
|
@keyframes dash { to { stroke-dashoffset: -32; } }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; }
|
||||||
|
}
|
||||||
4
src/types/china-map-data.d.ts
vendored
Normal file
4
src/types/china-map-data.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module 'china-map-data/china' {
|
||||||
|
const chinaGeoJson: unknown
|
||||||
|
export default chinaGeoJson
|
||||||
|
}
|
||||||
117
src/types/freight.ts
Normal file
117
src/types/freight.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
export type OrderStatus = '待调度' | '待装货' | '运输中' | '待卸货' | '已完成' | '异常'
|
||||||
|
export type RiskLevel = '高' | '中' | '低'
|
||||||
|
|
||||||
|
export interface CityNode {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
province: string
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
volume: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreightRoute {
|
||||||
|
id: string
|
||||||
|
from: CityNode
|
||||||
|
to: CityNode
|
||||||
|
distance: number
|
||||||
|
orders: number
|
||||||
|
tonnage: number
|
||||||
|
rate: number
|
||||||
|
punctuality: number
|
||||||
|
riskCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vehicle {
|
||||||
|
id: string
|
||||||
|
plate: string
|
||||||
|
type: '高栏车' | '厢式车' | '平板车' | '冷藏车' | '罐式车' | '自卸车' | '半挂牵引车'
|
||||||
|
load: number
|
||||||
|
status: '空闲' | '待装' | '装货' | '运输中' | '待卸' | '异常'
|
||||||
|
routeId?: string
|
||||||
|
progress: number
|
||||||
|
newEnergy: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Driver {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
score: number
|
||||||
|
punctuality: number
|
||||||
|
safety: number
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FreightOrder {
|
||||||
|
id: string
|
||||||
|
cargo: string
|
||||||
|
weight: number
|
||||||
|
routeId: string
|
||||||
|
distance: number
|
||||||
|
unitRate: number
|
||||||
|
fee: number
|
||||||
|
vehicleId: string
|
||||||
|
driverId: string
|
||||||
|
status: OrderStatus
|
||||||
|
createdAt: string
|
||||||
|
eta: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RiskAlert {
|
||||||
|
id: string
|
||||||
|
type: string
|
||||||
|
level: RiskLevel
|
||||||
|
vehicleId: string
|
||||||
|
routeId: string
|
||||||
|
city: string
|
||||||
|
time: string
|
||||||
|
resolved: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapScene {
|
||||||
|
nodes: CityNode[]
|
||||||
|
routes: FreightRoute[]
|
||||||
|
vehicles: Vehicle[]
|
||||||
|
alerts: RiskAlert[]
|
||||||
|
snapshot: PlatformSnapshot
|
||||||
|
selectedRouteId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapRendererProps {
|
||||||
|
scene: MapScene
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapRendererEvents {
|
||||||
|
selectRoute: [routeId: string]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KpiMetric {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
displayValue?: string
|
||||||
|
unit: string
|
||||||
|
change: number
|
||||||
|
accent?: 'cyan' | 'gold'
|
||||||
|
digits?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlatformSnapshot {
|
||||||
|
todayOrders: number
|
||||||
|
completedOrders: number
|
||||||
|
inTransitOrders: number
|
||||||
|
pendingPickupOrders: number
|
||||||
|
activeVehicles: number
|
||||||
|
inTransitVehicles: number
|
||||||
|
todayTonnage: number
|
||||||
|
freightAmount: number
|
||||||
|
averageWeight: number
|
||||||
|
averageDistance: number
|
||||||
|
averageRate: number
|
||||||
|
responseSeconds: number
|
||||||
|
onTimeRate: number
|
||||||
|
trackIntegrityRate: number
|
||||||
|
receiptRate: number
|
||||||
|
settlementRate: number
|
||||||
|
invoiceRate: number
|
||||||
|
abnormalRate: number
|
||||||
|
}
|
||||||
182
src/views/ExecutionScreenV2.vue
Normal file
182
src/views/ExecutionScreenV2.vue
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import FreightIcon from '@/components/base/FreightIcon.vue'
|
||||||
|
import RouteExecutionMap from '@/components/map/RouteExecutionMap.vue'
|
||||||
|
import TechPanel from '@/components/base/TechPanel.vue'
|
||||||
|
import type {
|
||||||
|
FreightOrder, FreightRoute, MapScene, PlatformSnapshot, RiskAlert, Vehicle,
|
||||||
|
} from '@/types/freight'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
snapshot: PlatformSnapshot
|
||||||
|
routes: FreightRoute[]
|
||||||
|
orders: readonly FreightOrder[]
|
||||||
|
vehicles: readonly Vehicle[]
|
||||||
|
alerts: readonly RiskAlert[]
|
||||||
|
mapScene: MapScene
|
||||||
|
selectedRouteId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ selectRoute: [routeId: string] }>()
|
||||||
|
const selectedRoute = computed(() => props.routes.find((route) => route.id === props.selectedRouteId) ?? props.routes[0])
|
||||||
|
const selectedOrder = computed(() => props.orders.find((order) => order.routeId === props.selectedRouteId) ?? props.orders[0])
|
||||||
|
const selectedVehicle = computed(() => props.vehicles.find((vehicle) => vehicle.id === selectedOrder.value.vehicleId) ?? props.vehicles[0])
|
||||||
|
|
||||||
|
const topMetrics = computed(() => [
|
||||||
|
{ label: '当前在途运单', value: props.snapshot.inTransitOrders.toLocaleString(), unit: '单', icon: 'route' as const, tone: 'cyan' as const },
|
||||||
|
{ label: '当前在途车辆', value: props.snapshot.inTransitVehicles.toLocaleString(), unit: '辆', icon: 'truck' as const, tone: 'cyan' as const },
|
||||||
|
{ label: '准时送达率', value: props.snapshot.onTimeRate.toFixed(1), unit: '%', icon: 'clock' as const, tone: 'green' as const },
|
||||||
|
{ label: '轨迹完整率', value: props.snapshot.trackIntegrityRate.toFixed(1), unit: '%', icon: 'location' as const, tone: 'green' as const },
|
||||||
|
])
|
||||||
|
|
||||||
|
const milestones = [
|
||||||
|
{ label: '调度完成', time: '08:36', done: true },
|
||||||
|
{ label: '到厂装货', time: '09:18', done: true },
|
||||||
|
{ label: '驶离围栏', time: '10:06', done: true },
|
||||||
|
{ label: '运输在途', time: '进行中', done: true },
|
||||||
|
{ label: '到达卸货', time: '预计 22:40', done: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatTime(value: string) {
|
||||||
|
return new Date(value).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function alertRoute(routeId: string) {
|
||||||
|
const route = props.routes.find((item) => item.id === routeId)
|
||||||
|
return route ? `${route.from.name}→${route.to.name}` : ''
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="execution-v2">
|
||||||
|
<div class="execution-kpis">
|
||||||
|
<article v-for="item in topMetrics" :key="item.label">
|
||||||
|
<FreightIcon :kind="item.icon" :tone="item.tone" :size="45" />
|
||||||
|
<div><span>{{ item.label }}</span><Transition name="metric-flip" mode="out-in"><strong :key="item.value">{{ item.value }}</strong></Transition><small>{{ item.unit }}</small></div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="execution-layout">
|
||||||
|
<aside class="execution-side execution-side--left">
|
||||||
|
<article class="waybill-photo">
|
||||||
|
<img src="@/assets/stock/coal-road-truck.jpg" alt="满载煤炭的公路运输车辆" />
|
||||||
|
<div class="waybill-photo__shade" />
|
||||||
|
<header><span>当前关注运单</span><b>{{ selectedOrder.id }}</b></header>
|
||||||
|
<div class="waybill-photo__route">{{ selectedRoute.from.name }} <i>→</i> {{ selectedRoute.to.name }}</div>
|
||||||
|
<div class="waybill-photo__info">
|
||||||
|
<div><span>货物</span><b>{{ selectedOrder.cargo }}</b></div>
|
||||||
|
<div><span>重量</span><b>{{ selectedOrder.weight }} 吨</b></div>
|
||||||
|
<div><span>里程</span><b>{{ selectedRoute.distance }} km</b></div>
|
||||||
|
<div><span>预计到达</span><b>{{ formatTime(selectedOrder.eta) }}</b></div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<TechPanel title="运输节点" tag="MILESTONES">
|
||||||
|
<div class="milestones">
|
||||||
|
<div v-for="(item,index) in milestones" :key="item.label" :class="{ pending: !item.done }">
|
||||||
|
<i><b /></i>
|
||||||
|
<span>{{ item.label }}</span><time>{{ item.time }}</time>
|
||||||
|
<em v-if="index < milestones.length-1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TechPanel>
|
||||||
|
|
||||||
|
<TechPanel title="车辆与司机" tag="VEHICLE & DRIVER">
|
||||||
|
<div class="vehicle-driver">
|
||||||
|
<div class="vehicle-driver__plate"><FreightIcon kind="truck" :size="48" /><strong>{{ selectedVehicle.plate }}</strong><span>{{ selectedVehicle.type }} · 核载 {{ selectedVehicle.load }}t</span></div>
|
||||||
|
<div class="vehicle-driver__facts">
|
||||||
|
<div><span>司机</span><b>王海峰</b></div><div><span>安全评分</span><b>96.8</b></div>
|
||||||
|
<div><span>连续驾驶</span><b>2h 18m</b></div><div><span>车辆状态</span><b class="normal">正常</b></div>
|
||||||
|
<div><span>当前载重</span><b>{{ selectedOrder.weight }}t</b></div><div><span>剩余里程</span><b>{{ Math.round(selectedRoute.distance * (1 - selectedVehicle.progress)) }}km</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="vehicle-driver__progress"><span>当前行程</span><i><b :style="{ width: `${Math.round(selectedVehicle.progress * 100)}%` }" /></i><strong>{{ Math.round(selectedVehicle.progress * 100) }}%</strong></div>
|
||||||
|
</div>
|
||||||
|
</TechPanel>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="execution-center">
|
||||||
|
<div class="execution-map">
|
||||||
|
<div class="execution-map__title">在途运输监控 / IN-TRANSIT MONITORING</div>
|
||||||
|
<RouteExecutionMap :scene="mapScene" />
|
||||||
|
<div class="execution-map__status">
|
||||||
|
<span><i />GPS 在线</span><span>最近定位 12 秒前</span><span>当前速度 <b>78 km/h</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="execution-bottom">
|
||||||
|
<TechPanel title="单票成本与毛利" tag="ORDER PROFIT">
|
||||||
|
<div class="profit-summary">
|
||||||
|
<div><span>运费收入</span><b>¥{{ selectedOrder.fee.toLocaleString() }}</b></div>
|
||||||
|
<i>−</i>
|
||||||
|
<div><span>预计运输成本</span><b>¥{{ Math.round(selectedOrder.fee * .82).toLocaleString() }}</b></div>
|
||||||
|
<i>=</i>
|
||||||
|
<div class="profit"><span>预计毛利</span><b>¥{{ Math.round(selectedOrder.fee * .18).toLocaleString() }}</b></div>
|
||||||
|
</div>
|
||||||
|
<div class="cost-parts">
|
||||||
|
<div v-for="item in [['司机运费',57],['燃油能源',21],['路桥费用',14],['其他费用',8]]" :key="String(item[0])"><span>{{ item[0] }}</span><i><b :style="{ width: `${item[1]}%` }" /></i><strong>{{ item[1] }}%</strong></div>
|
||||||
|
</div>
|
||||||
|
</TechPanel>
|
||||||
|
|
||||||
|
<TechPanel title="线路履约表现" tag="LANE PERFORMANCE">
|
||||||
|
<div class="lane-performance">
|
||||||
|
<div><FreightIcon kind="clock" tone="green" :size="46" /><span>准时送达</span><b>{{ selectedRoute.punctuality }}%</b><small>行业均值 92.8%</small><i><em :style="{ width: `${selectedRoute.punctuality}%` }" /></i></div>
|
||||||
|
<div><FreightIcon kind="route" :size="46" /><span>平均时效</span><b>18.6h</b><small>较昨日缩短 0.7h</small><i><em style="width: 82%" /></i></div>
|
||||||
|
<div><FreightIcon kind="money" tone="gold" :size="46" /><span>吨公里运价</span><b>{{ selectedRoute.rate }}元</b><small>线路均价 0.36元</small><i><em :style="{ width: `${Math.min(100, selectedRoute.rate / .5 * 100)}%` }" /></i></div>
|
||||||
|
</div>
|
||||||
|
</TechPanel>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="execution-side execution-side--right">
|
||||||
|
<TechPanel title="在途风险事件" tag="RISK EVENTS">
|
||||||
|
<div class="risk-headline"><strong>{{ Math.round(snapshot.todayOrders * snapshot.abnormalRate / 100) }}</strong><span>今日异常运单</span><b>{{ snapshot.abnormalRate }}%</b><small>异常率</small></div>
|
||||||
|
<div class="risk-events">
|
||||||
|
<button v-for="alert in alerts.slice(0,5)" :key="alert.id" @click="emit('selectRoute', alert.routeId)">
|
||||||
|
<FreightIcon :kind="alert.type.includes('驾驶') ? 'driver' : alert.type.includes('超速') ? 'speed' : 'warning'" :tone="alert.level === '高' ? 'red' : 'gold'" :size="31" />
|
||||||
|
<div><b>{{ alert.type }}</b><span>{{ alertRoute(alert.routeId) }} · {{ alert.city }}</span></div>
|
||||||
|
<time>{{ formatTime(alert.time) }}</time>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TechPanel>
|
||||||
|
|
||||||
|
<TechPanel title="履约质量" tag="FULFILLMENT QUALITY">
|
||||||
|
<div class="quality-metrics">
|
||||||
|
<div v-for="item in [['准时送达',snapshot.onTimeRate,'clock'],['轨迹完整',snapshot.trackIntegrityRate,'location'],['电子回单',snapshot.receiptRate,'document']]" :key="String(item[0])">
|
||||||
|
<FreightIcon :kind="item[2] as 'clock' | 'location' | 'document'" :size="36" />
|
||||||
|
<span>{{ item[0] }}</span><strong>{{ item[1] }}%</strong>
|
||||||
|
<i><b :style="{ width: `${item[1]}%` }" /></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TechPanel>
|
||||||
|
|
||||||
|
<article class="cargo-evidence">
|
||||||
|
<img src="@/assets/stock/coal-truck-loading.png" alt="采煤装载设备向矿用车辆装煤" />
|
||||||
|
<div class="cargo-evidence__shade" />
|
||||||
|
<header><FreightIcon kind="document" tone="green" :size="36" /><div><span>矿区装车影像留证</span><b>09:42 自动上传</b></div></header>
|
||||||
|
<div class="cargo-evidence__footer">
|
||||||
|
<span>电子围栏内</span><span>重量复核一致</span><span>影像 4 张</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.execution-v2{height:968px;padding-top:7px;position:relative;z-index:1;animation:executionIn .5s ease both}.execution-kpis{height:82px;display:grid;grid-template-columns:repeat(4,1fr);gap:10px;padding:0 170px}.execution-kpis article{display:flex;align-items:center;justify-content:center;gap:16px;background:linear-gradient(110deg,rgba(6,40,59,.88),rgba(3,17,29,.52));border-bottom:1px solid rgba(25,217,255,.28);clip-path:polygon(0 0,calc(100% - 13px) 0,100% 13px,100% 100%,0 100%)}.execution-kpis span{display:block;color:#7da5b2;font-size:10px}.execution-kpis strong{color:#dffaff;font:600 25px Rajdhani}.execution-kpis small{margin-left:4px;color:#638b98;font-size:8px}.execution-layout{height:858px;margin-top:8px;display:grid;grid-template-columns:320px 1fr 320px;gap:10px}.execution-side{display:grid;gap:9px;min-height:0}.execution-side--left{grid-template-rows:300px 272px 1fr}.execution-side--right{grid-template-rows:310px 260px 1fr}.execution-center{display:grid;grid-template-rows:590px 259px;gap:9px;min-width:0}.execution-map{position:relative;overflow:hidden;border:1px solid rgba(25,217,255,.12);background:rgba(2,15,26,.25)}.execution-map__title{position:absolute;z-index:6;left:17px;top:12px;color:#4ca6bd;font:600 10px Rajdhani;letter-spacing:3px}.execution-map__status{position:absolute;z-index:7;left:20%;right:20%;bottom:13px;height:32px;display:flex;align-items:center;justify-content:space-around;background:rgba(3,20,31,.88);border-top:1px solid rgba(25,217,255,.2);color:#6f98a6;font-size:8px}.execution-map__status i{display:inline-block;width:5px;height:5px;margin-right:5px;border-radius:50%;background:#4fe3b1;box-shadow:0 0 6px #4fe3b1}.execution-map__status b{color:#57dfed}.execution-bottom{display:grid;grid-template-columns:1.35fr 1fr;gap:9px;min-height:0}
|
||||||
|
.waybill-photo{position:relative;overflow:hidden;border:1px solid rgba(25,217,255,.2)}.waybill-photo img{width:100%;height:100%;object-fit:cover;filter:saturate(.5) brightness(.72)}.waybill-photo__shade{position:absolute;inset:0;background:linear-gradient(180deg,rgba(1,11,19,.25),rgba(2,15,25,.94)),linear-gradient(90deg,rgba(5,60,80,.4),transparent)}.waybill-photo header{position:absolute;left:14px;top:13px}.waybill-photo header span{display:block;color:#79a5b3;font-size:8px}.waybill-photo header b{color:#d9f9ff;font:600 12px Rajdhani}.waybill-photo__route{position:absolute;left:14px;top:55px;color:#fff;font-size:19px;letter-spacing:2px;text-shadow:0 0 12px #000}.waybill-photo__route i{color:var(--gold);font-style:normal}.waybill-photo__info{position:absolute;left:10px;right:10px;bottom:10px;display:grid;grid-template-columns:1fr 1fr;background:rgba(3,18,29,.8)}.waybill-photo__info div{padding:9px 10px;border-right:1px solid rgba(25,217,255,.08);border-bottom:1px solid rgba(25,217,255,.08)}.waybill-photo__info span{display:block;color:#6f98a6;font-size:7px}.waybill-photo__info b{color:#d9f8ff;font-size:10px}
|
||||||
|
.milestones{height:100%;padding:5px 4px}.milestones>div{position:relative;height:41px;display:grid;grid-template-columns:24px 1fr auto;align-items:center;gap:7px}.milestones>div>i{width:15px;height:15px;display:grid;place-items:center;border:1px solid #4fe3b1;border-radius:50%;box-shadow:0 0 7px rgba(79,227,177,.35)}.milestones>div>i b{width:5px;height:5px;border-radius:50%;background:#4fe3b1}.milestones em{position:absolute;left:7px;top:28px;width:1px;height:26px;background:linear-gradient(#4fe3b1,rgba(79,227,177,.1))}.milestones span{font-size:9px}.milestones time{color:#6b95a3;font:9px Rajdhani}.milestones .pending{opacity:.45}.vehicle-driver{height:100%;display:grid;grid-template-columns:1fr 1.2fr;gap:10px;align-items:center}.vehicle-driver__plate{text-align:center}.vehicle-driver__plate :deep(.freight-icon){margin:0 auto 10px}.vehicle-driver__plate strong{display:block;color:#dffaff;font:600 17px Rajdhani}.vehicle-driver__plate span{color:#6e95a2;font-size:7px}.vehicle-driver__facts{display:grid;grid-template-columns:1fr 1fr;gap:12px 8px}.vehicle-driver__facts span{display:block;color:#6e97a4;font-size:7px}.vehicle-driver__facts b{color:#d7f7ff;font-size:9px}.vehicle-driver__facts b.normal{color:#4fe3b1}
|
||||||
|
.profit-summary{height:75px;display:grid;grid-template-columns:1fr 18px 1fr 18px 1fr;align-items:center;text-align:center;border-bottom:1px solid rgba(25,217,255,.09)}.profit-summary span{display:block;color:#7098a5;font-size:8px}.profit-summary b{color:#d8f8ff;font:600 16px Rajdhani}.profit-summary>i{color:#477586;font-style:normal}.profit-summary .profit b{color:#4fe3b1}.cost-parts{padding-top:8px}.cost-parts>div{display:grid;grid-template-columns:55px 1fr 28px;gap:6px;align-items:center;height:25px;color:#6c95a2;font-size:7px}.cost-parts i{height:3px;background:rgba(25,217,255,.08)}.cost-parts i b{display:block;height:100%;background:linear-gradient(90deg,#127c9e,#38dae5)}.cost-parts strong{font:9px Rajdhani;color:#5ecedd}.lane-performance{height:100%;display:flex;justify-content:space-around;align-items:center}.lane-performance>div{text-align:center}.lane-performance :deep(.freight-icon){margin:0 auto 12px}.lane-performance span{display:block;color:#739ba8;font-size:8px}.lane-performance b{display:block;color:#dffaff;font:600 15px Rajdhani}
|
||||||
|
.risk-headline{height:67px;display:grid;grid-template-columns:auto 1fr auto auto;align-items:center;gap:5px;padding:0 12px;background:linear-gradient(90deg,rgba(255,65,93,.09),transparent);border-bottom:1px solid rgba(255,65,93,.13)}.risk-headline strong{color:#ff6e83;font:600 24px Rajdhani}.risk-headline span,.risk-headline small{color:#78959f;font-size:8px}.risk-headline b{color:#ff8697;font:600 14px Rajdhani}.risk-events button{width:100%;height:39px;display:grid;grid-template-columns:36px 1fr auto;align-items:center;gap:7px;border:0;border-bottom:1px solid rgba(25,217,255,.055);background:transparent;color:#d4f4fb;text-align:left;cursor:pointer}.risk-events button div b{display:block;font-size:9px}.risk-events button div span,.risk-events time{color:#658c99;font-size:7px}.quality-metrics>div{height:66px;display:grid;grid-template-columns:42px 1fr auto;grid-template-rows:1fr 1fr;align-items:center;gap:0 7px;border-bottom:1px solid rgba(25,217,255,.06)}.quality-metrics :deep(.freight-icon){grid-row:1/3}.quality-metrics span{align-self:end;color:#7b9fac;font-size:8px}.quality-metrics strong{grid-column:3;grid-row:1/3;color:#4fe0e9;font:600 15px Rajdhani}.quality-metrics>div>i{align-self:start;height:3px;background:rgba(25,217,255,.08)}.quality-metrics>div>i b{display:block;height:100%;background:linear-gradient(90deg,#157d9f,#4fe3b1)}
|
||||||
|
.cargo-evidence{position:relative;overflow:hidden;border:1px solid rgba(25,217,255,.18)}.cargo-evidence img{width:100%;height:100%;object-fit:cover;object-position:center 72%;filter:saturate(.45) brightness(.62)}.cargo-evidence__shade{position:absolute;inset:0;background:linear-gradient(180deg,rgba(3,15,24,.28),rgba(2,14,23,.88))}.cargo-evidence header{position:absolute;left:13px;top:13px;display:flex;align-items:center;gap:11px}.cargo-evidence header span{display:block;font-size:10px}.cargo-evidence header b{color:#4fe3b1;font:8px Rajdhani}.cargo-evidence__footer{position:absolute;left:9px;right:9px;bottom:9px;display:flex;justify-content:space-around;padding:8px;background:rgba(2,18,29,.82);color:#7aa2ae;font-size:7px}.cargo-evidence__footer span::before{content:'✓';margin-right:4px;color:#4fe3b1}
|
||||||
|
@keyframes executionIn{from{opacity:0;transform:translateX(24px);filter:blur(3px)}}
|
||||||
|
.execution-v2{animation:none}.waybill-photo img{object-fit:contain;object-position:center center;background:#020c13}.cargo-evidence img{object-position:center center}
|
||||||
|
.execution-kpis article{text-align:center}.execution-kpis article>div{min-width:105px;text-align:center}
|
||||||
|
.execution-kpis :deep(.freight-icon),.lane-performance :deep(.freight-icon),.quality-metrics :deep(.freight-icon),.risk-events :deep(.freight-icon){display:grid;place-items:center;justify-self:center;align-self:center}
|
||||||
|
.lane-performance>div{width:30%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center}
|
||||||
|
.quality-metrics>div{padding:0 8px}.quality-metrics span,.quality-metrics strong{align-self:center}
|
||||||
|
.cargo-evidence header{left:50%;right:auto;transform:translateX(-50%);justify-content:center;width:max-content;text-align:center}
|
||||||
|
.vehicle-driver{grid-template-columns:.9fr 1.5fr;grid-template-rows:1fr 32px;gap:5px 10px}.vehicle-driver__plate{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;background:rgba(10,55,72,.16)}.vehicle-driver__facts{grid-template-columns:1fr 1fr;grid-template-rows:repeat(3,1fr);gap:5px 9px;align-content:center}.vehicle-driver__facts>div{padding:4px 5px;border-left:2px solid rgba(25,217,255,.22);background:linear-gradient(90deg,rgba(25,217,255,.05),transparent)}.vehicle-driver__progress{grid-column:1/3;display:grid;grid-template-columns:52px 1fr 28px;align-items:center;gap:7px;color:#6d96a4;font-size:7px}.vehicle-driver__progress>i{height:4px;background:rgba(25,217,255,.09)}.vehicle-driver__progress>i b{display:block;height:100%;background:linear-gradient(90deg,#188eb0,#4fe3b1)}.vehicle-driver__progress strong{color:#4fe3b1;font:9px Rajdhani}
|
||||||
|
.lane-performance>div{padding:12px 10px 10px;background:linear-gradient(180deg,rgba(18,80,96,.1),transparent)}.lane-performance small{height:20px;margin-top:5px;color:#557d8a;font-size:7px}.lane-performance>div>i{width:100%;height:4px;margin-top:7px;background:rgba(25,217,255,.08)}.lane-performance>div>i em{display:block;height:100%;background:linear-gradient(90deg,#157d9f,#4fe3b1);font-style:normal}
|
||||||
|
.metric-flip-enter-active,.metric-flip-leave-active{display:inline-block;transition:opacity .2s ease,transform .2s ease,filter .2s ease}.metric-flip-enter-from{opacity:0;transform:translateY(8px) rotateX(-35deg);filter:brightness(2)}.metric-flip-leave-to{opacity:0;transform:translateY(-8px) rotateX(35deg)}
|
||||||
|
</style>
|
||||||
116
src/views/FreightDashboardV2.vue
Normal file
116
src/views/FreightDashboardV2.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, shallowRef } from 'vue'
|
||||||
|
import { useIntervalFn, useWindowSize } from '@vueuse/core'
|
||||||
|
import { cities } from '@/mock/freightData'
|
||||||
|
import { useFreightSimulation } from '@/composables/useFreightSimulation'
|
||||||
|
import OverviewScreenV2 from '@/views/OverviewScreenV2.vue'
|
||||||
|
import ExecutionScreenV2 from '@/views/ExecutionScreenV2.vue'
|
||||||
|
|
||||||
|
const {
|
||||||
|
orders, vehicles, alerts, routes, kpis, recentOrders, snapshot,
|
||||||
|
selectedRouteId, clockText, dateText, selectRoute,
|
||||||
|
} = useFreightSimulation()
|
||||||
|
|
||||||
|
const { width, height } = useWindowSize()
|
||||||
|
const scale = computed(() => Math.min(width.value / 1920, height.value / 1080))
|
||||||
|
const screenStyle = computed(() => ({ transform: `translate(-50%, -50%) scale(${scale.value})` }))
|
||||||
|
|
||||||
|
type ScreenName = 'overview' | 'safety'
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
const activeScreen = shallowRef<ScreenName>(params.get('screen') === 'safety' ? 'safety' : 'overview')
|
||||||
|
const autoplayEnabled = params.get('autoplay') !== '0'
|
||||||
|
const nextSwitchAt = shallowRef(Date.now() + 60_000)
|
||||||
|
const switchCountdown = shallowRef(60)
|
||||||
|
|
||||||
|
function switchScreen(screen: ScreenName, manual = true) {
|
||||||
|
activeScreen.value = screen
|
||||||
|
if (manual) nextSwitchAt.value = Date.now() + 60_000
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
url.searchParams.set('screen', screen)
|
||||||
|
window.history.replaceState(null, '', url)
|
||||||
|
}
|
||||||
|
|
||||||
|
useIntervalFn(() => {
|
||||||
|
switchCountdown.value = Math.max(0, Math.ceil((nextSwitchAt.value - Date.now()) / 1_000))
|
||||||
|
if (!autoplayEnabled || Date.now() < nextSwitchAt.value) return
|
||||||
|
switchScreen(activeScreen.value === 'overview' ? 'safety' : 'overview', false)
|
||||||
|
nextSwitchAt.value = Date.now() + 60_000
|
||||||
|
switchCountdown.value = 60
|
||||||
|
}, 1_000)
|
||||||
|
|
||||||
|
const mapScene = computed(() => ({
|
||||||
|
nodes: cities,
|
||||||
|
routes,
|
||||||
|
vehicles: [...vehicles],
|
||||||
|
alerts: [...alerts],
|
||||||
|
snapshot: snapshot.value,
|
||||||
|
selectedRouteId: selectedRouteId.value,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="viewport">
|
||||||
|
<div class="dashboard" :style="screenStyle">
|
||||||
|
<div class="atmosphere" aria-hidden="true">
|
||||||
|
<span v-for="i in 48" :key="i" :style="{ left: `${(i * 43) % 97}%`, top: `${8 + (i * 31) % 84}%`, width: `${2 + i % 3}px`, height: `${2 + i % 3}px`, animationDelay: `${i * -.28}s`, animationDuration: `${4 + (i % 5) * .7}s` }" />
|
||||||
|
</div>
|
||||||
|
<header class="header">
|
||||||
|
<div class="header__side">
|
||||||
|
<span class="status-dot" /> 全国数据链路正常
|
||||||
|
<nav class="screen-switch" aria-label="大屏页面切换">
|
||||||
|
<button :class="{ active: activeScreen === 'overview' }" @click="switchScreen('overview')"><i>01</i> 平台经营</button>
|
||||||
|
<button :class="{ active: activeScreen === 'safety' }" @click="switchScreen('safety')"><i>02</i> 履约风控</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="header__title">
|
||||||
|
<small>NETWORK FREIGHT INTELLIGENCE</small>
|
||||||
|
<h1>网络货运智慧运营中心</h1>
|
||||||
|
<div class="header__deco"><i /><i /><i /></div>
|
||||||
|
</div>
|
||||||
|
<div class="header__side header__side--right">
|
||||||
|
{{ dateText }}<strong>{{ clockText }}</strong>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<Transition name="screen" mode="out-in">
|
||||||
|
<OverviewScreenV2
|
||||||
|
v-if="activeScreen === 'overview'"
|
||||||
|
key="overview"
|
||||||
|
:snapshot="snapshot"
|
||||||
|
:kpis="kpis"
|
||||||
|
:routes="routes"
|
||||||
|
:orders="recentOrders"
|
||||||
|
:alerts="alerts"
|
||||||
|
:map-scene="mapScene"
|
||||||
|
:selected-route-id="selectedRouteId"
|
||||||
|
@select-route="selectRoute"
|
||||||
|
/>
|
||||||
|
<ExecutionScreenV2
|
||||||
|
v-else
|
||||||
|
key="safety"
|
||||||
|
:snapshot="snapshot"
|
||||||
|
:routes="routes"
|
||||||
|
:orders="orders"
|
||||||
|
:vehicles="vehicles"
|
||||||
|
:alerts="alerts"
|
||||||
|
:map-scene="mapScene"
|
||||||
|
:selected-route-id="selectedRouteId"
|
||||||
|
@select-route="selectRoute"
|
||||||
|
/>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<span>{{ activeScreen === 'overview' ? '平台经营与供需' : '运输履约与风控' }}</span><i />
|
||||||
|
数据更新时间 {{ clockText }}
|
||||||
|
<b>口径:当日 00:00 至当前 · 有效运单 · {{ autoplayEnabled ? `${switchCountdown}s 后自动切屏` : '自动切屏已关闭' }}</b>
|
||||||
|
<em>SIMULATION DATA · 场景模拟数据</em>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.viewport{width:100vw;height:100vh;position:relative;overflow:hidden;background:#020913}.dashboard{position:absolute;left:50%;top:50%;width:1920px;height:1080px;transform-origin:center;overflow:hidden;padding:0 28px 16px;background:linear-gradient(rgba(2,9,19,.72),rgba(2,9,19,.9)),url('@/assets/visuals/logistics-night.svg') center/cover no-repeat,linear-gradient(rgba(11,56,81,.08) 1px,transparent 1px),linear-gradient(90deg,rgba(11,56,81,.08) 1px,transparent 1px),radial-gradient(circle at 50% 45%,rgba(3,99,146,.22),transparent 44%),linear-gradient(145deg,#020812,#051927 54%,#020914);background-size:auto,cover,36px 36px,36px 36px,auto,auto}.dashboard::before{content:'';position:absolute;inset:0;pointer-events:none;background:linear-gradient(90deg,rgba(0,0,0,.4),transparent 20%,transparent 80%,rgba(0,0,0,.4))}.header{position:relative;z-index:2;height:70px;display:grid;grid-template-columns:1fr 660px 1fr;align-items:center;border-bottom:1px solid rgba(25,217,255,.15)}.header::after{content:'';position:absolute;left:27%;right:27%;bottom:-1px;height:2px;background:linear-gradient(90deg,transparent,var(--cyan),transparent)}.header__title{height:70px;text-align:center;position:relative;background:linear-gradient(180deg,rgba(8,50,75,.8),transparent);clip-path:polygon(7% 0,93% 0,100% 65%,88% 100%,12% 100%,0 65%)}.header__title h1{margin:2px 0 0;font-size:31px;letter-spacing:8px;font-weight:600}.header__title small{display:block;padding-top:4px;color:#4ba6bd;font:600 10px Rajdhani;letter-spacing:7px}.header__deco{display:flex;justify-content:center;gap:8px;margin-top:2px}.header__deco i{width:38px;height:2px;background:linear-gradient(90deg,transparent,#1edcff,transparent)}.header__side{color:#6995a6;font-size:11px;letter-spacing:1px}.header__side--right{text-align:right}.header__side--right strong{margin-left:18px;color:#c7f7ff;font:600 22px Rajdhani;letter-spacing:2px}.status-dot{display:inline-block;width:6px;height:6px;margin-right:7px;border-radius:50%;background:#42ebb1}.screen-switch{display:inline-flex;margin-left:26px;border:1px solid rgba(25,217,255,.2);background:rgba(2,15,26,.55);transform:skewX(-12deg)}.screen-switch button{min-width:100px;height:28px;border:0;border-right:1px solid rgba(25,217,255,.13);background:transparent;color:#5d8999;font-size:10px;letter-spacing:1px;cursor:pointer}.screen-switch button:last-child{border-right:0}.screen-switch button i{margin-right:5px;color:#346f84;font:600 9px Rajdhani}.screen-switch button.active{color:#d9f9ff;background:linear-gradient(90deg,rgba(25,217,255,.2),rgba(22,104,255,.11));box-shadow:inset 0 -2px var(--cyan)}.screen-switch button.active i{color:var(--cyan)}.footer{height:26px;display:flex;align-items:center;gap:10px;color:#456f7f;font-size:9px;letter-spacing:1px}.footer>span{color:#62cde4}.footer>i{width:5px;height:5px;border-radius:50%;background:#4fe3b1}.footer>b{margin-left:auto;color:#5e8997;font-weight:400}.footer>em{font-style:normal;color:#325a6a}
|
||||||
|
.atmosphere{position:absolute;inset:70px 0 26px;z-index:8;pointer-events:none}.atmosphere span{position:absolute;border-radius:50%;background:#7df1ff;opacity:.2;box-shadow:0 0 9px rgba(25,217,255,.9);animation:particleFlow 5s ease-in-out infinite}.status-dot,.footer>i{animation:statusBreath 2.2s ease-in-out infinite}.header__title h1{animation:titleBreath 3.8s ease-in-out infinite}
|
||||||
|
@keyframes particleFlow{0%{opacity:.06;transform:translateY(18px) scale(.65)}45%{opacity:.62;transform:translateY(-5px) scale(1.25)}100%{opacity:.04;transform:translateY(-34px) scale(.75)}}@keyframes statusBreath{0%,100%{opacity:.45;box-shadow:0 0 2px currentColor}50%{opacity:1;box-shadow:0 0 13px #42ebb1}}@keyframes titleBreath{0%,100%{text-shadow:0 0 4px rgba(25,217,255,.15)}50%{text-shadow:0 0 20px rgba(25,217,255,.65)}}
|
||||||
|
</style>
|
||||||
191
src/views/OverviewScreenV2.vue
Normal file
191
src/views/OverviewScreenV2.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<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>
|
||||||
20
tsconfig.app.json
Normal file
20
tsconfig.app.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": { "@/*": ["src/*"] },
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user