页面完成

main
崔方鑫 2026-06-22 14:06:42 +08:00
parent b582e22a35
commit 7508347d45
20 changed files with 1539 additions and 1058 deletions

7
package-lock.json generated
View File

@ -15,6 +15,7 @@
"element-plus": "^2.6.1", "element-plus": "^2.6.1",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"odometer": "^0.4.8",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"three": "^0.183.2", "three": "^0.183.2",
"v3-scroll": "^1.0.4", "v3-scroll": "^1.0.4",
@ -2037,6 +2038,12 @@
"integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/odometer": {
"version": "0.4.8",
"resolved": "https://registry.npmjs.org/odometer/-/odometer-0.4.8.tgz",
"integrity": "sha512-bfKuAhWE/qMCiX9bwX90c5bTpt7MDVeq1e6YsOHQrYokNhv9jhS5JbR9kg6i+FeWmDgalf3VUtwNx1aVaZ8cgg==",
"license": "MIT"
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",

View File

@ -15,6 +15,7 @@
"element-plus": "^2.6.1", "element-plus": "^2.6.1",
"hls.js": "^1.6.15", "hls.js": "^1.6.15",
"nprogress": "^0.2.0", "nprogress": "^0.2.0",
"odometer": "^0.4.8",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"three": "^0.183.2", "three": "^0.183.2",
"v3-scroll": "^1.0.4", "v3-scroll": "^1.0.4",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 964 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

View File

@ -168,7 +168,7 @@ watch(stationOptions, options => {
:deep(.el-input__wrapper) { :deep(.el-input__wrapper) {
box-sizing: border-box; box-sizing: border-box;
border: 0; border: none !important;
box-shadow: none; box-shadow: none;
background: transparent !important; background: transparent !important;

View File

@ -1,7 +1,29 @@
<template> <template>
<section class="traffic-1">1</section> <section class="traffic-1">
<div class="rank-list">
<TrafficRankRow v-for="item in rankList" :key="item.rank" :item="item" />
</div>
</section>
</template> </template>
<script setup>
import { ref } from 'vue'
import TrafficRankRow from './TrafficRankRow.vue'
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 },
{ rank: 3, lines: '1,3,7,15', station: '延安西路', type: '进出站', today: '6.40', compare: '3.20', percent: 70 },
{ rank: 4, lines: '1,3,7,15', station: '上大路', type: '进出站', today: '6.20', compare: '6.20', percent: 50 },
{ rank: 5, lines: '1,3,7,15', station: '上海体育馆', type: '进出站', today: '6.20', compare: '6.20', percent: 40 },
{ rank: 6, lines: '1,3,7,15', station: '上海南站', type: '进出站', today: '6.20', compare: '6.20', percent: 38 },
{ 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 },
{ rank: 9, lines: '1,3,7,15', station: '红宝石路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 },
{ rank: 10, lines: '1,3,7,15', station: '吴中路', type: '进出站', today: '6.20', compare: '6.20', percent: 10 }
])
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.traffic-1 { .traffic-1 {
position: absolute; position: absolute;
@ -17,5 +39,18 @@
background: url('/imgs/traffic/1-bg.png') no-repeat center center; background: url('/imgs/traffic/1-bg.png') no-repeat center center;
background-size: cover; background-size: cover;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
.rank-list {
position: absolute;
top: 140px;
right: 10px;
bottom: 30px;
left: 0px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
}
} }
</style> </style>

View File

@ -1,162 +1,26 @@
<template> <template>
<div class="traffic-2"> <div class="traffic-2">
<!-- <div class="module-title">模块四</div> --> <div class="rank-list">
<div class="module-body"> <TrafficRankRow v-for="item in rankList" :key="item.rank" :item="item" />
<div class="scroll-wrapper">
<div class="scroll-list">
<div class="list-item" v-for="(item, index) in stationData" :key="item.id + '-' + index">
<div class="cell-bg">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: item.progress + '%' }"></div>
</div>
</div>
<div class="rank" :class="'rank-' + ((index % stationData.length) + 1)">{{ String(item.rank).padStart(2,
'0')
}}</div>
<div class="line-numbers">
<span v-for="lineId in getLineIdList(item.lineId)" :key="lineId" class="line-number-badge"
:style="getLineNumberStyle(lineId)">
{{ lineId }}
</span>
</div>
<div class="station-name">{{ item.stationName }}</div>
<div class="action-type">{{ item.actionType }}</div>
<!-- <div class="progress-bar">
<div class="progress-fill" :style="{ width: item.progress + '%' }"></div>
</div> -->
<div class="in-count">
<span class="highlight">{{ item.inCount }}</span>
<span class="unit"></span>
</div>
<div class="out-count">
<span :style="getOutCountColorStyle(item)">{{ getOutCountPercentage(item) }}</span>
<span class="unit">%</span>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref } from 'vue'
import server from '@/utils/service' import TrafficRankRow from './TrafficRankRow.vue'
const stationData = ref([]) const rankList = ref([
{ rank: 1, lines: '1,3,7,15', station: '上海火车站', type: '进出站', today: '18.40', compare: '6.20', percent: 92 },
const fetchStationMaxList = async () => { { rank: 2, lines: '3,7', station: '上海体育场', type: '进出站', today: '18.20', compare: '5.20', percent: 80 },
try { { rank: 3, lines: '1,3,7,15', station: '延安西路', type: '进出站', today: '16.40', compare: '3.20', percent: 70 },
const response = await server.getStationMaxList() { rank: 4, lines: '1,3,7', station: '上大路', type: '进出站', today: '6.20', compare: '6.20', percent: 50 },
if (response.data.success) { { rank: 5, lines: '1,15', station: '上海体育馆', type: '进出站', today: '6.20', compare: '6.20', percent: 40 },
// console.log(':', response.data.data) { rank: 6, lines: '1,3,7,15', station: '上海南站', type: '进出站', today: '6.20', compare: '6.20', percent: 38 },
const maxTotal = Math.max(...response.data.data.map(item => parseFloat(item.total))) { rank: 7, lines: '1,3,15', station: '大渡河路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 },
stationData.value = response.data.data.map((item, index) => ({ { rank: 8, lines: '1,3,7,15', station: '大渡河路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 },
id: item.stationId, { rank: 9, lines: '1,3', station: '红宝石路', type: '进出站', today: '6.20', compare: '6.20', percent: 38 },
rank: index + 1, { rank: 10, lines: '15', station: '吴中路', type: '进出站', today: '6.20', compare: '6.20', percent: 10 }
stationName: item.stationName, ])
actionType: '进出站',
progress: Math.round((parseFloat(item.total) / maxTotal) * 100),
inCount: item.total,
outCount: item.compareTotal,
lineId: item.lineId
}))
console.log('站点最大客流列表:', stationData.value)
}
} catch (error) {
console.error('获取站点最大客流列表失败:', error)
}
}
const currentIndex = ref(0)
const transitionState = ref(true)
let timer = null
const itemHeight = 70
const visibleCount = 3
const displayList = computed(() => {
return stationData.value
})
const getLineIdList = (lineId) => {
if (!lineId) {
return []
}
return String(lineId).split(',').map(id => id.trim())
}
const getLineNumberStyle = (lineId) => {
const lineNum = parseInt(lineId)
const colors = {
1: '#E6002A',
2: '#8CC220',
3: '#FBD61D',
4: '#451E83',
5: '#944D9A',
6: '#D40068',
7: '#ED7010',
8: '#0095D9',
9: '#87CAED',
10: '#C6AFD4',
11: '#871C2B',
12: '#007A60',
13: '#E999C0',
14: '#9A982F',
15: '#C4AF8C',
16: '#98D1C0',
17: '#B6766C',
18: '#C09453'
}
const textColors = {
4: '#ffffff'
}
const bgColor = colors[lineNum] || '#ffcc00'
const textColor = textColors[lineNum] || '#000000'
return {
backgroundColor: bgColor,
color: textColor
}
}
const getOutCountPercentage = (item) => {
if (!item.outCount || item.outCount === 0) {
return '0.00'
}
const percentage = ((item.inCount - item.outCount) / item.inCount) * 100
// return Math.abs(percentage).toFixed(2)
return percentage.toFixed(2)
}
const getOutCountColorStyle = (item) => {
if (!item.outCount || item.outCount === 0) {
return { color: '#ffffff', fontWeight: 'bold' }
}
const percentage = (item.inCount - item.outCount) / item.inCount
if (percentage > 0) {
return { color: '#ff3333', fontWeight: 'bold' }
} else if (percentage < 0) {
return { color: '#00ff00', fontWeight: 'bold' }
} else {
return { color: '#ffffff', fontWeight: 'bold' }
}
}
const startScroll = () => {
}
const handleScrollEnd = (index) => {
console.log('滚动到第', index, '项')
}
onMounted(() => {
fetchStationMaxList()
})
onUnmounted(() => {
if (timer) {
clearInterval(timer)
}
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@ -174,256 +38,22 @@ onUnmounted(() => {
background-size: cover; background-size: cover;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
.module-title {
height: 40px; :deep(.traffic-rank-row) {
line-height: 40px; grid-template-columns: 66fr 110fr 210fr 120fr 150fr 140fr;
padding: 0 16px;
font-size: 16px;
font-weight: 600;
color: #00d4ff;
background: rgba(0, 212, 255, 0.1);
border-bottom: 1px solid rgba(0, 212, 255, 0.2);
} }
.module-body { .rank-list {
flex: 1; position: absolute;
padding: 20px; top: 140px;
overflow: hidden; right: 22px;
bottom: 30px;
left: 14px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
.scroll-wrapper {
overflow: visible;
margin-top: 100px;
// background: yellow;
.scroll-list {
.list-item {
display: flex;
align-items: center;
height: 70px;
width: 100%;
position: relative;
background: transparent;
margin-bottom: 6px;
.cell-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 742.6px;
height: 44px;
background: url('/imgs/cell.png') no-repeat center center;
background-size: 100% 100%;
z-index: 0;
pointer-events: none;
}
.rank {
position: relative;
z-index: 1;
width: 40px;
height: 28px;
line-height: 28px;
text-align: center;
font-size: 22px;
font-weight: 600;
margin-right: 16px;
flex-shrink: 0;
margin-left: 8px;
// 10
&.rank-1 {
background: linear-gradient(90deg, #00FFFF 0%, #00FFFF 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-2 {
background: linear-gradient(90deg, #00FFFF 0%, #00FFFF 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-3 {
background: linear-gradient(90deg, #00FFFF 0%, #00FFFF 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-4 {
background: linear-gradient(90deg, #fff 0%, #fff 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-5 {
background: linear-gradient(90deg, #fff 0%, #fff 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-6 {
background: linear-gradient(90deg, #fff 0%, #fff 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-7 {
background: linear-gradient(90deg, #fff 0%, #fff 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-8 {
background: linear-gradient(90deg, #fff 0%, #fff 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-9 {
background: linear-gradient(90deg, #fff 0%, #fff 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
&.rank-10 {
background: linear-gradient(90deg, #fff 0%, #fff 100%);
-webkit-background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
}
.line-numbers {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 2px;
width: 70px;
// margin-left: 10px;
flex-shrink: 0;
// background-color: yellow;
.line-number-badge {
width: 20px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 700;
border-radius: 4px;
font-size: 14px;
}
}
.station-name {
position: relative;
z-index: 1;
width: 140px;
font-size: 23px;
color: #fff;
flex-shrink: 0;
// margin-left: 20px;
padding-bottom: 8px;
}
.action-type {
position: relative;
z-index: 1;
width: 90px;
font-size: 23px;
color: #fff;
flex-shrink: 0;
margin-left: 76px;
padding-bottom: 8px;
}
.progress-bar {
position: relative;
z-index: 1;
flex: 1;
height: 6px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
margin-left: 60px;
margin-top: 37px;
overflow: hidden;
width: 500px;
.progress-fill {
height: 100%;
background: linear-gradient(270deg, #00FFFF 0%, rgba(0, 183, 255, 0.18) 100%);
border-radius: 3px;
transition: width 0.3s ease;
}
}
.in-count {
position: relative;
z-index: 1;
width: 150px;
text-align: left;
font-size: 18px;
font-weight: 600;
color: #47ffff;
flex-shrink: 0;
margin-left: 50px;
padding-bottom: 8px;
// background-color: yellow;
.highlight {
color: #47ffff;
}
.unit {
font-size: 10px;
color: #47ffff;
margin-left: 2px;
padding-bottom: 8px;
}
}
.out-count {
position: relative;
z-index: 1;
width: 120px;
text-align: left;
font-size: 16px;
color: #FFFFFF;
flex-shrink: 0;
margin-left: 0px;
padding-bottom: 8px;
.unit {
font-size: 10px;
color: #FFFFFF;
margin-left: 2px;
}
}
}
}
}
} }
} }
</style> </style>

View File

@ -1,15 +1,453 @@
<template> <template>
<section class="traffic-3">3</section> <section class="traffic-3">
<div class="chart-legend">
<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--history">历史峰值</span>
<span class="legend-unit">单位万人次</span>
</div>
<div class="line-chart-grid">
<article v-for="(item, index) in chartCards" :key="item.lineId" class="line-chart-card">
<div class="line-chart-header">
<span class="line-badge" :style="getLineStyle(item.lineId)">{{ item.lineId }}</span>
<span class="line-title">累计<br>客流</span>
<div class="total-digits">
<span v-for="(_, digitIndex) in targetTotals[index]" :key="`${item.lineId}-${digitIndex}`" class="digit-box">
<span
:ref="el => setDigitRef(el, index, digitIndex)"
class="digit-odometer"
>0</span>
</span>
</div>
</div>
<v-chart class="line-chart" :option="item.option" autoresize />
</article>
</div>
</section>
</template> </template>
<style lang="scss" scoped> <script setup>
.traffic-3 { import { computed, nextTick, onMounted, ref, watch } from 'vue'
position: absolute; import VChart from 'vue-echarts'
left: 1689px; import * as echarts from 'echarts'
top: 128px; import Odometer from 'odometer'
width: 1250px; import 'odometer/themes/odometer-theme-default.css'
height: 895px; import { METRO_LINES } from '@/utils/const'
background: url('/imgs/traffic/3-bg.png') no-repeat center center;
background-size: cover; const hours = ['5', '7', '9', '11', '13', '15', '17', '19', '21', '23']
const pointGlowSymbol = 'image:///imgs/traffic/4-item.png'
const lineCards = ref([
{
lineId: 3,
total: '2746782',
actual: [2600, 4700, 2400, 3900, 2300, 4100, 2600, 3300, 1700, 2200],
compare: [4850, 4550, 4650, 4300, 4300, 4550, 4700, 3900, 3900, 3000],
history: [5000, 4600, 4700, 4200, 4200, 4450, 4600, 4300, 3900, 3600]
},
{
lineId: 4,
total: '2746782',
actual: [700, 950, 1550, 4200, 1700, 1350, 1600, 1550, 2500, 1400],
compare: [1150, 1450, 2200, 3900, 2500, 1550, 1700, 1650, 2950, 2050],
history: [1350, 2200, 3100, 4450, 3000, 2650, 2550, 2750, 4200, 4550]
},
{
lineId: 7,
total: '2746782',
actual: [2900, 3300, 5000, 2300, 2000, 1900, 2600, 4050, 1700, 1700],
compare: [3200, 3700, 5200, 2500, 2400, 2500, 3100, 4700, 2400, 2400],
history: [3300, 4100, 5300, 2700, 2650, 2900, 3500, 4950, 3300, 2700]
},
{
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(() => {
return lineCards.value.map(item => ({
...item,
option: createChartOption(item)
}))
})
const targetTotals = computed(() => lineCards.value.map(item => item.total.split('')))
const digitEls = []
const odometerInstances = []
const setDigitRef = (el, cardIndex, digitIndex) => {
if (!el) {
return
}
digitEls[cardIndex] ||= []
digitEls[cardIndex][digitIndex] = el
} }
const initOdometers = () => {
targetTotals.value.forEach((digits, cardIndex) => {
odometerInstances[cardIndex] ||= []
digits.forEach((_, digitIndex) => {
const el = digitEls[cardIndex]?.[digitIndex]
if (!el || odometerInstances[cardIndex][digitIndex]) {
return
}
odometerInstances[cardIndex][digitIndex] = new Odometer({
el,
value: 0,
format: 'd',
duration: 1200,
theme: 'default'
})
})
})
}
const updateOdometers = () => {
targetTotals.value.forEach((digits, cardIndex) => {
digits.forEach((digit, digitIndex) => {
odometerInstances[cardIndex]?.[digitIndex]?.update(Number(digit))
})
})
}
watch(targetTotals, async () => {
await nextTick()
initOdometers()
updateOdometers()
})
onMounted(async () => {
await nextTick()
initOdometers()
requestAnimationFrame(updateOdometers)
})
const getLineStyle = lineId => {
const theme = METRO_LINES[lineId] || {
bg: '#2bd5ff',
color: '#00194a'
}
return {
backgroundColor: theme.bg,
color: theme.color
}
}
const createChartOption = item => ({
backgroundColor: 'transparent',
animationDuration: 900,
grid: {
left: 6,
right: 34,
top: 16,
bottom: 14,
containLabel: true
},
xAxis: {
type: 'category',
boundaryGap: false,
data: hours,
axisLine: {
lineStyle: {
color: 'rgba(167, 205, 255, 0.45)'
}
},
axisTick: {
show: false
},
axisLabel: {
color: 'rgba(226, 239, 255, 0.96)',
fontSize: 14,
fontWeight: 600,
margin: 10
},
splitLine: {
show: false
}
},
yAxis: {
type: 'value',
min: 0,
max: 5000,
interval: 1000,
axisLine: {
show: false
},
axisTick: {
show: false
},
axisLabel: {
color: 'rgba(226, 239, 255, 0.96)',
fontSize: 13,
fontWeight: 600,
margin: 10
},
splitLine: {
lineStyle: {
color: 'rgba(219, 235, 255, 0.32)',
type: 'dashed',
width: 1.5
}
}
},
series: [
{
type: 'scatter',
data: hours.map((hour, index) => [hour, item.actual[index]]),
symbol: pointGlowSymbol,
symbolSize: [38, 88],
symbolOffset: [3, 0],
symbolKeepAspect: false,
silent: true,
z: 3,
tooltip: {
show: false
}
},
{
name: '实际',
type: 'line',
data: item.actual,
smooth: false,
symbol: 'circle',
symbolSize: 5,
z: 5,
lineStyle: {
color: '#20cfff',
width: 3,
shadowBlur: 8,
shadowColor: 'rgba(32, 207, 255, 0.55)'
},
itemStyle: {
color: '#dffbff',
borderColor: '#20cfff',
borderWidth: 2
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(27, 218, 255, 0.86)' },
{ offset: 0.58, color: 'rgba(21, 144, 226, 0.48)' },
{ offset: 1, color: 'rgba(13, 74, 166, 0.16)' }
])
}
},
{
name: '对比日(2026-03-05)',
type: 'line',
data: item.compare,
smooth: false,
symbol: 'none',
z: 3,
lineStyle: {
color: '#f5c400',
width: 2
}
},
{
name: '历史峰值',
type: 'line',
data: item.history,
smooth: false,
symbol: 'none',
z: 3,
lineStyle: {
color: '#ff1d1d',
width: 2
}
}
],
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(0, 18, 55, 0.92)',
borderColor: 'rgba(31, 205, 255, 0.45)',
textStyle: {
color: '#ffffff',
fontSize: 12
}
}
})
</script>
<style lang="scss" scoped>
.traffic-3 {
position: absolute;
left: 1689px;
top: 128px;
width: 1250px;
height: 895px;
background: url('/imgs/traffic/3-bg.png') no-repeat center center;
background-size: cover;
.chart-legend {
position: absolute;
top: 64px;
left: 32px;
display: flex;
align-items: center;
gap: 20px;
color: #ffffff;
font-size: 18px;
font-weight: 500;
line-height: 1;
}
.legend-item {
position: relative;
padding-left: 18px;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 12px;
height: 12px;
border-radius: 50%;
transform: translateY(-50%);
}
&--actual::before {
background: #20d8ff;
}
&--compare::before {
background: #f5c400;
}
&--history::before {
background: #ff1d1d;
}
}
.legend-unit {
margin-left: -12px;
}
.line-chart-grid {
position: absolute;
top: 96px;
right: 23px;
bottom: 22px;
left: 23px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 27px 26px;
}
.line-chart-card {
position: relative;
overflow: hidden;
background: rgba(27, 83, 165, 0.25);
}
.line-chart-header {
position: absolute;
top: 36px;
left: 20px;
right: 22px;
z-index: 2;
display: flex;
align-items: center;
}
.line-badge {
width: 39px;
height: 39px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 6px;
font-size: 30px;
font-weight: 600;
line-height: 1;
}
.line-title {
margin-left: 21px;
color: #ffffff;
font-size: 18px;
font-weight: 500;
line-height: normal;
}
.total-digits {
display: flex;
align-items: center;
gap: 15px;
margin-left: 47px;
}
.digit-box {
width: 39px;
height: 44px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: #ffffff;
font-size: 28px;
font-weight: 600;
line-height: 1;
background: url('/imgs/traffic/3-item.png') no-repeat center center;
background-size: contain;
}
.digit-odometer {
width: 39px;
height: 44px;
display: block;
color: #ffffff;
font-family: inherit;
font-size: 28px;
font-weight: 600;
line-height: 44px;
text-align: center;
}
.digit-odometer :deep(.odometer-digit),
.digit-odometer :deep(.odometer-digit-spacer) {
width: 39px;
height: 44px;
line-height: 44px;
}
.digit-odometer :deep(.odometer-digit-inner) {
width: 39px;
height: 44px;
line-height: 44px;
}
.digit-odometer :deep(.odometer-value) {
width: 39px;
height: 44px;
line-height: 44px;
text-align: center;
}
.line-chart {
position: absolute;
right: 0;
bottom: 14px;
left: 10px;
height: 238px;
}
}
</style> </style>

View File

@ -159,8 +159,8 @@ const chartOption = computed(() => ({
symbol: pointGlowSymbol, symbol: pointGlowSymbol,
symbolSize: [47, 109], symbolSize: [47, 109],
symbolOffset: [4, 0], symbolOffset: [4, 0],
symbolKeepAspect: true, symbolKeepAspect: false,
silent: true, silent: false,
z: 4, z: 4,
tooltip: { tooltip: {
show: false show: false

View File

@ -167,12 +167,13 @@ const summaryCards = ref([
.rank-no { .rank-no {
min-width: 74px; min-width: 74px;
font-size: 26px; font-size: 26px;
font-style: italic;
font-weight: 900; font-family: YouSheBiaoTiHei;
line-height: 1; font-size: 20px;
font-weight: normal;
letter-spacing: 1px; letter-spacing: 1px;
text-transform: uppercase; text-transform: uppercase;
text-shadow: 0 0 12px rgba(0, 210, 255, 0.15);
@each $rank, $color in $rank-colors { @each $rank, $color in $rank-colors {
&--#{$rank} { &--#{$rank} {

View File

@ -1,7 +1,21 @@
<template> <template>
<section class="traffic-6">6</section> <section class="traffic-6">
<div class="traffic-image-list">
<img
v-for="item in imageList"
:key="item"
class="traffic-image"
:src="`/imgs/traffic/${item}`"
alt=""
>
</div>
</section>
</template> </template>
<script setup>
const imageList = ['image1.png', 'image2.png', 'image3.png', 'image4.png']
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.traffic-6 { .traffic-6 {
position: absolute; position: absolute;
@ -10,8 +24,29 @@
width: 2923px; width: 2923px;
height: 535px; height: 535px;
opacity: 1; opacity: 1;
background: url('/imgs/traffic/6-bg.png') no-repeat center center; background: url('/imgs/traffic/6-bg.png') no-repeat center center;
background-size: cover; background-size: cover;
box-sizing: border-box;
.traffic-image-list {
position: absolute;
top: 58px;
left: 24px;
right: 24px;
display: grid;
grid-template-columns: 702px 689px 702px 702px;
gap: 16px;
align-items: center;
}
.traffic-image {
width: 100%;
height: 419px;
display: block;
object-fit: cover;
border: 4px solid rgba(0, 102, 255, 0.72);
box-sizing: border-box;
box-shadow: inset 0 0 18px rgba(0, 180, 255, 0.36), 0 0 10px rgba(0, 90, 255, 0.32);
}
} }
</style> </style>

View File

@ -1,7 +1,162 @@
<template> <template>
<section class="traffic-8"></section> <section class="traffic-8">
<div class="ranking-panel">
<div class="ranking-list">
<article v-for="item in rankingList" :key="item.rank" class="ranking-item">
<span class="ranking-index" :class="'ranking-index--r' + item.rank">
NO.{{ item.rank }}
</span>
<span class="line-badge" :style="getLineStyle(item.lineId)">
<template v-if="getLineNameParts(item.lineId).isNumberLine">
<strong>{{ getLineNameParts(item.lineId).number }}</strong>
<small>{{ getLineNameParts(item.lineId).suffix }}</small>
</template>
<template v-else>
{{ getLineName(item.lineId) }}
</template>
</span>
<div>{{ item.count }}</div>
</article>
</div>
</div>
<div class="comment-panel">
<label class="search-box">
<input v-model="keyword" type="text" aria-label="" />
<span class="search-icon"></span>
</label>
<div class="comment-list">
<article v-for="comment in filteredComments" :key="comment.id" class="comment-card">
<header>
<span class="comment-user">
<i class="user-icon"></i>
{{ comment.user }}
</span>
<time>{{ comment.date }}&nbsp;&nbsp;{{ comment.time }}</time>
<i class="status-icon"></i>
</header>
<p>{{ comment.content }}</p>
</article>
</div>
</div>
</section>
</template> </template>
<script setup>
import { computed, ref } from 'vue'
import { METRO_LINES } from '@/utils/const'
const keyword = ref('')
const rankingList = [
{ rank: 1, lineId: 3, count: 20 },
{ rank: 2, lineId: 4, count: 2 },
{ rank: 3, lineId: 7, count: 11 },
{ rank: 4, lineId: 41, count: 11 },
{ rank: 5, lineId: 51, count: 11 }
]
const getLineTheme = lineId => {
return METRO_LINES[Number(lineId)] || {
bg: '#2bd5ff',
color: '#00194a',
name: `${lineId}号线`
}
}
const getLineName = lineId => getLineTheme(lineId).name
const getLineNameParts = lineId => {
const name = getLineName(lineId)
const match = String(name).match(/^(\d+)(.*)$/)
if (!match) {
return {
isNumberLine: false,
text: name
}
}
return {
isNumberLine: true,
number: match[1],
suffix: match[2]
}
}
const getLineStyle = lineId => {
const theme = getLineTheme(lineId)
return {
color: theme.color,
backgroundColor: theme.bg
}
}
const comments = [
{
id: 1,
user: '莲花楼楼主_CC',
date: '03-28',
time: '16:21',
content: '把15号线改为青浦线或者华为专线有这么难吗。'
},
{
id: 2,
user: '莲花楼楼主_CC',
date: '03-28',
time: '16:21',
content: '把15号线改为青浦线或者华为专线有这么难吗。'
},
{
id: 3,
user: '莲花楼楼主_CC',
date: '03-28',
time: '16:21',
content: '把15号线改为青浦线或者华为专线有这么难吗。'
},
{
id: 4,
user: '莲花楼楼主_CC',
date: '03-28',
time: '16:21',
content: '把15号线改为青浦线或者华为专线有这么难吗。'
},
{
id: 5,
user: '莲花楼楼主_CC',
date: '03-28',
time: '16:21',
content: '把15号线改为青浦线或者华为专线有这么难吗。'
},
{
id: 6,
user: '莲花楼楼主_CC',
date: '03-28',
time: '16:21',
content: '把15号线改为青浦线或者华为专线有这么难吗。'
}
]
const filteredComments = computed(() => {
const text = keyword.value.trim()
if (!text) {
return comments
}
return comments.filter(item => {
return item.user.includes(text) || item.content.includes(text)
})
})
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.traffic-8 { .traffic-8 {
position: absolute; position: absolute;
@ -13,5 +168,239 @@
background: url('/imgs/traffic/8-bg.png') no-repeat center center; background: url('/imgs/traffic/8-bg.png') no-repeat center center;
background-size: cover; background-size: cover;
.ranking-panel,
.comment-panel {
position: absolute;
top: 120px;
bottom: 18px;
}
.ranking-panel {
left: 29px;
width: 426px;
}
.comment-panel {
right: 24px;
width: 430px;
}
.ranking-list {
height: calc(100% - 62px);
margin-top: 12px;
padding-right: 8px;
overflow-x: hidden;
overflow-y: auto;
}
.ranking-item {
height: 81px;
box-sizing: border-box;
display: grid;
grid-template-columns: 112px 1fr 86px;
align-items: center;
margin-bottom: 30px;
padding: 0 25px 0 24px;
background: rgba(44, 121, 236, 0.25);
color: #ffffff;
font-size: 22px;
font-weight: 700;
letter-spacing: 0.03em;
div {
font-family: YouSheBiaoTiHei;
font-size: 24px;
font-weight: 400;
text-align: end;
}
}
.ranking-index {
font-family: YouSheBiaoTiHei;
color: #2bd5ff;
font-size: 20px;
font-weight: 400;
&--r1 {
color: #ff1010;
}
&--r2 {
color: #EEB30F;
}
}
.line-badge {
justify-self: center;
min-width: 72px;
height: 30px;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 8px;
border-radius: 6px;
font-size: 18px;
font-weight: 500;
line-height: 1;
margin-right: 10px;
strong,
small {
line-height: 1;
}
strong {
font-size: 24px;
font-weight: 500;
}
small {
margin-left: 1px;
margin-bottom: 4px;
font-size: 12px;
font-weight: 500;
}
}
.search-box {
position: absolute;
top: 8px;
right: 22px;
width: 385px;
height: 32px;
box-sizing: border-box;
display: flex;
align-items: center;
padding: 0 38px 0 14px;
border: 2px solid rgba(37, 216, 255, 0.76);
border-radius: 18px;
background: rgba(16, 65, 143, 0.38);
box-shadow: inset 0 0 12px rgba(31, 211, 255, 0.18);
}
.search-box input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: #ffffff;
font-size: 17px;
font-weight: 500;
}
.search-icon {
position: absolute;
top: 4px;
right: 13px;
width: 17px;
height: 17px;
border: 2px solid #28d9ff;
border-radius: 50%;
cursor: pointer;
&::after {
content: '';
position: absolute;
right: -5px;
bottom: -4px;
width: 8px;
height: 2px;
transform: rotate(45deg);
border-radius: 2px;
background: #28d9ff;
}
}
.comment-list {
position: absolute;
top: 56px;
right: 0;
bottom: 0;
left: 0;
display: flex;
flex-direction: column;
gap: 13px;
overflow-x: hidden;
overflow-y: auto;
padding-right: 5px;
}
.comment-card {
min-height: 104px;
box-sizing: border-box;
flex-shrink: 0;
padding: 15px 16px 14px 16px;
background: rgba(44, 121, 236, 0.25);
color: #ffffff;
header {
display: flex;
align-items: center;
min-width: 0;
}
time {
margin-left: auto;
color: rgba(235, 248, 255, 0.92);
font-weight: 500;
font-size: 20px;
line-height: 24px;
white-space: nowrap;
}
p {
margin: 12px 19px 0 34px;
color: rgba(230, 230, 230, 1);
font-size: 20px;
font-weight: 400;
line-height: 26px;
letter-spacing: 0.02em;
}
}
.comment-user {
display: flex;
align-items: center;
min-width: 0;
max-width: 224px;
color: #ffffff;
font-size: 22px;
font-weight: 500;
line-height: 24px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-icon,
.status-icon {
flex-shrink: 0;
display: inline-block;
background-repeat: no-repeat;
background-position: center;
background-size: contain;
}
.user-icon {
width: 21px;
height: 21px;
margin-right: 9px;
background-image: url('/imgs/traffic/8-item.png');
}
.status-icon {
width: 18px;
height: 18px;
margin-left: 7px;
background-image: url('/imgs/traffic/8-item1.png');
}
} }
</style> </style>

View File

@ -0,0 +1,255 @@
<template>
<article class="traffic-rank-row" :class="`traffic-rank-row--${fillType}`">
<div class="row-fill" :style="{ width: `${item.percent}%` }"></div>
<div class="rank-hex" :class="{ 'rank-hex--highlight': item.rank < 4 }">{{ rankText }}</div>
<div class="tag-cell">
<div class="line-list">
<span v-for="lineId in lineIds" :key="lineId" class="line-tag" :style="getLineStyle(lineId)">
{{ lineId }}
</span>
</div>
</div>
<div class="station-cell">
<span class="station-name">{{ item.station }}</span>
</div>
<div class="type-cell">{{ item.type }}</div>
<div class="flow-cell flow-cell--today" :class="{ 'flow-cell--highlight': item.rank < 4 }">
<strong>{{ item.today }}</strong>
<span>万人</span>
</div>
<div class="flow-cell flow-cell--compare">
<strong>{{ item.compare }}</strong>
<span>万人</span>
</div>
</article>
</template>
<script setup>
import { computed } from 'vue'
import { METRO_LINES } from '@/utils/const'
const props = defineProps({
item: {
type: Object,
required: true
}
})
const rankText = computed(() => String(props.item.rank).padStart(2, '0'))
const fillType = computed(() => {
const rank = Number(props.item.rank)
if (rank === 1) {
return 'first'
}
if (rank === 2) {
return 'second'
}
if (rank === 3) {
return 'third'
}
return 'normal'
})
const lineIds = computed(() => {
return String(props.item.lines || '')
.split(',')
.map(item => item.trim())
.filter(Boolean)
})
const getLineStyle = lineId => {
const theme = METRO_LINES[Number(lineId)] || {
bg: '#2bd5ff',
color: '#00194a'
}
return {
backgroundColor: theme.bg + 'aa',
border:`1px solid ${theme.bg}`,
color: theme.color
}
}
</script>
<style lang="scss" scoped>
.traffic-rank-row {
position: relative;
height: 44px;
display: grid;
grid-template-columns: 66px 120px 200px 130px 160px 146px;
align-items: center;
overflow: hidden;
color: #ffffff;
background: linear-gradient(90deg, #0085ff00 0%, #00a3ff32 7%, #00a3ff33 46%, #0075ff0a 100%);
&--first {
--fill-start: #0085ff00;
--fill-center1: #00ffff33;
--fill-center2: #00ffff33;
--fill-end: #0075ff14;
--bar-start: #00b7ff2e;
--bar-end: #00FFFF;
}
&--second {
--fill-start: #00d9ff00;
--fill-center1: #00ffdd33;
--fill-center2: #00ffdd33;
--fill-end: #00d9ff14;
--bar-start: #00b7ff2e;
--bar-end: #00ffdd;
}
&--third {
--fill-start: #0085ff00;
--fill-center1: #0099ff52;
--fill-center2: #00b7ff52;
--fill-end: #0075ff14 ;
--bar-start: #00b7ff2e;
--bar-end: #00b7ff;
}
&--normal {
--fill-start: #ffffff00;
--fill-center1: #f5f5f533;
--fill-center2: #f1f1f133;
--fill-end: #ffffff14 ;
--bar-start: #c6c6c62e;
--bar-end: #ffffffcc;
}
}
.row-fill {
position: absolute;
top: 0;
bottom: 0;
left: 66px;
z-index: 0;
background: linear-gradient(90deg, var(--fill-start) 0%,var(--fill-center1) 42%,var(--fill-center2) 80%, var(--fill-end) 100%);
&::after {
content: '';
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 6px;
background: linear-gradient(90deg, var(--bar-start) 0%, var(--bar-end) 100%);
}
}
.rank-hex,
.station-cell,
.tag-cell,
.type-cell,
.flow-cell {
position: relative;
z-index: 1;
}
.rank-hex {
justify-self: center;
width: 53px;
height: 43px;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 22px;
font-weight: 400;
background: url('/imgs/traffic/1-item.png') no-repeat center center;
background-size: contain;
&--highlight {
color: #17f7ff;
text-shadow: 0 0 7px rgba(21, 247, 255, 0.6);
}
}
.station-cell {
margin-left: 30px;
}
.tag-cell {
margin-left: 10px;
}
.line-list {
width: 93px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 3px;
flex-shrink: 0;
}
.line-tag {
width: 22px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 1px;
font-size: 17px;
font-weight: 400;
line-height: 18px;
box-sizing: border-box;
}
.station-name,
.type-cell {
overflow: hidden;
color: #ffffff;
font-size: 23px;
font-weight: 500;
line-height: 1;
white-space: nowrap;
text-overflow: ellipsis;
}
.type-cell {
text-align: center;
margin-left: 10px;
}
.flow-cell {
display: flex;
align-items: baseline;
justify-content: center;
color: #ffffff;
margin-left: 20px;
strong {
font-size: 30px;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
}
span {
margin-left: 2px;
font-size: 18px;
font-weight: 500;
line-height: 1;
}
&--highlight {
color: #12f8ff;
}
}
</style>

964
yarn.lock

File diff suppressed because it is too large Load Diff