初始化网络货运智慧运营双屏大屏项目。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-11 09:47:30 +08:00
commit ffc0f7421c
31 changed files with 3741 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
.vite
*.log
.DS_Store
*.tsbuildinfo

23
README.md Normal file
View 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
View 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 图标。

View 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
View 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

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View 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
View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import FreightDashboard from '@/views/FreightDashboardV2.vue'
</script>
<template>
<FreightDashboard />
</template>

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

View 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

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,4 @@
declare module 'china-map-data/china' {
const chinaGeoJson: unknown
export default chinaGeoJson
}

117
src/types/freight.ts Normal file
View 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
}

View 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>

View 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>

View 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
View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

12
tsconfig.node.json Normal file
View 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
View 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)) },
},
})