dkwang 2026-08-28 13:05:55 +08:00
commit 9686f58e97
54 changed files with 2730 additions and 637 deletions

View File

@ -1,3 +1,3 @@
# 开发环境配置 # 开发环境配置
# 使用相对路径,通过 vite proxy 代理到目标服务器 # 留空则通过 vite proxy 代理到 http://101.133.172.2:8082
VITE_API_BASE_URL= VITE_API_BASE_URL=

View File

@ -1,2 +1,2 @@
# 生产环境配置 # 生产环境配置 目前是配置代理的方式
VITE_API_BASE_URL=http://10.104.10.151:8888 VITE_API_BASE_URL=/juntech-ysdp

51
.gitignore vendored
View File

@ -1,2 +1,53 @@
# 依赖
node_modules node_modules
# 构建产物
dist dist
dist-ssr
*.local
# 项目文档(本地维护,不纳入版本库)
doc/
# 环境变量(保留 .env.development / .env.production忽略本地覆盖
.env.local
.env.*.local
# 日志
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# 编辑器 / IDE
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# 操作系统
.DS_Store
Thumbs.db
Desktop.ini
# 测试 / 覆盖率
coverage
*.lcov
.nyc_output
# 缓存 / 临时文件
.cache
.temp
.tmp
*.tsbuildinfo
.eslintcache
.stylelintcache
# Vite
vite.config.js.timestamp-*

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 230 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 197 KiB

After

Width:  |  Height:  |  Size: 307 KiB

View File

@ -15,7 +15,7 @@ const props = defineProps({
// //
glowImage: { glowImage: {
type: String, type: String,
default: '/imgs/guangyun.png' default: './imgs/guangyun.png'
}, },
// 线 // 线
showRing: { showRing: {

View File

@ -0,0 +1,102 @@
import { ref, watch, onMounted } from 'vue'
import server from '@/utils/service'
import metroMenuData from '@/utils/lineStationList.json'
const localLineStationList = metroMenuData.lineStationList || []
function mapLocalLines() {
return localLineStationList.map(item => ({
label: item.nameCn,
value: item.lineId
}))
}
function mapLocalStations(lineId) {
const currentLine = localLineStationList.find(item => item.lineId === lineId)
return (currentLine?.stationList || []).map(item => ({
label: item.nameCn,
value: item.statId
}))
}
/** 客运页线路/站点下拉:优先接口,空数据时回退本地 JSON */
export function useTrafficLineStation(getLine, getStation, emit) {
const lineOptions = ref([])
const stationOptions = ref([])
const loadLines = async () => {
try {
const response = await server.getLineList()
if (response.data.success && response.data.data?.length) {
lineOptions.value = response.data.data.map(item => ({
label: item.name,
value: String(item.lineId)
}))
return
}
} catch (error) {
console.error('获取线路列表失败:', error)
}
lineOptions.value = mapLocalLines()
}
const loadStations = async lineId => {
if (!lineId) {
stationOptions.value = []
return
}
try {
const response = await server.getStationsByLine(lineId)
if (response.data.success && response.data.data?.length) {
stationOptions.value = response.data.data.map(item => ({
label: item.stationName || item.name || item.nameCn,
value: item.stationId || item.statId
}))
syncStationSelection()
return
}
} catch (error) {
console.error('获取站点列表失败:', error)
}
stationOptions.value = mapLocalStations(lineId)
syncStationSelection()
}
const syncStationSelection = () => {
const options = stationOptions.value
if (!options.length) {
emit('update:station', '')
return
}
const currentStation = getStation()
const hasCurrent = options.some(item => item.value === currentStation)
if (!hasCurrent) {
emit('update:station', options[0].value)
}
}
const initLineSelection = () => {
if (!lineOptions.value.length) return
const currentLine = getLine()
const hasCurrent = lineOptions.value.some(item => item.value === currentLine)
if (!hasCurrent) {
emit('update:line', lineOptions.value[0].value)
}
}
watch(
() => getLine(),
lineId => loadStations(lineId),
{ immediate: true }
)
onMounted(async () => {
await loadLines()
initLineSelection()
await loadStations(getLine())
})
return { lineOptions, stationOptions }
}

View File

@ -33,6 +33,18 @@ const routes = [
name: 'Traffic', name: 'Traffic',
component: () => import('@/views/traffic/index.vue'), component: () => import('@/views/traffic/index.vue'),
meta: { title: '客运', requiresAuth: true } meta: { title: '客运', requiresAuth: true }
},
{
path: 'driving',
name: 'Driving',
component: () => import('@/views/driving/index.vue'),
meta: { title: '行车', requiresAuth: true }
},
{
path: 'construction',
name: 'Construction',
component: () => import('@/views/construction/index.vue'),
meta: { title: '施工', requiresAuth: true }
} }
], ],
meta: { requiresAuth: true } meta: { requiresAuth: true }

View File

@ -1,10 +1,9 @@
import { request } from './request' import { request } from './request'
import { encrypt } from './decrypt' import { encrypt } from './decrypt'
import axios from 'axios' import { ysdpPost } from './ysdpRequest'
var ipRoot = "http://140.206.138.190:8900" var ipRoot = "http://140.206.138.190:8900"
var ip = "http://140.206.138.190:8905" var ip = "http://140.206.138.190:8905"
var ysdpmIp = import.meta.env.VITE_API_BASE_URL || "http://192.168.16.193:8888"
var getToken = "/app/getToken" var getToken = "/app/getToken"
var getTblMetroInout = ipRoot + '/api/xckf/getTblMetroInout/v1' var getTblMetroInout = ipRoot + '/api/xckf/getTblMetroInout/v1'
@ -22,14 +21,6 @@ var getBusInfo = ipRoot + '/api/xckf/getBusInfo/v1'
var updayeBusEntrance = ipRoot + '/api/xckf/updayeBusEntrance/v1' var updayeBusEntrance = ipRoot + '/api/xckf/updayeBusEntrance/v1'
var getHotEventList = ipRoot + '/api/xckf/getHotEventList/v1' var getHotEventList = ipRoot + '/api/xckf/getHotEventList/v1'
var updateHotEvent = ipRoot + '/api/xckf/updateHotEvent/v1' var updateHotEvent = ipRoot + '/api/xckf/updateHotEvent/v1'
var getOperateInfo = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getOperateInfo/v1'
var getCommonMonitor = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getCommonMonitor/v1'
var getDutyList = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getDutyList/v1'
var getStationMaxList = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getStationMaxList/v1'
var getIndicator = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getIndicator/v1'
var getYunyingInfo = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getYunyingInfo/v1'
var getQxjGdybHour = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getQxjGdybHour/v1'
var getTaskNotice = (import.meta.env.DEV ? '' : import.meta.env.VITE_API_BASE_URL) + '/juntech-ysdp/api/getTaskNotice/v1'
var xcImg = ip + '/1734.jpg' var xcImg = ip + '/1734.jpg'
var xcImgGd = ip + '/lineCrossImg/1734.jpg' var xcImgGd = ip + '/lineCrossImg/1734.jpg'
@ -142,35 +133,75 @@ var server = {
}, },
getOperateInfo() { getOperateInfo() {
return axios.post(getOperateInfo, {}) return ysdpPost('/api/getOperateInfo/v1')
}, },
getCommonMonitor() { getCommonMonitor() {
return axios.post(getCommonMonitor, {}) return ysdpPost('/api/getCommonMonitor/v1')
}, },
getDutyList() { getDutyList() {
return axios.post(getDutyList, {}) return ysdpPost('/api/getDutyList/v1')
}, },
getStationMaxList() { getStationMaxList() {
return axios.post(getStationMaxList, {}) return ysdpPost('/api/getStationMaxList/v1')
}, },
getIndicator() { getIndicator() {
return axios.post(getIndicator, {}) return ysdpPost('/api/getIndicator/v1')
}, },
getQxjGdybHour() { getQxjGdybHour() {
return axios.post(getQxjGdybHour, {}) return ysdpPost('/api/getQxjGdybHour/v1')
}, },
getYunyingInfo() { getYunyingInfo() {
return axios.post(getYunyingInfo, {}) return ysdpPost('/api/getYunyingInfo/v1')
}, },
getTaskNotice() { getTaskNotice() {
return axios.post(getTaskNotice, {}) return ysdpPost('/api/getTaskNotice/v1')
},
getInOutStationRank() {
return ysdpPost('/api/traffic/getInOutStationRank/v1')
},
getTransferStationRank() {
return ysdpPost('/api/traffic/getTransferStationRank/v1')
},
getLineList() {
return ysdpPost('/api/traffic/getLineList/v1')
},
getLinePassengerFlow() {
return ysdpPost('/api/traffic/getLinePassengerFlow/v1')
},
getStationPassengerFlow(stationId) {
return ysdpPost('/api/traffic/getStationPassengerFlow/v1', { stationId })
},
getStationsByLine(lineId) {
return ysdpPost('/api/traffic/getStationsByLine/v1', { lineId })
},
getBigActivityList({ metroLine, metroStation, activityMonth }) {
return ysdpPost('/api/traffic/getBigActivityList/v1', {
metroLine,
metroStation,
activityMonth
})
},
cleanTrafficCache() {
return ysdpPost('/api/traffic/cleanCache')
},
cleanApiCache() {
return ysdpPost('/api/cleanApiCache')
} }
} }

103
src/utils/traffic.js Normal file
View File

@ -0,0 +1,103 @@
const TIME_KEYS = ['time1', 'time2', 'time3', 'time4', 'time5', 'time6', 'time7', 'time8', 'time9', 'time10']
/** 从 time1~time10 对象提取时序数值 */
export function extractTimeValues(dataObj) {
return TIME_KEYS.map(key => {
const value = dataObj?.[key]
return value ? parseFloat(value) : 0
})
}
/** 将单站客流接口数据转为 Traffic4 图表结构 */
export function processStationPassengerFlow(data) {
if (!data) return null
return {
compareDate: data.compareDate || '',
inOut: {
actual: extractTimeValues(data.inOut?.today),
compare: extractTimeValues(data.inOut?.compareDay)
},
transfer: {
actual: extractTimeValues(data.transfer?.today),
compare: extractTimeValues(data.transfer?.compareDay)
}
}
}
/** 计算图表 Y 轴最大值(按万取整) */
export function calcChartYAxisMax(...seriesList) {
const maxValue = Math.max(...seriesList.flat(), 0)
return Math.ceil(maxValue / 10000) * 10000 || 10000
}
/** 将接口 lineId如 07、04,07转为页面展示格式7、4,7 */
export function normalizeLineIds(lineId) {
if (!lineId) return ''
return String(lineId)
.split(',')
.map(id => String(parseInt(id.trim(), 10)))
.filter(id => id && id !== 'NaN')
.join(',')
}
/** 客流数值转万人(保留两位小数) */
export function toWanRen(value) {
const num = parseFloat(value) || 0
return (num / 10000).toFixed(2)
}
const ACTIVITY_STATUS_TYPE_MAP = {
进行中: 'active',
已结束: 'ended',
未开始: 'pending'
}
/** 活动月份格式化为 YYYY-MM */
export function formatActivityMonth(month) {
if (!month) return ''
const [year, monthValue] = String(month).split('-')
if (!year || !monthValue) return ''
return `${year}-${String(monthValue).padStart(2, '0')}`
}
/** 将重大活动清单接口数据映射为 Traffic7 所需结构 */
export function processBigActivityList(data) {
if (!Array.isArray(data)) return []
return data.map(item => {
const startDate = item.startDate ? item.startDate.replace(/-/g, '/') : ''
const endDate = item.endDate ? item.endDate.replace(/-/g, '/') : ''
return {
id: item.id,
name: item.activityName || '',
time: startDate && endDate ? `${startDate}--${endDate}` : startDate || endDate,
venue: item.venue || '',
status: item.status || '',
statusType: ACTIVITY_STATUS_TYPE_MAP[item.status] || 'pending'
}
})
}
/** 将站点排名接口数据映射为 TrafficRankRow 所需结构 */
export function mapStationRankList(data, type = '进出站') {
if (!Array.isArray(data) || !data.length) return []
const maxTotal = Math.max(...data.map(item => parseFloat(item.total) || 0))
return data.map((item, index) => {
const total = parseFloat(item.total) || 0
const compare = parseFloat(item.compareTotal) || 0
return {
rank: index + 1,
lines: normalizeLineIds(item.lineId),
station: item.stationName || '',
type,
today: toWanRen(total),
compare: toWanRen(compare),
percent: maxTotal > 0 ? Math.round((total / maxTotal) * 100) : 0
}
})
}

22
src/utils/ysdpRequest.js Normal file
View File

@ -0,0 +1,22 @@
import axios from 'axios'
// 开发环境留空时默认 /juntech-ysdp走 vite proxy生产环境 VITE_API_BASE_URL 已含此前缀,不再重复拼接
const ysdpRequest = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/juntech-ysdp',
timeout: 30000
})
/** POST 请求,参数以 application/x-www-form-urlencoded 提交 */
export function ysdpPost(path, data = {}) {
const body = new URLSearchParams()
Object.entries(data).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
body.append(key, value)
}
})
return ysdpRequest.post(path, body, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
}
export default ysdpRequest

52
src/utils/yunying.js Normal file
View File

@ -0,0 +1,52 @@
const TIME_KEYS = ['time1', 'time2', 'time3', 'time4', 'time5', 'time6', 'time7', 'time8', 'time9', 'time10']
/** 根据当前时刻计算对应的 time 索引time1~time10 */
export function getCurrentTimeIndex() {
const hour = new Date().getHours()
if (hour < 5) return 0
return Math.min(Math.ceil((hour - 5) / 2), 9)
}
function getTimeValues(dataObj) {
return TIME_KEYS.map(key => {
const value = dataObj?.[key]
return value ? parseFloat(value) : 0
})
}
/** 将 getYunyingInfo 接口数据转为图表组件所需结构 */
export function processYunyingInfo(dataList) {
if (!Array.isArray(dataList)) return []
const currentTimeIndex = getCurrentTimeIndex()
return dataList.map(item => {
const barData = getTimeValues(item.today)
const yellowData = getTimeValues(item.compareDay)
const redData = getTimeValues(item.maxDay)
const maxValue = redData[currentTimeIndex]
const compareMaxValue = yellowData[currentTimeIndex]
const totalSum = item.today?.totalSum ? parseFloat(item.today.totalSum) : 0
const total = barData.reduce((sum, val) => sum + val, 0)
const totalStr = Math.floor(total).toString()
return {
lineId: parseInt(item.line, 10),
line: parseInt(item.line, 10),
total: totalStr,
actual: barData,
compare: yellowData,
history: redData,
barData,
yellowData,
redData,
digits: totalStr.split('').map(Number),
compareDate: item.compareDate || '',
maxDate: item.maxDay?.maxDate || '',
maxValue,
compareMaxValue,
totalSum
}
})
}

View File

@ -0,0 +1,12 @@
<template>
<div class="construction-container" />
</template>
<style lang="scss" scoped>
.construction-container {
width: 4800px;
height: 1514px;
position: relative;
overflow: hidden;
}
</style>

View File

@ -0,0 +1,312 @@
<template>
<section class="driving-1">
<div class="column-select">
<MetroLineSelect v-model="activeLine" />
</div>
<div class="vehicle-table">
<div class="table-header table-grid">
<span>车站</span>
<span>图号</span>
<span>用车数</span>
<span>当前计划用车数</span>
<span>行车间隔</span>
</div>
<el-scrollbar class="table-body">
<div class="table-body-inner">
<div v-for="item in vehicles" :key="item.id" class="table-row table-grid">
<span class="station">{{ item.station }}</span>
<span class="diagram">{{ item.diagram }}</span>
<span class="vehicles">{{ item.vehicles }}</span>
<span class="plan">
{{ item.plan }}<b :class="item.change > 0 ? 'rise' : 'fall'">{{ formatChange(item.change) }}</b>
</span>
<el-popover placement="top" trigger="hover" :width="560" :offset="34" :fallback-placements="[]"
:teleported="false" :show-after="150" :hide-after="80" popper-class="driving-row-popover">
<template #reference>
<div class="intervals">
<span class="interval-route">{{ item.interval.section }}</span>
<div class="interval-directions">
<div class="interval-direction">
<img src="/imgs/driving/1-item-1.png" alt="上行" />
<span>{{ item.interval.up }}</span>
</div>
<div class="interval-direction">
<img src="/imgs/driving/1-item-2.png" alt="下行" />
<span>{{ item.interval.down }}</span>
</div>
</div>
</div>
</template>
<div class="row-popover-content">
<div class="popover-overview">{{ intervalPopover.overview }}</div>
<div v-for="row in intervalPopover.rows" :key="row.section" class="popover-interval">
<span class="popover-section">{{ row.section }}</span>
<span class="popover-time"><img src="/imgs/driving/1-item-1.png" alt="上行" />{{ row.up }}</span>
<span class="popover-time"><img src="/imgs/driving/1-item-2.png" alt="下行" />{{ row.down }}</span>
</div>
</div>
</el-popover>
</div>
</div>
</el-scrollbar>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
import MetroLineSelect from './MetroLineSelect.vue'
const activeLine = ref(7)
const vehicles = [
{ id: 1, station: '上海南站', diagram: '1706-2', vehicles: 54, plan: 4, change: 2, interval: { section: '东川路站~奉贤新城站', up: '3分45秒', down: '2分13秒' } },
{ id: 2, station: '石龙路', diagram: '1706-2', vehicles: 54, plan: 4, change: 1, interval: { section: '嘉定新城站~南翔站', up: '3分45秒', down: '2分13秒' } },
{ id: 3, station: '龙漕路', diagram: '1706-2', vehicles: 54, plan: 4, change: -1, interval: { section: '三林站~罗山路站', up: '3分45秒', down: '2分13秒' } },
{ id: 4, station: '漕溪路', diagram: '1706-2', vehicles: 54, plan: 4, change: 2, interval: { section: '花桥站~嘉定新城站', up: '3分45秒', down: '2分13秒' } },
{ id: 5, station: '龙漕路', diagram: '1706-2', vehicles: 54, plan: 4, change: -1, interval: { section: '嘉定北站~嘉定新城站', up: '3分45秒', down: '2分13秒' } },
{ id: 6, station: '漕溪路', diagram: '1706-2', vehicles: 54, plan: 4, change: 2, interval: { section: '罗山路站~迪士尼站', up: '3分45秒', down: '2分13秒' } },
{ id: 7, station: '龙漕路', diagram: '1706-2', vehicles: 54, plan: 4, change: -1, interval: { section: '东川路站~闵行开发区站', up: '7分30秒', down: '5分20秒' } },
{ id: 8, station: '漕溪路', diagram: '1706-2', vehicles: 54, plan: 4, change: 2, interval: { section: '嘉定新城站~南翔站', up: '3分45秒', down: '2分13秒' } },
{ id: 9, station: '漕溪路', diagram: '1706-2', vehicles: 54, plan: 4, change: 2, interval: { section: '嘉定新城站~南翔站', up: '3分45秒', down: '2分13秒' } },
{ id: 10, station: '漕溪路', diagram: '1706-2', vehicles: 54, plan: 4, change: 2, interval: { section: '嘉定新城站~南翔站', up: '3分45秒', down: '2分13秒' } }
]
const intervalPopover = {
overview: '东川路站~闵行开发区站平均7分30秒(3分~5分)',
rows: [
{ section: '嘉定新城站~南翔站:', up: '平均3分45秒', down: '平均2分13秒' },
{ section: '三林站~罗山路站:', up: '平均3分45秒', down: '平均2分13秒' },
{ section: '花桥站~嘉定新城站:', up: '平均3分45秒', down: '平均2分13秒' },
{ section: '嘉定北~嘉定新城站:\n罗山路站~迪士尼站:', up: '平均3分45秒', down: '平均2分13秒' }
]
}
const formatChange = value => `${value > 0 ? '+' : '-'}${Math.abs(value)}`
</script>
<style lang="scss" scoped>
.driving-1 {
position: absolute;
left: 11px;
top: 123.99px;
width: 1255.52px;
height: 895.21px;
color: #d9ebff;
background: url('/imgs/driving/1-bg.png') no-repeat center center;
background-size: cover;
overflow: hidden;
}
.column-select {
position: absolute;
top: 61px;
right: 27px;
z-index: 2;
}
.vehicle-table {
position: absolute;
top: 115px;
right: 17px;
bottom: 27px;
left: 17px;
display: flex;
min-height: 0;
flex-direction: column;
}
.table-grid {
display: grid;
grid-template-columns: 220px 145px 115px 145px minmax(0, 1fr);
align-items: center;
}
.table-header {
height: 46px;
padding: 0 15px;
color: #FFFFFF;
font-size: 24px;
text-align: center;
background: linear-gradient(90deg, #0094ff00 -7%, #0085ff4d 50%, #0047ff00 99%), rgba(0, 87, 255, 0.05);
span {
text-align: center;
white-space: nowrap;
}
}
.table-body {
min-height: 0;
flex: 1;
}
.table-body-inner {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 16px;
padding-right: 4px;
}
.table-row {
flex: 0 0 74px;
height: 66px;
line-height: 66px;
padding: 0 15px;
font-size: 20px;
text-align: center;
cursor: default;
}
.station {
background: linear-gradient(114deg, #3485ff33 4%, #3e8bff20 44%, #2eadff10 104%);
}
.diagram {
font-size: 23px;
color: #19CFD6;
background: linear-gradient(130deg, #3485ff32 3%, #3e8bff26 47%, #2eadff1d 112%);
}
.vehicles {
font-size: 25px;
font-weight: normal;
background: linear-gradient(131deg, #3485ff32 3%, #3e8bff26 47%, #2eadff1d 113%);
}
.plan {
font-size: 25px;
background: linear-gradient(131deg, #3485ff32 3%, #3e8bff26 47%, #2eadff1d 113%);
}
.rise,
.fall {
color: #FF0000;
}
.intervals {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 170px;
min-width: 0;
align-items: center;
padding: 0 12px 0 16px;
cursor: default;
text-align: left;
background: linear-gradient(99deg, #3485ff32 5%, #3e8bff26 43%, #2eadff0e 101%);
&:hover {
background: linear-gradient(90deg, rgba(35, 113, 211, .96), rgba(24, 91, 178, .88));
box-shadow: inset 0 0 20px rgba(58, 174, 255, .22);
}
}
.interval-route {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.interval-directions {
min-width: 0;
}
.interval-direction {
display: flex;
align-items: center;
height: 32px;
white-space: nowrap;
img {
width: 24px;
height: 24px;
margin-right: 10px;
object-fit: contain;
}
}
</style>
<style lang="scss">
.el-popper.driving-row-popover {
padding: 13px 31px 15px;
border: 1px solid #087bce;
border-radius: 9px;
color: #dce9fa;
background: rgba(2, 22, 61, .98);
box-shadow: inset 0 0 18px rgba(0, 106, 213, .1), 0 5px 14px rgba(0, 8, 32, .32);
.el-popper__arrow {
display: none;
}
&[data-popper-placement^='top']::before,
&[data-popper-placement^='top']::after {
position: absolute;
left: 77px;
content: '';
clip-path: polygon(0 0, 100% 0, 0 100%);
}
&[data-popper-placement^='top']::before {
bottom: -40px;
width: 103px;
height: 41px;
background: #087bce;
}
&[data-popper-placement^='top']::after {
bottom: -37px;
left: 78px;
width: 99px;
height: 38px;
background: #02163d;
}
}
.row-popover-content {
font-size: 17px;
line-height: 24px;
}
.popover-overview {
height: 31px;
white-space: nowrap;
}
.popover-interval {
display: grid;
grid-template-columns: 200px 149px 149px;
min-height: 43px;
align-items: center;
}
.popover-section {
padding-right: 10px;
white-space: pre-line;
}
.popover-time {
display: flex;
align-items: center;
white-space: nowrap;
img {
flex: 0 0 24px;
width: 24px;
height: 24px;
margin-right: 9px;
object-fit: contain;
}
}
</style>

View File

@ -0,0 +1,138 @@
<template>
<section class="driving-2">
<div class="column-select">
<MetroLineSelect v-model="activeLine" />
</div>
<div class="station-table">
<div class="table-header table-grid">
<span>车站</span>
<span>供电</span>
<span>车辆</span>
<span>车队</span>
<span>通号</span>
<span>保护区</span>
<span>轨行区</span>
</div>
<el-scrollbar class="table-body">
<div class="table-body-inner">
<div v-for="item in stationRanks" :key="item.id" class="table-row table-grid">
<span class="station">{{ item.station }}</span>
<span>{{ item.power }}</span>
<span>{{ item.vehicle }}</span>
<span>{{ item.fleet }}</span>
<span>{{ item.signal }}</span>
<span>{{ item.protection }}</span>
<span>{{ item.track }}</span>
</div>
</div>
</el-scrollbar>
</div>
</section>
</template>
<script setup>
import { ref } from 'vue'
import MetroLineSelect from './MetroLineSelect.vue'
const activeLine = ref(7)
const stationRanks = [
{ id: 1, station: '上海南站', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 2, station: '石龙路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 3, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 4, station: '漕溪路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 5, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 6, station: '虹桥火车站', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 7, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 8, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 9, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 10, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 11, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 12, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 },
{ id: 13, station: '龙漕路', power: 23, vehicle: 56, fleet: 45, signal: 7, protection: 46, track: 5 }
]
</script>
<style lang="scss" scoped>
.driving-2 {
position: absolute;
left: 1277.92px;
top: 123.99px;
width: 865.4px;
height: 894px;
color: #d9ebff;
background: url('/imgs/driving/2-bg.png') no-repeat center center;
background-size: cover;
overflow: hidden;
}
.column-select {
position: absolute;
top: 61px;
right: 27px;
z-index: 2;
}
.station-table {
position: absolute;
top: 115px;
right: 13px;
bottom: 22px;
left: 13px;
display: flex;
min-height: 0;
flex-direction: column;
}
.table-grid {
display: grid;
grid-template-columns: 130px repeat(6, minmax(0, 1fr));
align-items: center;
}
.table-header {
flex: 0 0 49px;
padding: 0 11px;
color: #FFFFFF;
font-size: 24px;
text-align: center;
background: linear-gradient(90deg, #0094ff00 -7%, #0085ff4d 50%, #0047ff00 99%), rgba(0, 87, 255, 0.05);
span:first-child {
text-align: left;
}
}
.table-body {
min-height: 0;
flex: 1;
}
.table-body-inner {
display: flex;
flex-direction: column;
gap: 16px;
padding-top: 16px;
}
.table-row {
flex: 0 0 48px;
height: 48px;
padding: 0 11px;
border-bottom: 1px solid rgba(0, 138, 255, .72);
font-size: 20px;
text-align: center;
background: linear-gradient(90deg, #0094ff00 -7%, #0085ff4d 50%, #0047ff00 99%), rgba(0, 87, 255, 0.05);
}
.station {
overflow: hidden;
font-weight: 700;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,121 @@
<template>
<section class="driving-3">
<img class="rotating-visual" src="/imgs/driving/3-item-1.png" alt="" />
<span v-for="item in smallBallCounts" :key="item.name" class="small-ball-count"
:style="{ left: `${item.left}px`, top: `${item.top}px`, fontSize: `${item.fontSize}px` }">{{
constructionStats[item.key] }}</span>
<div class="total-count">
<span>总施工量</span>
<strong>{{ formatNumber(constructionStats.total) }}</strong>
</div>
</section>
</template>
<script setup>
import { reactive } from 'vue'
const constructionStats = reactive({
protection: 6,
database: 6,
signal: 183,
generalAffairs: 19,
power: 82,
vehicle: 1,
track: 20,
fleet: 30,
total: 63935
})
const smallBallCounts = [
{ name: '保护区', key: 'protection', left: 125, top: 105, fontSize: 30 },
{ name: '数据库', key: 'database', left: 354, top: 121, fontSize: 38 },
{ name: '通号', key: 'signal', left: 541, top: 158, fontSize: 34 },
{ name: '总务', key: 'generalAffairs', left: 787, top: 108, fontSize: 30 },
{ name: '供电', key: 'power', left: 190, top: 278, fontSize: 38 },
{ name: '车辆', key: 'vehicle', left: 700, top: 256, fontSize: 38 },
{ name: '轨行区', key: 'track', left: 117, top: 486, fontSize: 34 },
{ name: '车队', key: 'fleet', left: 765, top: 480, fontSize: 36 }
]
const formatNumber = value => Number(value ?? 0).toLocaleString('en-US')
</script>
<style lang="scss" scoped>
.driving-3 {
position: absolute;
left: 2155.13px;
top: 123.99px;
width: 866.87px;
height: 926.8px;
opacity: 1;
display: flex;
flex-direction: column;
background: url('/imgs/driving/3-bg.png') no-repeat center center;
background-size: cover;
}
.rotating-visual {
position: absolute;
top: 328px;
left: 250px;
width: 380px;
height: 379px;
object-fit: contain;
transform-origin: center;
animation: driving-3-rotate 18s linear infinite;
pointer-events: none;
}
.small-ball-count {
position: absolute;
z-index: 2;
color: #fff;
font-weight: 700;
line-height: 1;
text-align: center;
text-shadow: 0 2px 4px rgba(0, 38, 118, .7);
transform: translateX(-50%);
white-space: nowrap;
pointer-events: none;
}
.total-count {
position: absolute;
top: 451px;
left: 250px;
z-index: 2;
display: flex;
width: 380px;
flex-direction: column;
align-items: center;
color: #fff;
text-align: center;
pointer-events: none;
span {
font-size: 42px;
line-height: 52px;
text-shadow: 0px 0px 10px rgba(30, 121, 255, 0.8);
}
strong {
margin-top: 18px;
font-size: 50px;
font-weight: 600;
line-height: 62px;
text-shadow: 0px 1px 4px rgba(7, 44, 137, 0.5);
}
}
@keyframes driving-3-rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@ -0,0 +1,20 @@
<template>
<DrivingChartPanel
v-model:line="line"
v-model:date="date"
:chart-data="chartData"
/>
</template>
<script setup>
import { ref } from 'vue'
import DrivingChartPanel from './DrivingChartPanel.vue'
const line = ref(7)
const date = ref('2026-06-30')
const chartData = {
dates: ['01/21', '01/22', '01/23', '01/24', '01/25', '01/26'],
current: [98.8, 100.15, 101.3, 99.95, 100.42, 99.35],
previous: [98.55, 99.7, 100.08, 99.68, 100.88, 99.32]
}
</script>

View File

@ -0,0 +1,203 @@
<template>
<section class="driving-5">
<div class="metric-cards">
<article v-for="card in metricCards" :key="card.key" class="metric-card"
:class="`metric-card--${card.type}`">
<h3>{{ card.title }}</h3>
<div class="primary-value">
<strong>{{ card.value }}</strong>
<span v-if="card.unit">{{ card.unit }}</span>
</div>
<div class="secondary-value">
<span>{{ card.secondaryLabel }}</span>
<strong>{{ card.secondaryValue }}</strong>
<small v-if="card.secondaryUnit">{{ card.secondaryUnit }}</small>
</div>
</article>
</div>
</section>
</template>
<script setup>
import { reactive } from 'vue'
const metricCards = reactive([
{
key: 'assignedMileage',
type: 'basic',
title: '配属车辆',
value: '774',
unit: '公里',
secondaryLabel: '车站数',
secondaryValue: '460',
secondaryUnit: '座'
},
{
key: 'assignedVehicles',
type: 'basic',
title: '配属车辆',
value: '7154',
unit: '辆',
secondaryLabel: '列车数',
secondaryValue: '1116',
secondaryUnit: '座'
},
{
key: 'headway',
type: 'basic',
title: '行车间隔',
value: '150″',
unit: '',
secondaryLabel: '中心城区',
secondaryValue: '150″',
secondaryUnit: ''
},
{
key: 'dailyPassengerFlow',
type: 'gauge',
title: '日均客流',
value: '965.5',
unit: '万人',
secondaryLabel: '目标值',
secondaryValue: '915',
secondaryUnit: '万人'
},
{
key: 'fiveMinuteDelay',
type: 'gauge',
title: "5晚点",
value: '940',
unit: '万车公里',
secondaryLabel: '目标值',
secondaryValue: '750',
secondaryUnit: ''
},
{
key: 'punctualityRate',
type: 'gauge',
title: '正点率',
value: '99.9',
unit: '%',
secondaryLabel: '目标值',
secondaryValue: '99.7',
secondaryUnit: '%'
}
])
</script>
<style lang="scss" scoped>
.driving-5 {
position: absolute;
left: 3856.58px;
top: 128px;
width: 920.56px;
height: 717.6px;
color: #fff;
font-family: 'AlibabaPuHuiTi', 'Microsoft YaHei', sans-serif;
background: url('/imgs/driving/5-bg.png') no-repeat center center;
background-size: cover;
overflow: hidden;
}
.metric-cards {
position: absolute;
top: 128px;
left: 46px;
display: grid;
grid-template-columns: repeat(3, 234px);
grid-auto-rows: 229px;
gap: 54px 63px;
}
.metric-card {
position: relative;
width: 234px;
height: 229px;
text-align: center;
background-repeat: no-repeat;
background-position: center;
background-size: 100% 100%;
&--basic {
background-image: url('/imgs/driving/5-item-1.png');
}
&.metric-card--gauge {
background-image: url('/imgs/driving/5-item-2.png');
h3 {
top: 14px;
}
.primary-value {
top: 118px;
}
.secondary-value {
top: 170px;
}
}
h3 {
position: absolute;
top: 17px;
left: 0;
width: 100%;
margin: 0;
font-size: 28px;
line-height: normal;
letter-spacing: 0;
}
}
.primary-value {
position: absolute;
top: 91px;
left: 0;
display: flex;
width: 100%;
align-items: baseline;
justify-content: center;
white-space: nowrap;
strong {
font-size: 42px;
font-weight: 700;
line-height: 48px;
letter-spacing: 0;
text-shadow: 0 3px 5px rgba(0, 22, 79, .68);
}
span {
margin-left: 3px;
font-size: 20px;
}
}
.secondary-value {
position: absolute;
top: 158px;
left: 0;
display: flex;
width: 100%;
align-items: baseline;
justify-content: center;
font-size: 24px;
line-height: normal;
white-space: nowrap;
strong {
margin-left: 13px;
font-size: 24px;
font-weight: 700;
}
small {
margin-left: 3px;
font-size: 14px;
}
}
</style>

View File

@ -0,0 +1,32 @@
<template>
<section class="driving-6">
</section>
</template>
<script setup>
import { ref } from 'vue'
</script>
<style lang="scss" scoped>
.driving-6 {
position: absolute;
left: 16px;
top: 1052px;
width: 3009px;
height: 535px;
opacity: 1;
display: flex;
flex-direction: column;
background: url('/imgs/driving/6-bg.png') no-repeat center center;
background-size: cover;
backdrop-filter: blur(10px);
}
</style>

View File

@ -0,0 +1,48 @@
<template>
<section class="driving-7">
<DrivingChartPanel
class="driving-7-panel"
v-model:line="line"
v-model:date="date"
:chart-data="chartData"
/>
</section>
</template>
<script setup>
import { ref } from 'vue'
import DrivingChartPanel from './DrivingChartPanel.vue'
const line = ref(7)
const date = ref('2026-06-30')
const chartData = {
dates: ['01/21', '01/22', '01/23', '01/24', '01/25', '01/26'],
current: [98.8, 100.15, 101.3, 99.95, 100.42, 99.35],
previous: [98.55, 99.7, 100.08, 99.68, 100.88, 99.32]
}
</script>
<style lang="scss" scoped>
.driving-7 {
position: absolute;
left: 3035px;
top: 860.63px;
width: 805px;
height: 726.37px;
opacity: 1;
display: flex;
flex-direction: column;
background: url('/imgs/driving/7-bg.png') no-repeat center center;
background-size: cover;
}
.driving-7-panel {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
background: transparent !important;
}
</style>

View File

@ -0,0 +1,252 @@
<template>
<section class="driving-8">
<header class="toolbar"><span>对比日期:</span><select v-model="state.date">
<option>{{ state.date }}</option>
</select><select v-model="state.period">
<option>{{ state.period }}</option>
</select></header>
<div class="summary">
<div class="row"><b>最大满载区间</b><span>{{ state.maxSection }} <i>{{ state.loadRate }}%</i></span></div>
<div class="row"><b>运行图图号</b><span>实际 <i>{{ state.actualTrips }}</i><em>|</em> 计划 <strong>{{
state.planTrips }}</strong></span></div>
<div class="row"><b>备车情况</b><span>{{ state.spare }}</span></div>
<div class="row"><b>行车间隔</b><span>高峰: <span style="margin-right: 20px;">{{ state.peakInterval }}min
</span>平峰: <span>{{
state.offPeakInterval }}min</span></span></div>
</div>
<div class="route">
<div class="route-line">
<label>西岑</label>
<span class="station mid">淀山湖大道</span>
<span class="station end">虹桥火车站</span>
<small class="time t1">6分钟</small>
<small class="time t2">3分钟</small>
</div>
<div class="flow f1">{{ state.flowLeft }}/小时</div>
<div class="flow f2">{{ state.flowRight }}/小时</div>
</div>
</section>
</template>
<script setup>
import { reactive } from 'vue'
const state = reactive({ date: '20251113', period: '早高峰时段', maxSection: '虹桥火车站-国家会展中心', loadRate: 110, actualTrips: 23, planTrips: 24, spare: '东方绿洲/淀山湖', peakInterval: 2.5, offPeakInterval: 4, flowLeft: 225600, flowRight: 225600 })
</script>
<style lang="scss" scoped>
$panel-bg: rgba(4, 124, 235, 0.1);
$panel-radius: 6px;
$route-color: #00e9ed;
$tag-bg: rgba(34, 71, 122, 0.92);
$tag-border: #8d7c4e;
@mixin flex($align: center, $justify: flex-start, $direction: row) {
display: flex;
flex-direction: $direction;
align-items: $align;
justify-content: $justify;
}
@mixin glass-panel {
background: $panel-bg;
backdrop-filter: blur(10px);
border-radius: $panel-radius;
}
@mixin route-tag($top) {
position: absolute;
top: $top;
z-index: 2;
padding: 4px 16px;
color: inherit;
white-space: nowrap;
background: linear-gradient(130deg, #f5ff3442 2%, #ffb23e31 36%, #ff932e27 116%);
border: 1px solid;
border-image: linear-gradient(90deg, #ffd343b3 -3%, #ffc85b 51%, #ffcd43b3 102%) 1;
border-radius: 3px;
}
@mixin time-tag($top) {
position: absolute;
top: $top;
z-index: 2;
padding: 4px 16px;
color: inherit;
white-space: nowrap;
background: linear-gradient(122deg, #28426a 4%, #0d4061 108%);
border: 1px solid;
border-image: linear-gradient(90deg, #43aaffb3 -3%, #5bb5ff 51%, #43aaffb3 102%) 1;
border-radius: 3px;
}
.driving-8 {
@include flex(stretch, flex-start, column);
position: absolute;
top: 860.63px;
left: 3856.58px;
width: 920.56px;
height: 726.37px;
padding: 8px 32px 0;
color: #FFFFFF;
font: 22px;
background: url('/imgs/driving/8-bg.png') no-repeat center / cover;
.toolbar {
@include flex(center, flex-end);
height: 33px;
margin-top: 70px;
gap: 12px;
color: #dcecff;
font-size: 16px;
select {
height: 26px;
padding: 0 24px 0 10px;
appearance: none;
color: #e4f2ff;
font-size: 17px;
background-color: #2166a8;
background-image: linear-gradient(45deg, transparent 50%, #b8d9f7 50%),
linear-gradient(135deg, #b8d9f7 50%, transparent 50%);
background-repeat: no-repeat;
background-position: calc(100% - 13px) 10px, calc(100% - 8px) 10px;
background-size: 5px 5px;
border: 1px solid #4b9ee5;
border-radius: 2px;
}
}
.summary {
@include glass-panel;
@include flex(stretch, space-evenly, column);
height: 280px;
padding: 0 18px;
.row {
@include flex(center, space-between);
height: 53px;
padding: 0 18px;
margin-bottom: 1px;
background: linear-gradient(90deg, #0085ff00 0%, #00a3ff48 40%, #00a2ff48 70%, #0075ff0a 100%);
b {
font-size: 22px;
font-weight: 500;
letter-spacing: 0;
}
span {
// font-weight: 600;
font-size: 22px;
}
i,
strong {
padding: 3px 10px;
margin-left: 10px;
color: white;
font-style: normal;
background: #D72929;
border-radius: 4px;
font-weight: 500;
}
strong {
background: #008CFF;
font-weight: 500;
}
em {
margin: 0 10px;
color: #d6e6fa;
font-style: normal;
}
}
}
.route {
@include glass-panel;
position: relative;
height: 290px;
margin-top: 24px;
.route-line {
position: absolute;
top: 30px;
left: 44px;
width: 760px;
height: 187px;
border: 3px solid $route-color;
border-radius: 100px;
&::after {
position: absolute;
top: 45px;
left: 238px;
width: 512px;
height: 85px;
content: '';
border: 3px solid $route-color;
border-radius: inherit;
}
label {
@include route-tag(73px);
left: 40px;
}
.station {
@include route-tag(73px);
&.mid,
&.end {
width: 125px;
text-align: center;
}
&.mid {
left: 270px;
}
&.end {
right: 30px;
}
}
.time {
@include time-tag(169px);
padding: 3px 20px;
&.t1 {
left: 190px;
}
&.t2 {
left: 487px;
}
}
}
.flow {
@include time-tag(242px);
font-size: 18px;
&.f1 {
left: 198px;
}
&.f2 {
left: 492px;
}
}
}
}
</style>

View File

@ -0,0 +1,413 @@
<template>
<section class="driving-4">
<div class="filters">
<MetroLineSelect v-model="activeLine" />
<div class="date-select-shell">
<el-config-provider :locale="zhCn">
<el-date-picker
v-model="selectedDate"
class="date-select"
popper-class="driving-date-popper"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
:clearable="false"
:editable="false"
/>
</el-config-provider>
</div>
</div>
<span class="chart-unit">单位100%</span>
<img
class="current-highlight"
src="/imgs/driving/4-item-1.png"
alt=""
aria-hidden="true"
:style="highlightStyle"
/>
<div class="current-value-labels" :style="highlightValueStyle">
<strong class="current-value-labels__current">{{ chartData.currentCount }}</strong>
<strong class="current-value-labels__previous">{{ chartData.previousCount }}</strong>
</div>
<v-chart class="punctuality-chart" :option="chartOption" autoresize @click="handleChartClick" />
<div class="chart-legend" aria-hidden="true">
<span class="legend-item legend-item--current">当前数据</span>
<span class="legend-item legend-item--previous">去年同期</span>
</div>
</section>
</template>
<script setup>
import { computed, reactive, ref } from 'vue'
import VChart from 'vue-echarts'
import * as echarts from 'echarts'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import MetroLineSelect from './MetroLineSelect.vue'
const props = defineProps({
line: { type: Number, default: 7 },
date: { type: String, default: '2026-06-30' },
chartData: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['update:line', 'update:date'])
const activeLine = computed({ get: () => props.line, set: value => emit('update:line', value) })
const selectedDate = computed({ get: () => props.date, set: value => emit('update:date', value) })
const currentIndex = ref(2)
const chartData = reactive({
dates: ['01/21', '01/22', '01/23', '01/24', '01/25', '01/26'],
current: [98.8, 100.15, 101.3, 99.95, 100.42, 99.35],
previous: [98.55, 99.7, 100.08, 99.68, 100.88, 99.32],
currentCount: 418,
previousCount: 101
})
Object.assign(chartData, props.chartData)
const summary = reactive({
current: '99.46',
month: '99.86',
year: '99.86',
yearTotal: '94.86'
})
const handleChartClick = params => {
if (!['series', 'xAxis'].includes(params.componentType)) return
const index = typeof params.dataIndex === 'number'
? params.dataIndex
: chartData.dates.indexOf(params.value)
if (index >= 0) currentIndex.value = index
}
const highlightStyle = computed(() => ({
left: `${74 + currentIndex.value * 135 - 28.5}px`
}))
const highlightValueStyle = computed(() => ({
left: `${74 + currentIndex.value * 135 - 36}px`
}))
const chartOption = computed(() => ({
animationDuration: 900,
backgroundColor: 'transparent',
grid: {
top: 78,
right: 56,
bottom: 82,
left: 74
},
tooltip: {
show: true,
showContent: false,
trigger: 'axis',
axisPointer: {
type: 'line',
lineStyle: { color: 'rgba(39, 220, 255, .75)', width: 1 }
}
},
xAxis: {
type: 'category',
boundaryGap: false,
triggerEvent: true,
data: chartData.dates,
axisLine: {
lineStyle: { color: '#d4e8ff', width: 1.5 }
},
axisTick: { show: false },
axisLabel: {
interval: 0,
margin: 18,
color: '#fff',
fontSize: 19,
fontWeight: 600,
formatter: (value, index) => index === currentIndex.value ? `{active|${value}}\n{current|当前}` : value,
rich: {
active: { color: '#158dff', fontSize: 19, fontWeight: 700, lineHeight: 26 },
current: { color: '#158dff', fontSize: 18, fontWeight: 600, lineHeight: 28 }
}
}
},
yAxis: {
type: 'value',
min: 97,
max: 101.5,
interval: 1,
axisLine: {
show: true,
symbol: ['none', 'arrow'],
symbolSize: [8, 10],
lineStyle: { color: '#d4e8ff', width: 1.5 }
},
axisTick: { show: false },
axisLabel: {
color: '#fff',
fontSize: 18,
fontWeight: 600,
margin: 16,
formatter: value => Number.isInteger(value) ? value : ''
},
splitLine: {
show: true,
lineStyle: { color: 'rgba(190, 218, 255, .32)', width: 1.5 }
}
},
series: [
{
name: '当前数据',
type: 'line',
data: chartData.current,
smooth: 0.45,
showSymbol: false,
z: 5,
lineStyle: {
color: '#28bfff',
width: 4,
shadowBlur: 8,
shadowColor: 'rgba(40, 191, 255, .5)'
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(33, 184, 255, .58)' },
{ offset: 1, color: 'rgba(20, 107, 203, .08)' }
])
},
markPoint: {
silent: true,
symbol: 'none',
z: 20,
data: [
{
coord: [currentIndex.value, 101.22],
value: chartData.currentCount,
label: { color: '#00caff', fontSize: 34, fontWeight: 700, offset: [-36, -28] }
},
{
coord: [currentIndex.value, 99.2],
value: chartData.previousCount,
label: { color: '#ffd20b', fontSize: 34, fontWeight: 700, offset: [-36, 0] }
}
]
}
},
{
name: '去年同期',
type: 'line',
data: chartData.previous,
smooth: 0.45,
showSymbol: false,
z: 4,
lineStyle: { color: '#f2cb08', width: 4 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(238, 202, 15, .42)' },
{ offset: 1, color: 'rgba(238, 202, 15, .06)' }
])
}
},
{
name: '日期选择区域',
type: 'bar',
data: chartData.dates.map(() => 101.5),
barWidth: '100%',
silent: false,
z: 10,
tooltip: { show: false },
itemStyle: { color: 'transparent' },
emphasis: { itemStyle: { color: 'transparent' } }
}
]
}))
</script>
<style lang="scss" scoped>
.driving-4 {
position: absolute;
left: 3035px;
top: 123.99px;
width: 805px;
height: 717.6px;
color: #fff;
background: url('/imgs/driving/4-bg.png') no-repeat center center;
background-size: cover;
overflow: hidden;
}
.filters {
position: absolute;
top: 68px;
right: 24px;
z-index: 4;
display: flex;
align-items: center;
gap: 20px;
}
.date-select-shell {
position: relative;
width: 200px;
height: 43px;
border-radius: 4px;
background: linear-gradient(90deg, rgba(51, 99, 164, .9), rgba(9, 38, 92, .66));
--el-input-bg-color: transparent;
--el-fill-color-blank: transparent;
&::after {
position: absolute;
top: 50%;
right: 13px;
width: 16px;
height: 16px;
background: url('/imgs/driving/arrow.png') no-repeat center / contain;
content: '';
pointer-events: none;
transform: translateY(-50%);
}
}
:global(.driving-date-popper.el-picker__popper) {
border: 1px solid rgba(42, 154, 239, .7) !important;
border-radius: 4px;
background: rgba(4, 27, 72, .98) !important;
box-shadow: 0 10px 28px rgba(0, 8, 30, .55);
}
:global(.driving-date-popper .el-picker-panel),
:global(.driving-date-popper .el-date-picker__header),
:global(.driving-date-popper .el-picker-panel__content) {
background: transparent !important;
}
:global(.driving-date-popper .el-date-picker__header-label),
:global(.driving-date-popper .el-picker-panel__icon-btn) {
color: #e8f4ff !important;
font-size: 16px;
font-weight: 700;
}
:global(.driving-date-popper .el-date-table th) {
color: #75b9ed !important;
border-bottom-color: rgba(117, 185, 237, .2) !important;
}
:global(.driving-date-popper .el-date-table-cell__text) {
color: #dcecff !important;
}
:global(.driving-date-popper .el-date-table td.available:hover .el-date-table-cell__text),
:global(.driving-date-popper .el-date-table td.current:not(.disabled) .el-date-table-cell__text) {
color: #fff !important;
background: #1677d6 !important;
}
:global(.driving-date-popper .el-date-table td.today .el-date-table-cell__text) {
color: #39c5ff !important;
font-weight: 700;
}
// Element Plus applies the date editor outline on the outer input wrapper.
:global(.driving-4 .date-select.el-date-editor),
:global(.driving-4 .date-select.el-date-editor .el-input__wrapper),
:global(.driving-4 .date-select.el-date-editor.is-focus .el-input__wrapper),
:global(.driving-4 .date-select.el-date-editor:hover .el-input__wrapper) {
border: 0 !important;
box-shadow: none !important;
}
:global(.driving-4 .date-select .el-input__prefix) {
display: none !important;
}
:global(.driving-4 .date-select-shell .el-input),
:global(.driving-4 .date-select-shell .el-input__wrapper),
:global(.driving-4 .date-select-shell .el-input__inner) {
background-color: transparent !important;
background-image: none !important;
height: 44px !important;
font-size: 18px !important;
}
.chart-unit {
position: absolute;
top: 147px;
left: 33px;
z-index: 3;
font-size: 18px;
}
.punctuality-chart {
position: absolute;
top: 128px;
left: 0;
width: 100%;
height: 530px;
}
.current-highlight {
position: absolute;
top: 219px;
z-index: 2;
width: 57px;
height: 364px;
pointer-events: none;
}
.current-value-labels {
position: absolute;
top: 182px;
z-index: 6;
display: flex;
flex-direction: column;
align-items: center;
gap: 123px;
width: 72px;
pointer-events: none;
}
.current-value-labels strong { font-size: 34px; line-height: 1; }
.current-value-labels__current { color: #12bfff; }
.current-value-labels__previous { color: #ffd20b; }
.chart-legend {
position: absolute;
bottom: 38px;
left: 0;
display: flex;
width: 100%;
justify-content: center;
gap: 38px;
font-size: 16px;
font-weight: 600;
}
.legend-item {
position: relative;
padding-left: 37px;
&::before {
position: absolute;
top: 50%;
left: 0;
width: 29px;
border-top: 4px solid;
content: '';
transform: translateY(-50%);
}
&--current {
color: #28bfff;
}
&--previous {
color: #f2cb08;
&::before {
border-top-style: dotted;
}
}
}
</style>

View File

@ -0,0 +1,167 @@
<template>
<div ref="root" class="metro-line-select">
<button class="select-trigger" type="button" aria-haspopup="listbox" :aria-expanded="open" @click="open = !open"
@keydown.down.prevent="moveSelection(1)" @keydown.up.prevent="moveSelection(-1)" @keydown.esc="open = false">
<span class="selected-line" :style="lineStyle(selectedLine)">
<strong v-if="isNumberedLine(selectedLine)">{{ selectedLine.id }}</strong>
<span>{{ isNumberedLine(selectedLine) ? '号线' : selectedLine.name }}</span>
</span>
<img class="arrow" :class="{ open }" src="/imgs/driving/arrow.png" alt="" aria-hidden="true" />
</button>
<Transition name="line-options">
<ul v-if="open" class="options" role="listbox">
<li v-for="line in lineOptions" :key="line.id" :class="{ active: line.id === modelValue }" role="option"
:aria-selected="line.id === modelValue" @click="selectLine(line.id)">
<span class="line-swatch" :style="lineStyle(line)">{{ line.name }}</span>
</li>
</ul>
</Transition>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { METRO_LINES } from '@/utils/const.js'
const props = defineProps({
modelValue: { type: Number, default: 7 }
})
const emit = defineEmits(['update:modelValue', 'change'])
const root = ref(null)
const open = ref(false)
const lineOptions = Object.entries(METRO_LINES).map(([id, theme]) => ({
id: Number(id),
...theme
}))
const selectedLine = computed(() => (
lineOptions.find(line => line.id === props.modelValue) || lineOptions[0]
))
const lineStyle = line => ({ backgroundColor: line.bg, color: line.color })
const isNumberedLine = line => line.id >= 1 && line.id <= 18
const selectLine = id => {
emit('update:modelValue', id)
emit('change', id)
open.value = false
}
const moveSelection = offset => {
open.value = true
const currentIndex = lineOptions.findIndex(line => line.id === props.modelValue)
const nextIndex = (currentIndex + offset + lineOptions.length) % lineOptions.length
selectLine(lineOptions[nextIndex].id)
}
const closeOnOutsideClick = event => {
if (!root.value?.contains(event.target)) open.value = false
}
onMounted(() => document.addEventListener('pointerdown', closeOnOutsideClick))
onBeforeUnmount(() => document.removeEventListener('pointerdown', closeOnOutsideClick))
</script>
<style lang="scss" scoped>
.metro-line-select {
position: relative;
width: 154px;
}
.select-trigger {
display: flex;
align-items: center;
width: 100%;
height: 43px;
padding: 0 13px 0 18px;
border: 0;
border-radius: 4px;
background: linear-gradient(90deg, rgba(51, 99, 164, .9), rgba(9, 38, 92, .66));
cursor: pointer;
}
.selected-line {
display: inline-flex;
// align-items: center;
justify-content: center;
gap: 4px;
min-width: 78px;
height: 33px;
padding: 0 9px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
white-space: nowrap;
strong {
font-size: 30px;
line-height: 1;
}
}
.arrow {
width: 16px;
height: 16px;
margin-left: 12px;
object-fit: contain;
transition: transform .18s ease;
&.open {
transform: rotate(180deg);
}
}
.options {
position: absolute;
top: 47px;
right: 0;
z-index: 20;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
width: 238px;
max-height: 330px;
margin: 0;
padding: 9px;
overflow-y: auto;
border: 1px solid rgba(47, 156, 255, .7);
border-radius: 4px;
list-style: none;
background: rgba(3, 24, 68, .98);
box-shadow: 0 8px 24px rgba(0, 8, 30, .55);
}
.options li {
padding: 2px;
border: 1px solid transparent;
border-radius: 3px;
cursor: pointer;
&:hover,
&.active {
border-color: #60d9ff;
}
}
.line-swatch {
display: block;
height: 30px;
border-radius: 3px;
font-size: 14px;
line-height: 30px;
text-align: center;
white-space: nowrap;
}
.line-options-enter-active,
.line-options-leave-active {
transition: opacity .15s ease, transform .15s ease;
}
.line-options-enter-from,
.line-options-leave-to {
opacity: 0;
transform: translateY(-5px);
}
</style>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
import Driving1 from './components/Driving1.vue';
import Driving2 from './components/Driving2.vue';
import Driving3 from './components/Driving3.vue';
import Driving4 from './components/Driving4.vue';
import Driving5 from './components/Driving5.vue';
import Driving6 from './components/Driving6.vue';
import Driving7 from './components/Driving7.vue';
import Driving8 from './components/Driving8.vue';
</script>
<template>
<div class="driving-container">
<Driving1 />
<Driving2 />
<Driving3 />
<Driving4 />
<Driving5 />
<Driving6 />
<Driving7 />
<Driving8 />
</div>
</template>
<style lang="scss" scoped>
.driving-container {
width: 4800px;
height: 1620px;
position: absolute;
left: 0;
bottom: 0;
overflow: hidden;
}
</style>

View File

@ -1,38 +1,18 @@
<template> <template>
<div class="dashboard-header"> <div class="dashboard-header">
<nav class="layout-menu" aria-label="">
<RouterLink v-for="item in menuItems" :key="item.to" :to="item.to" class="layout-menu__item">
<span>{{ item.label }}</span>
</RouterLink>
<div class="weather-info">
<el-icon class="weather-icon"><Pouring /></el-icon>
<span class="weather-temperature">{{ temperatureRange }}</span>
<span class="weather-name">{{ weatherInfo.weather || '--' }}</span>
</div>
</nav>
<div class="header-title"> <div class="header-title">
<img src="/imgs/title.png" alt="title" class="title-img" /> <img src="/imgs/title.png" alt="title" class="title-img" />
</div> </div>
<div class="function-switch">
<button class="switch-btn" @click="goHome">
<img src="/imgs/Group607.png" alt="home" />
</button>
<button class="switch-btn" @click="goTraffic">
<img src="/imgs/Group608.png" alt="traffic" />
</button>
</div>
<div class="weather-info">
<div class="weather-item">
<span class="label">天气</span>
<span class="value">{{ weatherInfo.weather || '--' }}</span>
</div>
<div class="weather-item">
<span class="label">温度</span>
<span class="value">{{ weatherInfo.tem || '--' }}</span>
</div>
<!-- <div class="weather-item">
<span class="label">风速</span>
<span class="value">{{ weatherInfo.wind_speed || '--' }}m/s</span>
</div>
<div class="weather-item">
<span class="label">湿度</span>
<span class="value">{{ weatherInfo.rh || '--' }}%</span>
</div>
<div class="weather-item">
<span class="label">能见度</span>
<span class="value">{{ weatherInfo.vis || '--' }}km</span>
</div> -->
</div>
<div class="header-time">{{ currentTime }}</div> <div class="header-time">{{ currentTime }}</div>
<div class="notice-marquee" v-if="noticeList.length > 0"> <div class="notice-marquee" v-if="noticeList.length > 0">
<div class="marquee-content" :class="{ 'marquee-scroll': noticeList.length > 1 }"> <div class="marquee-content" :class="{ 'marquee-scroll': noticeList.length > 1 }">
@ -46,6 +26,7 @@
<script setup> <script setup>
import { ref, onMounted, onUnmounted, computed } from 'vue' import { ref, onMounted, onUnmounted, computed } from 'vue'
import { Pouring } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import server from '@/utils/service' import server from '@/utils/service'
@ -66,11 +47,25 @@ const currentNoticeIndex = ref(0)
let noticeTimer = null let noticeTimer = null
let timeTimer = null let timeTimer = null
const menuItems = [
{ label: '常规', to: '/home' },
{ label: '客运', to: '/traffic' },
{ label: '行车', to: '/driving' },
{ label: '施工', to: '/construction' }
]
const displayNoticeList = computed(() => { const displayNoticeList = computed(() => {
if (noticeList.value.length === 0) return [] if (noticeList.value.length === 0) return []
return [noticeList.value[currentNoticeIndex.value]] return [noticeList.value[currentNoticeIndex.value]]
}) })
const temperatureRange = computed(() => {
const min = weatherInfo.value.tem_min ?? weatherInfo.value.min_tem ?? weatherInfo.value.temp_min ?? 21
const max = weatherInfo.value.tem_max ?? weatherInfo.value.max_tem ?? weatherInfo.value.temp_max
?? (weatherInfo.value.tem ? Math.round(Number(weatherInfo.value.tem)) : 27)
return `${min}°~${max}°`
})
const updateTime = () => { const updateTime = () => {
const now = new Date() const now = new Date()
currentTime.value = now.toLocaleString('zh-CN', { currentTime.value = now.toLocaleString('zh-CN', {
@ -133,63 +128,109 @@ onUnmounted(() => {
<style lang="scss" scoped> <style lang="scss" scoped>
.dashboard-header { .dashboard-header {
width: 4800px;
height: 106px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
background: url('/imgs/top.png') no-repeat center center;
background-size: cover;
// border-bottom: 2px solid rgba(0, 212, 255, 0.5);
.header-title {
.title-img {
max-height: 100%;
max-width: 100%;
}
}
.header-time {
position: absolute; position: absolute;
// right: 40px; left: -2px;
top: 110px; top: -36px;
left: 50%; width: 4802px;
font-family: BlackOpsOne, sans-serif; height: 150px;
font-size: 44px; display: flex;
color: #19EBFF; align-items: center;
text-align: center; justify-content: center;
// background: url('/imgs/top.png') no-repeat center center;
transform: translateX(-50%); background-size: 100% 100%;
} // border-bottom: 2px solid rgba(0, 212, 255, 0.5);
.notice-marquee { .layout-menu {
position: absolute; position: absolute;
right: 40px; top: 82px;
top: 55px; left: 42px;
width: 1300px; z-index: 10;
height: 45px;
overflow: hidden;
.marquee-content {
display: flex; display: flex;
flex-direction: column; align-items: center;
.notice-item { &__item {
font-size: 40px; display: flex;
color: rgba(255, 255, 255, 0.9); align-items: center;
white-space: nowrap; justify-content: center;
animation: marquee 20s linear infinite; width: 214px;
height: 58px;
padding-bottom: 8px;
background: url('/imgs/layout/menu-bg.png') no-repeat center / 100% 100%;
text-decoration: none;
margin-left: -10px;
span {
color: #fff;
font-size: 36px;
font-weight: 500;
background: linear-gradient(180deg, #ffffff 53%, #8dc2ff 69%, #66b5ff 75%), #FFFFFF;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}
&:hover {
filter: brightness(1.15);
}
&.router-link-active {
background-image: url('/imgs/layout/menu-bg-active.png');
color: #eafaff;
}
} }
} }
}
@keyframes marquee { .header-title {
0% { .title-img {
transform: translateX(100%); margin-top: 18px;
max-height: 100%;
max-width: 100%;
}
} }
.header-time {
position: absolute;
// right: 40px;
top: 148px;
left: 50%;
font-family: BlackOpsOne, sans-serif;
font-size: 44px;
color: #19EBFF;
text-align: center;
//
transform: translateX(-50%);
}
.notice-marquee {
position: absolute;
right: 40px;
top: 55px;
width: 1300px;
height: 45px;
overflow: hidden;
.marquee-content {
display: flex;
flex-direction: column;
.notice-item {
font-size: 40px;
color: rgba(255, 255, 255, 0.9);
white-space: nowrap;
animation: marquee 20s linear infinite;
}
}
}
@keyframes marquee {
0% {
transform: translateX(100%);
}
100% { 100% {
transform: translateX(-100%); transform: translateX(-100%);
} }
@ -224,29 +265,29 @@ onUnmounted(() => {
} }
.weather-info { .weather-info {
position: absolute;
left: 1000px;
top: 70%;
transform: translateY(-50%);
display: flex; display: flex;
gap: 20px; align-items: center;
height: 58px;
margin-left: 46px;
gap: 14px;
white-space: nowrap;
color: #fff;
.weather-item { .weather-icon {
display: flex; width: 40px;
align-items: center; height: 40px;
gap: 8px; color: #82f6ff;
line-height: 45px; font-size: 40px;
filter: drop-shadow(0 0 6px rgba(70, 224, 255, 0.75));
}
.label { .weather-temperature,
font-size: 36px; .weather-name {
color: #FFF; font-size: 30px;
} font-weight: 500;
line-height: 58px;
.value { text-shadow: 0px 0px 10px rgba(30, 198, 255, 0.8);
font-size: 36px;
color: #fff;
}
} }
} }
} }
</style> </style>

View File

@ -3,12 +3,12 @@
<!-- <div class="module-title">模块一</div> --> <!-- <div class="module-title">模块一</div> -->
<div class="module-body"> <div class="module-body">
<div class="pyramid-3d-wrapper"> <div class="pyramid-3d-wrapper">
<Pyramid3D :show-glow="true" :show-ring="true" glow-image="/imgs/guangyun.png" color="#00d4ff" :scale="1.77" <Pyramid3D :show-glow="true" :show-ring="true" glow-image="./imgs/guangyun.png" color="#00d4ff" :scale="1.77"
:rotation-speed="0.2" :auto-rotate="true" :width="252" :height="166" @loaded="onPyramidLoaded" :rotation-speed="0.2" :auto-rotate="true" :width="252" :height="166" @loaded="onPyramidLoaded"
@error="onPyramidError" /> @error="onPyramidError" />
</div> </div>
<div class="pyramid-3d-wrapper2"> <div class="pyramid-3d-wrapper2">
<Pyramid3D :show-glow="true" :show-ring="true" glow-image="/imgs/guangyun.png" color="#00d4ff" :scale="1.77" <Pyramid3D :show-glow="true" :show-ring="true" glow-image="./imgs/guangyun.png" color="#00d4ff" :scale="1.77"
:rotation-speed="0.2" :auto-rotate="true" :width="252" :height="166" @loaded="onPyramidLoaded" :rotation-speed="0.2" :auto-rotate="true" :width="252" :height="166" @loaded="onPyramidLoaded"
@error="onPyramidError" /> @error="onPyramidError" />
</div> </div>
@ -48,7 +48,8 @@ const fetchOperateInfo = async () => {
const response = await server.getOperateInfo() const response = await server.getOperateInfo()
if (response.data.success) { if (response.data.success) {
const { level: levelData, begindate } = response.data.data const { level: levelData, begindate } = response.data.data
level.value = levelData const levelMatch = String(levelData).match(/\d+/)
level.value = levelMatch ? levelMatch[0] : levelData
const now = new Date() const now = new Date()
const begin = new Date(begindate) const begin = new Date(begindate)

View File

@ -39,6 +39,7 @@
import { ref, onMounted, nextTick } from 'vue' import { ref, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import server from '@/utils/service' import server from '@/utils/service'
import { processYunyingInfo } from '@/utils/yunying'
const chartRefs = ref([]) const chartRefs = ref([])
@ -55,8 +56,6 @@ const charts = ref(chartDataList.value)
const chartInstances = ref([]) const chartInstances = ref([])
const timeKeys = ['time1', 'time2', 'time3', 'time4', 'time5', 'time6', 'time7', 'time8', 'time9', 'time10']
const getNumberBoxColor = (number) => { const getNumberBoxColor = (number) => {
const colors = { const colors = {
3: '#FBD61D', 3: '#FBD61D',
@ -99,60 +98,18 @@ const getBaifenbiColorStyle = (chart) => {
} }
} }
const processData = (dataList) => { const processData = (dataList) => {
// time return processYunyingInfo(dataList).map(item => ({
const getCurrentTimeIndex = () => { barData: item.barData,
const now = new Date() yellowData: item.yellowData,
const hour = now.getHours() redData: item.redData,
const minutes = now.getMinutes() number: item.line,
// 5 6 time17 8 time2 digits: item.digits.length > 0 ? item.digits : [0],
// time1: 5-6, time2: 7-8, time3: 9-10, time4: 11-12, time5: 13-14 compareDate: item.compareDate,
// time6: 15-16, time7: 17-18, time8: 19-20, time9: 21-22, time10: 23-24 maxDate: item.maxDate,
if (hour < 5) return 0 maxValue: item.maxValue,
// 0 compareMaxValue: item.compareMaxValue,
// 10:3010 10 time4 totalSum: item.totalSum
// 11:00-11:59 time412:00-12:59 time4 }))
const timeIndex = Math.ceil((hour - 5) / 2)
const result = Math.min(timeIndex, 9) // time10 ( 9)
console.log(`当前时间:${hour}:${minutes},计算得到的 time 索引:${result}, 对应 ${timeKeys[result]}`)
return result
}
const currentTimeIndex = getCurrentTimeIndex()
return dataList.map(item => {
const getValues = (dataObj) => {
return timeKeys.map(key => {
const value = dataObj[key]
return value ? parseFloat(value) : 0
})
}
const barData = getValues(item.today)
const yellowData = getValues(item.compareDay)
const redData = getValues(item.maxDay)
// maxDay compareDay
const maxValue = redData[currentTimeIndex]
const compareMaxValue = yellowData[currentTimeIndex]
const totalSum = item.today?.totalSum ? parseFloat(item.today.totalSum) : 0
const total = barData.reduce((sum, val) => sum + val, 0)
const totalStr = Math.floor(total).toString()
const digits = totalStr.split('').map(Number)
return {
barData,
yellowData,
redData,
number: parseInt(item.line),
digits: digits.length > 0 ? digits : [0],
compareDate: item.compareDate,
maxDate: item.maxDay?.maxDate || '',
maxValue: maxValue,
compareMaxValue: compareMaxValue,
totalSum: totalSum
}
})
} }
const fetchYunyingInfo = async () => { const fetchYunyingInfo = async () => {

View File

@ -12,19 +12,21 @@ import DashboardHeader from '@/views/home/components/DashboardHeader.vue'
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.layout-container { .layout-container {
width: 4800px; width: 4800px;
height: 1620px; height: 1620px;
position: relative; position: relative;
background: url('/imgs/mainBg.webp') no-repeat center center; background: url('/imgs/mainBg.webp') no-repeat center center;
background-size: cover; background-size: cover;
overflow: hidden; overflow: hidden;
} }
.route-content { .route-content {
width: 4800px; width: 4800px;
height: 1514px; height: 1514px;
position: relative; position: absolute;
overflow: hidden; bottom: 0;
} left: 0;
overflow: hidden;
}
</style> </style>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -18,10 +18,9 @@
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed } from 'vue'
import metroMenuData from '@/utils/lineStationList.json'
import { METRO_LINES } from '@/utils/const' import { METRO_LINES } from '@/utils/const'
import { useTrafficLineStation } from '@/composables/useTrafficLineStation'
const props = defineProps({ const props = defineProps({
line: { line: {
@ -31,12 +30,10 @@ const props = defineProps({
station: { station: {
type: String, type: String,
default: '' default: ''
}, }
}) })
const emit = defineEmits(['update:line', 'update:station']) const emit = defineEmits(['update:line', 'update:station'])
const lineStationList = metroMenuData.lineStationList || []
const selectedLine = computed({ const selectedLine = computed({
get: () => props.line, get: () => props.line,
@ -48,25 +45,11 @@ const selectedStation = computed({
set: value => emit('update:station', value) set: value => emit('update:station', value)
}) })
const { lineOptions, stationOptions } = useTrafficLineStation(
() => props.line,
const lineOptions = computed(() => { () => props.station,
return lineStationList.map(item => ({ emit
label: item.nameCn, )
value: item.lineId
}))
})
const stationOptions = computed(() => {
const currentLine = lineStationList.find(item => item.lineId === selectedLine.value)
const stationList = currentLine?.stationList || []
return stationList.map(item => ({
label: item.nameCn,
value: item.statId,
line: item.line
}))
})
const lineDisplayText = computed(() => { const lineDisplayText = computed(() => {
const currentLine = lineOptions.value.find(item => item.value === props.line) const currentLine = lineOptions.value.find(item => item.value === props.line)
@ -78,8 +61,6 @@ const stationDisplayText = computed(() => {
return String(currentStation?.label || stationOptions.value[0]?.label || '') return String(currentStation?.label || stationOptions.value[0]?.label || '')
}) })
const lineTheme = computed(() => { const lineTheme = computed(() => {
return METRO_LINES[Number(selectedLine.value)] || { return METRO_LINES[Number(selectedLine.value)] || {
bg: '#ED6E00', bg: '#ED6E00',
@ -89,15 +70,15 @@ const lineTheme = computed(() => {
const themeStyle = computed(() => ({ const themeStyle = computed(() => ({
'--metro-bg': lineTheme.value.bg, '--metro-bg': lineTheme.value.bg,
'--metro-text': lineTheme.value.color, '--metro-text': lineTheme.value.color
})) }))
const getFilterItemStyle = (text, type) => { const getFilterItemStyle = (text, type) => {
const contentLength = Array.from(String(text || '')).length const contentLength = Array.from(String(text || '')).length
const rules = { const rules = {
line: { padding: 18, charWidth: 20 }, line: { padding: 18, charWidth: 20 },
station: { padding: 18, charWidth: 20 }, station: { padding: 18, charWidth: 20 },
month: { padding: 44, charWidth: 12 } month: { padding: 44, charWidth: 12 }
} }
const rule = rules[type] const rule = rules[type]
@ -105,18 +86,6 @@ const getFilterItemStyle = (text, type) => {
width: `${Math.max(rule.padding + contentLength * rule.charWidth)}px` width: `${Math.max(rule.padding + contentLength * rule.charWidth)}px`
} }
} }
watch(stationOptions, options => {
if (!options.length) {
emit('update:station', '')
return
}
const hasCurrentStation = options.some(item => item.value === props.station)
if (!hasCurrentStation) {
emit('update:station', options[0].value)
}
}, { immediate: true })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -26,12 +26,12 @@
</template> </template>
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed } from 'vue'
import { Calendar } from '@element-plus/icons-vue' import { Calendar } from '@element-plus/icons-vue'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import zhCn from 'element-plus/es/locale/lang/zh-cn' import zhCn from 'element-plus/es/locale/lang/zh-cn'
import metroMenuData from '@/utils/lineStationList.json'
import { METRO_LINES } from '@/utils/const' import { METRO_LINES } from '@/utils/const'
import { useTrafficLineStation } from '@/composables/useTrafficLineStation'
import 'dayjs/locale/zh-cn' import 'dayjs/locale/zh-cn'
@ -53,7 +53,6 @@ const props = defineProps({
}) })
const emit = defineEmits(['update:line', 'update:station', 'update:month']) const emit = defineEmits(['update:line', 'update:station', 'update:month'])
const lineStationList = metroMenuData.lineStationList || []
const selectedLine = computed({ const selectedLine = computed({
get: () => props.line, get: () => props.line,
@ -70,23 +69,11 @@ const selectedMonth = computed({
set: value => emit('update:month', value) set: value => emit('update:month', value)
}) })
const lineOptions = computed(() => { const { lineOptions, stationOptions } = useTrafficLineStation(
return lineStationList.map(item => ({ () => props.line,
label: item.nameCn, () => props.station,
value: item.lineId emit
})) )
})
const stationOptions = computed(() => {
const currentLine = lineStationList.find(item => item.lineId === selectedLine.value)
const stationList = currentLine?.stationList || []
return stationList.map(item => ({
label: item.nameCn,
value: item.statId,
line: item.line
}))
})
const lineDisplayText = computed(() => { const lineDisplayText = computed(() => {
const currentLine = lineOptions.value.find(item => item.value === props.line) const currentLine = lineOptions.value.find(item => item.value === props.line)
@ -109,15 +96,15 @@ const lineTheme = computed(() => {
const themeStyle = computed(() => ({ const themeStyle = computed(() => ({
'--metro-bg': lineTheme.value.bg, '--metro-bg': lineTheme.value.bg,
'--metro-text': lineTheme.value.color, '--metro-text': lineTheme.value.color
})) }))
const getFilterItemStyle = (text, type) => { const getFilterItemStyle = (text, type) => {
const contentLength = Array.from(String(text || '')).length const contentLength = Array.from(String(text || '')).length
const rules = { const rules = {
line: { padding: 18, charWidth: 20 }, line: { padding: 18, charWidth: 20 },
station: { padding: 18, charWidth: 20 }, station: { padding: 18, charWidth: 20 },
month: { padding: 44, charWidth: 12 } month: { padding: 44, charWidth: 12 }
} }
const rule = rules[type] const rule = rules[type]
@ -125,18 +112,6 @@ const getFilterItemStyle = (text, type) => {
width: `${Math.max(rule.padding + contentLength * rule.charWidth)}px` width: `${Math.max(rule.padding + contentLength * rule.charWidth)}px`
} }
} }
watch(stationOptions, options => {
if (!options.length) {
emit('update:station', '')
return
}
const hasCurrentStation = options.some(item => item.value === props.station)
if (!hasCurrentStation) {
emit('update:station', options[0].value)
}
}, { immediate: true })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -7,21 +7,27 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import TrafficRankRow from './TrafficRankRow.vue' import TrafficRankRow from './TrafficRankRow.vue'
import server from '@/utils/service'
import { mapStationRankList } from '@/utils/traffic'
const rankList = ref([ const rankList = ref([])
{ rank: 1, lines: '1,3,7,15', station: '上海火车站', type: '进出站', today: '8.40', compare: '6.20', percent: 92 },
{ rank: 2, lines: '1,3,7,15', station: '上海体育场', type: '进出站', today: '8.20', compare: '5.20', percent: 80 }, const fetchRankList = async () => {
{ rank: 3, lines: '1,3,7,15', station: '延安西路', type: '进出站', today: '6.40', compare: '3.20', percent: 70 }, try {
{ rank: 4, lines: '1,3,7,15', station: '上大路', type: '进出站', today: '6.20', compare: '6.20', percent: 50 }, const response = await server.getInOutStationRank()
{ rank: 5, lines: '1,3,7,15', station: '上海体育馆', type: '进出站', today: '6.20', compare: '6.20', percent: 40 }, if (response.data.success) {
{ rank: 6, lines: '1,3,7,15', station: '上海南站', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, rankList.value = mapStationRankList(response.data.data, '进出站')
{ rank: 7, lines: '1,3,7,15', station: '大渡河路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, }
{ rank: 8, lines: '1,3,7,15', station: '大渡河路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, } catch (error) {
{ rank: 9, lines: '1,3,7,15', station: '红宝石路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, console.error('获取进出站客流排名失败:', error)
{ rank: 10, lines: '1,3,7,15', station: '吴中路', type: '进出站', today: '6.20', compare: '6.20', percent: 10 } }
]) }
onMounted(() => {
fetchRankList()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -6,21 +6,27 @@
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import TrafficRankRow from './TrafficRankRow.vue' import TrafficRankRow from './TrafficRankRow.vue'
import server from '@/utils/service'
import { mapStationRankList } from '@/utils/traffic'
const rankList = ref([ const rankList = ref([])
{ rank: 1, lines: '1,3,7,15', station: '上海火车站', type: '进出站', today: '18.40', compare: '6.20', percent: 92 },
{ rank: 2, lines: '3,7', station: '上海体育场', type: '进出站', today: '18.20', compare: '5.20', percent: 80 }, const fetchRankList = async () => {
{ rank: 3, lines: '1,3,7,15', station: '延安西路', type: '进出站', today: '16.40', compare: '3.20', percent: 70 }, try {
{ rank: 4, lines: '1,3,7', station: '上大路', type: '进出站', today: '6.20', compare: '6.20', percent: 50 }, const response = await server.getTransferStationRank()
{ rank: 5, lines: '1,15', station: '上海体育馆', type: '进出站', today: '6.20', compare: '6.20', percent: 40 }, if (response.data.success) {
{ rank: 6, lines: '1,3,7,15', station: '上海南站', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, rankList.value = mapStationRankList(response.data.data, '换乘站')
{ rank: 7, lines: '1,3,15', station: '大渡河路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, }
{ rank: 8, lines: '1,3,7,15', station: '大渡河路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, } catch (error) {
{ rank: 9, lines: '1,3', station: '红宝石路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 }, console.error('获取换乘站客流排名失败:', error)
{ rank: 10, lines: '15', station: '吴中路', type: '进出站', today: '6.20', compare: '6.20', percent: 10 } }
]) }
onMounted(() => {
fetchRankList()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -2,7 +2,7 @@
<section class="traffic-3"> <section class="traffic-3">
<div class="chart-legend"> <div class="chart-legend">
<span class="legend-item legend-item--actual">实际</span> <span class="legend-item legend-item--actual">实际</span>
<span class="legend-item legend-item--compare">对比日(2026-03-05)</span> <span class="legend-item legend-item--compare">对比日{{ compareDateLabel }}</span>
<span class="legend-item legend-item--history">历史峰值</span> <span class="legend-item legend-item--history">历史峰值</span>
<span class="legend-unit">单位万人次</span> <span class="legend-unit">单位万人次</span>
</div> </div>
@ -35,45 +35,38 @@ import * as echarts from 'echarts'
import Odometer from 'odometer' import Odometer from 'odometer'
import 'odometer/themes/odometer-theme-default.css' import 'odometer/themes/odometer-theme-default.css'
import { METRO_LINES } from '@/utils/const' import { METRO_LINES } from '@/utils/const'
import server from '@/utils/service'
import { processYunyingInfo } from '@/utils/yunying'
import { pointGlowSymbolBase64 } from '../assets/4-item.base64.js'
const hours = ['5', '7', '9', '11', '13', '15', '17', '19', '21', '23'] const hours = ['5', '7', '9', '11', '13', '15', '17', '19', '21', '23']
const pointGlowSymbol = 'image:///imgs/traffic/4-item.png' const pointGlowSymbol = `image://${pointGlowSymbolBase64}`
const lineCards = ref([ const lineCards = ref([])
{ const compareDate = ref('')
lineId: 3,
total: '2746782', const compareDateLabel = computed(() => {
actual: [2600, 4700, 2400, 3900, 2300, 4100, 2600, 3300, 1700, 2200], return compareDate.value ? `(${compareDate.value})` : ''
compare: [4850, 4550, 4650, 4300, 4300, 4550, 4700, 3900, 3900, 3000], })
history: [5000, 4600, 4700, 4200, 4200, 4450, 4600, 4300, 3900, 3600]
}, const fetchLinePassengerFlow = async () => {
{ try {
lineId: 4, const response = await server.getLinePassengerFlow()
total: '2746782', if (response.data.success) {
actual: [700, 950, 1550, 4200, 1700, 1350, 1600, 1550, 2500, 1400], const processed = processYunyingInfo(response.data.data)
compare: [1150, 1450, 2200, 3900, 2500, 1550, 1700, 1650, 2950, 2050], compareDate.value = processed[0]?.compareDate || ''
history: [1350, 2200, 3100, 4450, 3000, 2650, 2550, 2750, 4200, 4550] lineCards.value = processed.map(item => ({
}, lineId: item.lineId,
{ total: String(Math.floor(item.totalSum || 0)),
lineId: 7, actual: item.actual,
total: '2746782', compare: item.compare,
actual: [2900, 3300, 5000, 2300, 2000, 1900, 2600, 4050, 1700, 1700], history: item.history
compare: [3200, 3700, 5200, 2500, 2400, 2500, 3100, 4700, 2400, 2400], }))
history: [3300, 4100, 5300, 2700, 2650, 2900, 3500, 4950, 3300, 2700] }
}, } catch (error) {
{ console.error('获取线路客流信息失败:', error)
lineId: 15,
total: '2746782',
actual: [3000, 3600, 4550, 2400, 3900, 2200, 3400, 4050, 2800, 3300],
compare: [4700, 4300, 4500, 4100, 4100, 4300, 4400, 4550, 4000, 3900],
history: [4850, 4350, 4550, 4100, 4100, 4300, 4450, 4500, 3950, 3600]
} }
]) }
setTimeout(() => {
lineCards.value[0].total = '8846081'
}, 8000);
const chartCards = computed(() => { const chartCards = computed(() => {
return lineCards.value.map(item => ({ return lineCards.value.map(item => ({
@ -82,7 +75,7 @@ const chartCards = computed(() => {
})) }))
}) })
const targetTotals = computed(() => lineCards.value.map(item => item.total.split(''))) const targetTotals = computed(() => lineCards.value.map(item => String(item.total || '0').split('')))
const digitEls = [] const digitEls = []
const odometerInstances = [] const odometerInstances = []
@ -132,6 +125,7 @@ watch(targetTotals, async () => {
}) })
onMounted(async () => { onMounted(async () => {
await fetchLinePassengerFlow()
await nextTick() await nextTick()
initOdometers() initOdometers()
requestAnimationFrame(updateOdometers) requestAnimationFrame(updateOdometers)
@ -149,7 +143,11 @@ const getLineStyle = lineId => {
} }
} }
const createChartOption = item => ({ const createChartOption = item => {
const maxValue = Math.max(...item.actual, ...item.compare, ...item.history, 0)
const yAxisMax = Math.ceil(maxValue / 10000) * 10000 || 10000
return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animationDuration: 900, animationDuration: 900,
grid: { grid: {
@ -184,8 +182,8 @@ const createChartOption = item => ({
yAxis: { yAxis: {
type: 'value', type: 'value',
min: 0, min: 0,
max: 5000, max: yAxisMax,
interval: 1000, interval: yAxisMax / 5,
axisLine: { axisLine: {
show: false show: false
}, },
@ -196,7 +194,8 @@ const createChartOption = item => ({
color: 'rgba(226, 239, 255, 0.96)', color: 'rgba(226, 239, 255, 0.96)',
fontSize: 13, fontSize: 13,
fontWeight: 600, fontWeight: 600,
margin: 10 margin: 10,
formatter: value => (value / 10000).toFixed(0)
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
@ -248,7 +247,7 @@ const createChartOption = item => ({
} }
}, },
{ {
name: '对比日(2026-03-05)', name: compareDateLabel.value || '对比日',
type: 'line', type: 'line',
data: item.compare, data: item.compare,
smooth: false, smooth: false,
@ -281,7 +280,8 @@ const createChartOption = item => ({
fontSize: 12 fontSize: 12
} }
} }
}) }
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -1,7 +1,6 @@
<template> <template>
<section class="traffic-4"> <section class="traffic-4">
<StationFilter class="traffic-4__filter" v-model:line="selectedLine" v-model:station="selectedStation" <StationFilter class="traffic-4__filter" v-model:line="selectedLine" v-model:station="selectedStation" />
/>
<div class="chart-shell"> <div class="chart-shell">
<v-chart class="traffic-4__chart" :option="chartOption" autoresize /> <v-chart class="traffic-4__chart" :option="chartOption" autoresize />
</div> </div>
@ -9,31 +8,56 @@
</template> </template>
<script setup> <script setup>
import { computed ,ref} from 'vue' import { computed, ref, watch } from 'vue'
import StationFilter from './StationFilter.vue' import StationFilter from './StationFilter.vue'
import VChart from 'vue-echarts' import VChart from 'vue-echarts'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import server from '@/utils/service'
import { processStationPassengerFlow, calcChartYAxisMax } from '@/utils/traffic'
import { pointGlowSymbolBase64 } from '../assets/4-item.base64.js'
import { pointGlowSymbol1Base64 } from '../assets/4-item1.base64.js'
const selectedLine = ref('3') const selectedLine = ref('3')
const selectedStation = ref('0313') const selectedStation = ref('0313')
const hours = ['5', '7', '9', '11', '13', '15', '17', '19', '21', '23'] const hours = ['5', '7', '9', '11', '13', '15', '17', '19', '21', '23']
const pointGlowSymbol = `image://${pointGlowSymbolBase64}`
const pointGlowSymbol1 = `image://${pointGlowSymbol1Base64}`
const topActual = [800, 900, 2100, 4050, 1500, 1450, 1200, 2300, 900, 900] const topActual = ref([])
const topCompare = [1400, 1800, 3400, 4500, 2950, 2600, 2800, 4300, 4300, 4850] const topCompare = ref([])
const bottomActual = ref([])
const bottomCompare = ref([])
const bottomActual = [4800, 4400, 3000, 1600, 3150, 3500, 3350, 1850, 1450, 1650] const fetchStationFlow = async () => {
const bottomCompare = [5400, 5300, 4900, 2200, 4700, 5000, 3850, 4700, 5350, 5350 ] if (!selectedStation.value) return
const pointGlowSymbol = 'image:///imgs/traffic/4-item.png' try {
const pointGlowSymbol1 = 'image:///imgs/traffic/4-item1.png' const response = await server.getStationPassengerFlow(selectedStation.value)
const topPointGlow = hours.map((hour, index) => [hour, topActual[index]]) if (response.data.success) {
const bottomPointGlow = hours.map((hour, index) => [hour, bottomActual[index]]) const processed = processStationPassengerFlow(response.data.data)
if (!processed) return
topActual.value = processed.inOut.actual
topCompare.value = processed.inOut.compare
bottomActual.value = processed.transfer.actual
bottomCompare.value = processed.transfer.compare
}
} catch (error) {
console.error('获取车站客流详情失败:', error)
}
}
watch(selectedStation, () => {
fetchStationFlow()
}, { immediate: true })
const topPointGlow = computed(() => hours.map((hour, index) => [hour, topActual.value[index] || 0]))
const bottomPointGlow = computed(() => hours.map((hour, index) => [hour, bottomActual.value[index] || 0]))
const yAxisMaxTop = computed(() => calcChartYAxisMax(topActual.value, topCompare.value))
const yAxisMaxBottom = computed(() => calcChartYAxisMax(bottomActual.value, bottomCompare.value))
const yAxisIntervalTop = computed(() => yAxisMaxTop.value / 5)
const yAxisIntervalBottom = computed(() => yAxisMaxBottom.value / 5)
const chartOption = computed(() => ({ const chartOption = computed(() => ({
backgroundColor: 'transparent', backgroundColor: 'transparent',
@ -63,21 +87,15 @@ const chartOption = computed(() => ({
boundaryGap: false, boundaryGap: false,
data: hours, data: hours,
gridIndex: 0, gridIndex: 0,
axisLine: { axisLine: { show: true },
show: true axisTick: { show: false },
},
axisTick: {
show: false
},
axisLabel: { axisLabel: {
color: 'rgba(226, 239, 255, 0.95)', color: 'rgba(226, 239, 255, 0.95)',
fontSize: 13, fontSize: 13,
fontWeight: 600, fontWeight: 600,
margin: 12 margin: 12
}, },
splitLine: { splitLine: { show: false }
show: false
}
}, },
{ {
type: 'category', type: 'category',
@ -85,35 +103,26 @@ const chartOption = computed(() => ({
data: hours, data: hours,
gridIndex: 1, gridIndex: 1,
position: 'top', position: 'top',
axisLine: { axisLine: { show: true },
show: true axisTick: { show: false },
}, axisLabel: { show: false },
axisTick: { splitLine: { show: false }
show: false
},
axisLabel: {
show: false
},
splitLine: {
show: false
}
} }
], ],
yAxis: [ yAxis: [
{ {
type: 'value', type: 'value',
gridIndex: 0, gridIndex: 0,
interval: 1000, min: 0,
axisLine: { max: yAxisMaxTop.value,
show: false interval: yAxisIntervalTop.value,
}, axisLine: { show: false },
axisTick: { axisTick: { show: false },
show: false
},
axisLabel: { axisLabel: {
color: 'rgba(222, 236, 255, 0.9)', color: 'rgba(222, 236, 255, 0.9)',
fontSize: 12, fontSize: 12,
margin: 10 margin: 10,
formatter: value => (value / 10000).toFixed(0)
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
@ -127,18 +136,17 @@ const chartOption = computed(() => ({
{ {
type: 'value', type: 'value',
gridIndex: 1, gridIndex: 1,
interval: 1000, min: 0,
max: yAxisMaxBottom.value,
interval: yAxisIntervalBottom.value,
inverse: true, inverse: true,
axisLine: { axisLine: { show: false },
show: false axisTick: { show: false },
},
axisTick: {
show: false
},
axisLabel: { axisLabel: {
color: 'rgba(222, 236, 255, 0.9)', color: 'rgba(222, 236, 255, 0.9)',
fontSize: 12, fontSize: 12,
margin: 10 margin: 10,
formatter: value => (value / 10000).toFixed(0)
}, },
splitLine: { splitLine: {
lineStyle: { lineStyle: {
@ -155,38 +163,34 @@ const chartOption = computed(() => ({
type: 'scatter', type: 'scatter',
xAxisIndex: 0, xAxisIndex: 0,
yAxisIndex: 0, yAxisIndex: 0,
data: topPointGlow, data: topPointGlow.value,
symbol: pointGlowSymbol, symbol: pointGlowSymbol,
symbolSize: [47, 109], symbolSize: [47, 109],
symbolOffset: [4, 0], symbolOffset: [4, 0],
symbolKeepAspect: false, symbolKeepAspect: false,
silent: false, silent: false,
z: 4, z: 4,
tooltip: { tooltip: { show: false }
show: false
}
}, },
{ {
type: 'scatter', type: 'scatter',
xAxisIndex: 1, xAxisIndex: 1,
yAxisIndex: 1, yAxisIndex: 1,
data: bottomPointGlow, data: bottomPointGlow.value,
symbol: pointGlowSymbol1, symbol: pointGlowSymbol1,
symbolSize: [47, 109], symbolSize: [47, 109],
symbolOffset: [4,0], symbolOffset: [4, 0],
symbolKeepAspect: true, symbolKeepAspect: true,
silent: true, silent: true,
z: 4, z: 4,
tooltip: { tooltip: { show: false }
show: false
}
}, },
{ {
name: '进出站', name: '进出站',
type: 'line', type: 'line',
xAxisIndex: 0, xAxisIndex: 0,
yAxisIndex: 0, yAxisIndex: 0,
data: topActual, data: topActual.value,
smooth: false, smooth: false,
symbol: 'circle', symbol: 'circle',
symbolSize: 6, symbolSize: 6,
@ -219,7 +223,7 @@ const chartOption = computed(() => ({
type: 'line', type: 'line',
xAxisIndex: 0, xAxisIndex: 0,
yAxisIndex: 0, yAxisIndex: 0,
data: topCompare, data: topCompare.value,
smooth: false, smooth: false,
symbol: 'none', symbol: 'none',
z: 4, z: 4,
@ -235,7 +239,7 @@ const chartOption = computed(() => ({
type: 'line', type: 'line',
xAxisIndex: 1, xAxisIndex: 1,
yAxisIndex: 1, yAxisIndex: 1,
data: bottomActual, data: bottomActual.value,
smooth: false, smooth: false,
symbol: 'circle', symbol: 'circle',
symbolSize: 6, symbolSize: 6,
@ -268,7 +272,7 @@ const chartOption = computed(() => ({
type: 'line', type: 'line',
xAxisIndex: 1, xAxisIndex: 1,
yAxisIndex: 1, yAxisIndex: 1,
data: bottomCompare, data: bottomCompare.value,
smooth: false, smooth: false,
symbol: 'none', symbol: 'none',
z: 4, z: 4,
@ -289,7 +293,7 @@ const chartOption = computed(() => ({
}, },
formatter: params => { formatter: params => {
const { name, seriesName, value } = params[0] const { name, seriesName, value } = params[0]
return `${name}时<br>${seriesName}: ${value}` return `${name}时<br>${seriesName}: ${(value / 10000).toFixed(2)}`
} }
} }
})) }))
@ -305,8 +309,6 @@ const chartOption = computed(() => ({
background: url('/imgs/traffic/4-bg.png') no-repeat center center; background: url('/imgs/traffic/4-bg.png') no-repeat center center;
background-size: cover; background-size: cover;
.traffic-4__filter { .traffic-4__filter {
position: absolute; position: absolute;
top: 12px; top: 12px;

View File

@ -8,8 +8,7 @@
/> />
<div class="activity-list"> <div class="activity-list">
<article v-for="activity in activityList" :key="activity.id" class="activity-card">
<article v-for="activity in activityList" :key="activity.name" class="activity-card">
<div class="activity-status" :class="`activity-status--${activity.statusType}`"> <div class="activity-status" :class="`activity-status--${activity.statusType}`">
{{ activity.status }} {{ activity.status }}
</div> </div>
@ -31,58 +30,62 @@
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { onMounted, ref, watch } from 'vue'
import dayjs from 'dayjs'
import StationTimeFilter from './StationTimeFilter.vue' import StationTimeFilter from './StationTimeFilter.vue'
import server from '@/utils/service'
import { formatActivityMonth, processBigActivityList } from '@/utils/traffic'
const selectedLine = ref('7') const selectedLine = ref('7')
const selectedStation = ref('0753') const selectedStation = ref('0753')
const selectedMonth = ref('2026-5') const selectedMonth = ref(dayjs().format('YYYY-M'))
const activityList = ref([])
const lineApiIdMap = ref({})
const activityList = [ const loadLineApiIdMap = async () => {
{ try {
name: 'CBE中国美容博览会', const response = await server.getLineList()
time: '2026/05/12--2026/05/14', if (response.data.success && response.data.data?.length) {
venue: '新国际博览中心', lineApiIdMap.value = Object.fromEntries(
status: '进行中', response.data.data.map(item => [String(item.lineId), String(item.id)])
statusType: 'active' )
}, }
{ } catch (error) {
name: '上海插画艺术节', console.error('获取线路列表失败:', error)
time: '2026/05/01--2026/05/03',
status: '已结束',
statusType: 'ended'
},
{
name: '中国国际自行车展览会/中国国际电动车及零配件展览会/中国国际摩托车及零配件展览会/上海国际户外骑行装备展览会',
time: '2026/05/05--2026/05/08',
status: '已结束',
statusType: 'ended'
},
{
name: 'SIAL 西雅国际食品展览会(上海)',
time: '2026/05/18--2026/05/20',
status: '未开始',
statusType: 'pending'
},
{
name: '亚洲国际有机产品博览会',
time: '2026/05/18--2026/05/20',
status: '未开始',
statusType: 'pending'
},
{
name: '上海网络安全博览会暨高峰论坛',
time: '2026/05/26--2026/05/28',
status: '未开始',
statusType: 'pending'
},
{
name: '中国国际厨房、卫浴设施展览会',
time: '2026/05/26--2026/05/29',
status: '未开始',
statusType: 'pending'
} }
] }
const fetchActivityList = async () => {
const metroLine = lineApiIdMap.value[selectedLine.value]
if (!metroLine || !selectedStation.value || !selectedMonth.value) {
activityList.value = []
return
}
try {
const response = await server.getBigActivityList({
metroLine,
metroStation: selectedStation.value,
activityMonth: formatActivityMonth(selectedMonth.value)
})
if (response.data.success) {
activityList.value = processBigActivityList(response.data.data)
} else {
activityList.value = []
}
} catch (error) {
console.error('获取重大活动清单失败:', error)
activityList.value = []
}
}
watch([selectedLine, selectedStation, selectedMonth], fetchActivityList)
onMounted(async () => {
await loadLineApiIdMap()
await fetchActivityList()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -58,8 +58,7 @@ const rankingList = [
{ rank: 1, lineId: 3, count: 20 }, { rank: 1, lineId: 3, count: 20 },
{ rank: 2, lineId: 4, count: 2 }, { rank: 2, lineId: 4, count: 2 },
{ rank: 3, lineId: 7, count: 11 }, { rank: 3, lineId: 7, count: 11 },
{ rank: 4, lineId: 41, count: 11 }, { rank: 4, lineId: 11, count: 11 }
{ rank: 5, lineId: 51, count: 11 }
] ]
const getLineTheme = lineId => { const getLineTheme = lineId => {