feat(web): 订票地图升级为静态SVG并叠加动态坐标交互;完善断点支持

This commit is contained in:
2026-07-11 21:15:48 +08:00
parent 690173c522
commit aede84f164
21 changed files with 316 additions and 262 deletions
+18 -18
View File
@@ -1,7 +1,7 @@
{
"style.css": {
"size": 77276,
"mtimeMs": 1781972131010.4126
"size": 86271,
"mtimeMs": 1783775484985.5583
},
"blog.css": {
"size": 994,
@@ -12,40 +12,40 @@
"mtimeMs": 1781967608378.205
},
"public-status.js": {
"size": 3814,
"mtimeMs": 1781967627973.2036
"size": 2782,
"mtimeMs": 1783602619602.658
},
"ticket-order.js": {
"size": 23614,
"mtimeMs": 1781974289008.8884
"size": 25105,
"mtimeMs": 1783775559758.2654
},
"ticket-search.js": {
"size": 11647,
"mtimeMs": 1781966517813.3787
"size": 17269,
"mtimeMs": 1783666241741.0564
},
"ticket-route.js": {
"size": 42284,
"mtimeMs": 1781967610560.6072
"size": 44004,
"mtimeMs": 1783666241687.1482
},
"index.js": {
"size": 63063,
"mtimeMs": 1781967609550.651
"size": 82021,
"mtimeMs": 1783775091220.4558
},
"ic-card-admin.js": {
"size": 15188,
"mtimeMs": 1781967608706.734
"size": 19095,
"mtimeMs": 1783666241303.3374
},
"ic-card-order.js": {
"size": 11528,
"mtimeMs": 1781967363353.6257
"size": 11488,
"mtimeMs": 1783234306158.3623
},
"ic-card-detail.js": {
"size": 5322,
"mtimeMs": 1781967346854.6172
},
"ic-card-search.js": {
"size": 5462,
"mtimeMs": 1781933937174.1592
"size": 14392,
"mtimeMs": 1783666241398.5178
},
"token.js": {
"size": 4064,
+9 -9
View File
@@ -1,16 +1,16 @@
{
"style.css": 12,
"style.css": 13,
"blog.css": 2,
"custom-dialog.js": 11,
"public-status.js": 13,
"ticket-order.js": 16,
"ticket-search.js": 11,
"ticket-route.js": 2,
"index.js": 2,
"ic-card-admin.js": 2,
"ic-card-order.js": 2,
"public-status.js": 14,
"ticket-order.js": 17,
"ticket-search.js": 12,
"ticket-route.js": 3,
"index.js": 3,
"ic-card-admin.js": 3,
"ic-card-order.js": 3,
"ic-card-detail.js": 2,
"ic-card-search.js": 2,
"ic-card-search.js": 3,
"token.js": 2,
"login.js": 2,
"blog.js": 2
+1 -1
View File
@@ -67,7 +67,7 @@
<span class="version">v1.0.12</span>
</footer>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="blog.js?v=2"></script>
</body>
+2 -2
View File
@@ -181,8 +181,8 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script>
const loadPublicAssets = async () => {
const fareMapBox = document.getElementById('fareMapBox');
+3 -3
View File
@@ -172,9 +172,9 @@
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/ic-card-admin.js?v=2"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script src="/ic-card-admin.js?v=3"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+2 -2
View File
@@ -99,9 +99,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-detail.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'
+3 -3
View File
@@ -105,9 +105,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ic-card-order.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-order.js?v=3"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+3 -3
View File
@@ -7,7 +7,7 @@
<title>IC卡查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14">
<link rel="stylesheet" href="/style.css?v=13">
</head>
<body class="public-search jr-public-page">
@@ -109,9 +109,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ic-card-search.js?v=3"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+92 -27
View File
@@ -7,7 +7,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>控制台|FSE铁路运输票务系统</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=14">
<link rel="stylesheet" href="style.css?v=13">
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/socket.io/socket.io.js"></script>
</head>
@@ -238,6 +238,9 @@
<button @click="addStationToLine"
:disabled="!newStation.code || !newStation.name"><i class="fas fa-plus"></i>
添加</button>
<button @click="addBreakpointToLine" class="btn" style="margin-left: 8px;" title="在末尾插入一个线路断点">
<i class="fas fa-cut"></i> 插入断点
</button>
</div>
</div>
@@ -261,37 +264,45 @@
</g>
<!--车站节点-->
<g v-for="(sCode, index) in selectedLine.stations" :key="sCode"
<g v-for="(sCode, index) in selectedLine.stations" :key="index"
@mousedown="onStationDragStart(index)" @mouseup="onStationDrop"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode)"
@mousemove="onStationDragOver(index)" @click="handleStationClick(sCode, index)"
class="station-node" :class="{
'selected': isStationSelected(sCode),
'fare-source': fareSelection[0] === sCode,
'fare-target': fareSelection[1] === sCode
}">
<!--车站节点图形-->
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
:stroke="selectedLine.color" stroke-width="3" />
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120" cy="100" r="8"
:fill="selectedLine.color" />
<template v-if="sCode === '-'">
<rect :x="50 + index * 120 - 10" :y="90" width="20" height="20" fill="var(--bg)" stroke="var(--danger)" stroke-dasharray="2,2" />
<text :x="50 + index * 120" y="105" text-anchor="middle" fill="var(--danger)" font-size="12" style="pointer-events: none;">✂</text>
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--danger)" font-size="10" style="pointer-events: none;">断点</text>
</template>
<template v-else>
<!--车站节点图形-->
<circle :cx="50 + index * 120" cy="100" r="14" fill="var(--bg)"
:stroke="selectedLine.color" stroke-width="3" />
<circle v-if="isStationSelected(sCode)" :cx="50 + index * 120" cy="100" r="8"
:fill="selectedLine.color" />
<!--节点标签-->
<text :x="50 + index * 120" y="70" text-anchor="middle" fill="var(--text)"
font-weight="bold" font-size="12" style="pointer-events: none;">{{
getStationName(sCode) }}</text>
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--muted)"
font-size="10" style="pointer-events: none;">{{ sCode }}</text>
<g v-if="getTransferLineBadges(sCode).length > 0">
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)" :key="`${sCode}-xfer-${li.id}`">
<circle :cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" cy="150" r="5"
:fill="li.color" stroke="#ffffff" stroke-width="1" />
<text :x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" y="165" text-anchor="middle"
fill="var(--muted)" font-size="7" style="pointer-events: none;">{{ li.id }}</text>
<!--节点标签-->
<text :x="50 + index * 120" y="70" text-anchor="middle" fill="var(--text)"
font-weight="bold" font-size="12" style="pointer-events: none;">{{
getStationName(sCode) }}</text>
<text :x="50 + index * 120" y="135" text-anchor="middle" fill="var(--muted)"
font-size="10" style="pointer-events: none;">{{ sCode }}</text>
<g v-if="getTransferLineBadges(sCode).length > 0">
<g v-for="(li, liIdx) in getTransferLineBadges(sCode)" :key="`${sCode}-xfer-${li.id}`">
<circle :cx="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" cy="150" r="5"
:fill="li.color" stroke="#ffffff" stroke-width="1" />
<text :x="(50 + index * 120) + (liIdx - (getTransferLineBadges(sCode).length - 1) / 2) * 14" y="165" text-anchor="middle"
fill="var(--muted)" font-size="7" style="pointer-events: none;">{{ li.id }}</text>
</g>
</g>
</g>
<!--删除-->
<title>{{ getStationName(sCode) }} ({{ sCode }}){{ getTransferTitleSuffix(sCode) }}</title>
<!--删除-->
<title>{{ getStationName(sCode) }} ({{ sCode }}){{ getTransferTitleSuffix(sCode) }}</title>
</template>
</g>
</svg>
<div v-else class="empty-state">
@@ -793,8 +804,62 @@
</div>
</div>
<div v-if="assetsRouteMapUrl" style="border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0b0b0f;">
<img :src="assetsRouteMapUrl" alt="线路图" style="display:block; width: 100%; height: auto;">
<div v-if="assetsRouteMapUrl" style="border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0b0b0f; padding: 16px;">
<div class="flex mb-4" style="gap: 10px; align-items: center; background: rgba(255,255,255,0.05); padding: 12px; border-radius: 8px;">
<label class="switch-label">
<input type="checkbox" v-model="mapBindingMode">
<span class="slider"></span>
<span class="label-text">开启坐标绑定模式</span>
</label>
<select v-if="mapBindingMode" v-model="mapBindingStation" style="width: 240px;">
<option value="">-- 选择要绑定的站点 --</option>
<option v-for="s in stations" :key="s.code" :value="s.code">{{ s.name }} ({{ s.code }})</option>
</select>
<div v-if="mapBindingMode" class="text-muted" style="font-size: 0.9em;">
在左侧选择站点后,点击下方图片中的位置即可绑定。绑定的坐标将用于网页订票展示。
</div>
</div>
<div style="position: relative; display: inline-block; width: 100%;" :style="{ cursor: mapBindingMode ? 'crosshair' : 'default' }" @click="onMapImageClick">
<img :src="assetsRouteMapUrl" alt="线路图" style="display:block; width: 100%; height: auto;" ref="mapImageRef">
<!-- 坐标标记点 -->
<template v-for="s in stations" :key="'marker-'+s.code">
<div v-if="s.svg_x != null && s.svg_y != null"
:style="{
position: 'absolute',
left: s.svg_x + '%',
top: s.svg_y + '%',
transform: 'translate(-50%, -50%)',
width: s.code === mapBindingStation ? '16px' : '10px',
height: s.code === mapBindingStation ? '16px' : '10px',
borderRadius: '50%',
backgroundColor: s.code === mapBindingStation ? 'var(--primary)' : 'rgba(255,50,50,0.7)',
border: '2px solid white',
boxShadow: '0 0 4px rgba(0,0,0,0.5)',
pointerEvents: 'none',
transition: 'all 0.2s ease'
}">
</div>
<div v-if="mapBindingMode && s.svg_x != null && s.svg_y != null"
:style="{
position: 'absolute',
left: s.svg_x + '%',
top: `calc(${s.svg_y}% + 12px)`,
transform: 'translateX(-50%)',
color: 'white',
background: 'rgba(0,0,0,0.7)',
padding: '2px 4px',
borderRadius: '4px',
fontSize: '10px',
pointerEvents: 'none',
whiteSpace: 'nowrap'
}">
{{ s.name }}
</div>
</template>
</div>
</div>
<div v-else class="loading">未上传线路图</div>
</div>
@@ -897,8 +962,8 @@
</div>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="index.js?v=6"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="index.js?v=3"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+66 -2
View File
@@ -1139,6 +1139,43 @@ createApp({
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
const zoomFareMapReset = () => { fareMapScale.value = 1; };
const mapBindingMode = ref(false);
const mapBindingStation = ref('');
const mapImageRef = ref(null);
const onMapImageClick = async (e) => {
if (!mapBindingMode.value || !mapBindingStation.value) return;
const img = mapImageRef.value;
if (!img) return;
const rect = img.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const percentX = (x / rect.width) * 100;
const percentY = (y / rect.height) * 100;
const station = stations.value.find(s => s.code === mapBindingStation.value);
if (!station) return;
station.svg_x = Number(percentX.toFixed(2));
station.svg_y = Number(percentY.toFixed(2));
try {
await requestJson(`/api/stations/${encodeURIComponent(station.code)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(station)
}, { expectOk: true });
// Optional: find the next station without coordinates and select it
const nextStation = stations.value.find(s => s.svg_x == null && s.svg_y == null);
if (nextStation) {
mapBindingStation.value = nextStation.code;
}
} catch (err) {
alert('保存坐标失败:' + err.message);
}
};
// --- Management Actions ---
const createStation = async () => {
@@ -1229,6 +1266,22 @@ createApp({
await updateLineStations(selectedLine.value.id, newStations);
};
const removeStationFromLineIndex = async (index) => {
if (!selectedLine.value) return;
const newStations = [...(selectedLine.value.stations || [])];
newStations.splice(index, 1);
await updateLineStations(selectedLine.value.id, newStations);
};
const addBreakpointToLine = async () => {
if (!selectedLine.value) return;
await runMutation(async () => {
const newStations = [...(selectedLine.value.stations || [])];
newStations.push('-');
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
});
};
const updateLineStations = async (lineId, stationsList, { skipFetchData } = {}) => {
// Need to update the whole line object usually, or a specific endpoint
// Assuming PUT /api/lines/:id updates fields provided
@@ -1336,11 +1389,21 @@ createApp({
});
};
const handleStationClick = async (code) => {
const handleStationClick = async (code, index) => {
if (lineViewportPan.moved) {
lineViewportPan.moved = false;
return;
}
if (code === '-') {
if (await appDialog.confirm({
title: '移除断点',
message: `确定移除该断点?`,
confirmText: '确认移除'
})) {
await removeStationFromLineIndex(index);
}
return;
}
if (stationEditMode.value) {
openStationModal(code);
return;
@@ -1730,12 +1793,13 @@ createApp({
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
mapBindingMode, mapBindingStation, mapImageRef, onMapImageClick,
// Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
isStationInLine, addStationToLine, removeStationFromLine,
isStationInLine, addStationToLine, removeStationFromLine, removeStationFromLineIndex, addBreakpointToLine,
handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
+1 -1
View File
@@ -64,7 +64,7 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="login.js?v=2"></script>
</body>
</html>
+3 -3
View File
@@ -7,7 +7,7 @@
<title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" />
<link rel="stylesheet" href="/style.css?v=13" />
</head>
<body class="public-search jr-public-page">
@@ -120,9 +120,9 @@
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-search.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+38
View File
@@ -4485,3 +4485,41 @@ body.jr-admin-login-page {
width: 100%;
}
}
/* Static Map Overlays */
.map-station-overlay {
z-index: 10;
}
.map-station-overlay .station-hotspot {
width: 24px;
height: 24px;
border-radius: 50%;
background-color: transparent;
border: 3px solid transparent;
transition: all 0.2s ease;
}
.map-station-overlay:hover .station-hotspot {
background-color: rgba(255, 255, 255, 0.5);
border-color: rgba(59, 130, 246, 0.8);
box-shadow: 0 0 8px rgba(0,0,0,0.3);
}
.map-station-overlay.start .station-hotspot {
background-color: var(--success);
border-color: white;
box-shadow: 0 0 0 2px var(--success);
}
.map-station-overlay.end .station-hotspot {
background-color: var(--danger);
border-color: white;
box-shadow: 0 0 0 2px var(--danger);
}
.map-station-overlay.route .station-hotspot {
background-color: var(--primary);
border-color: white;
}
.map-station-overlay.route-transfer .station-hotspot {
background-color: var(--warning);
border-color: white;
}
␍
+2 -2
View File
@@ -195,8 +195,8 @@
</div>
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+3 -3
View File
@@ -157,9 +157,9 @@
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/ticket-order.js?v=21"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-order.js?v=17"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isRemote = location.hostname.includes('fse-media.group');
+62 -175
View File
@@ -1,4 +1,4 @@
(() => {
(() => {
const $ = (sel) => document.querySelector(sel);
const fromEl = $('#from');
const toEl = $('#to');
@@ -90,9 +90,10 @@
async function renderSystemMap() {
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
try {
const [linesData, stationsData] = await Promise.all([
const [linesData, stationsData, manifestData] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()),
fetch('/api/public/stations').then(r=>r.json())
fetch('/api/public/stations').then(r=>r.json()),
fetch('/api/assets/manifest').then(r=>r.json()).catch(() => ({ ok: true }))
]);
window.cachedStationsData = stationsData;
@@ -116,186 +117,66 @@
}
const lineStops = [];
const lineColors = [];
const linesByStation = new Map();
for (let i = 0; i < linesData.length; i++) {
const line = linesData[i] || {};
const color = line.color || line.颜色 || '#93a2b7';
const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []);
const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean);
if (stops.length === 0) continue;
lineStops.push({ idx: lineStops.length, name: line.name || line.线路名称 || '', color, stops });
lineColors.push(color);
lineStops.push({ idx: lineStops.length, stops });
for (const c of stops) {
if (c === '-') continue;
const arr = linesByStation.get(c) || [];
arr.push(lineStops.length - 1);
linesByStation.set(c, arr);
}
}
if (lineStops.length === 0) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
return;
}
for (const [c, arr] of linesByStation.entries()) {
const uniq = Array.from(new Set(arr));
if (uniq.length >= 2) stationTransfer.add(c);
}
// Build SVG
const legendW = 210;
const legendX = 16;
const legendSwatchW = 22;
const legendTextX = legendX + legendSwatchW + 10;
const startX = legendW + 90;
const baseY = 44;
const lineGapY = 92;
const minGapX = 78;
const occurrences = {};
for (const li of lineStops) {
for (let i = 0; i < li.stops.length; i++) {
const code = li.stops[i];
const canonical = stationCanonicalByCode[code] || code;
if (!occurrences[canonical]) occurrences[canonical] = [];
occurrences[canonical].push(i * minGapX);
}
}
stationXByCanonical = {};
for (const canonical of Object.keys(occurrences)) {
const arr = occurrences[canonical];
const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length);
stationXByCanonical[canonical] = startX + Math.round(avg);
}
for (let pass = 0; pass < 3; pass++) {
for (const li of lineStops) {
let prevX = startX - minGapX;
for (const code of li.stops) {
const canonical = stationCanonicalByCode[code] || code;
const x = stationXByCanonical[canonical] ?? (prevX + minGapX);
const nx = Math.max(x, prevX + minGapX);
if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) stationXByCanonical[canonical] = nx;
prevX = stationXByCanonical[canonical];
}
}
}
const primaryLineByStation = {};
for (const [c, arr] of linesByStation.entries()) {
const uniq = Array.from(new Set(arr)).sort((a,b)=>a-b);
if (uniq.length === 0) continue;
primaryLineByStation[c] = uniq[0];
}
stationYByCanonical = {};
const ysByCanonical = {};
for (const c of Object.keys(primaryLineByStation)) {
const li = primaryLineByStation[c];
if (li == null) continue;
const canonical = stationCanonicalByCode[c] || c;
if (!ysByCanonical[canonical]) ysByCanonical[canonical] = [];
ysByCanonical[canonical].push(baseY + li * lineGapY);
}
const canonicalY = {};
for (const canonical of Object.keys(ysByCanonical)) {
const ys = ysByCanonical[canonical];
canonicalY[canonical] = ys.reduce((a,b) => a+b, 0) / ys.length;
}
for (const c of Object.keys(primaryLineByStation)) {
const canonical = stationCanonicalByCode[c] || c;
stationYByCanonical[c] = canonicalY[canonical];
}
const allStations = Object.keys(primaryLineByStation);
if (allStations.length === 0) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>线路数据为空,请稍后再试。</p></div>';
// Build Interactive Map Over Static SVG
if (!manifestData || !manifestData.routeMap) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂未上传静态线路图,请在控制台进行配置。</p></div>';
return;
}
const labelShownForCanonical = new Set();
const transferColorsByStation = {};
const primaryColorByStation = {};
for (const c of allStations) {
const lineIdxs = Array.from(new Set(linesByStation.get(c) || [])).sort((a,b)=>a-b);
const colors = lineIdxs.map(i => lineStops[i]?.color).filter(Boolean);
transferColorsByStation[c] = Array.from(new Set(colors));
primaryColorByStation[c] = colors[0] || '#93a2b7';
}
const routeMapUrl = `/assets/${manifestData.routeMap}?v=${manifestData.updatedAt || Date.now()}`;
let svgContent = '';
for (const li of lineStops) {
const yLine = baseY + li.idx * lineGapY;
svgContent += `<rect x="${legendX}" y="${yLine-10}" width="${legendSwatchW}" height="10" rx="5" fill="${li.color}" />`;
svgContent += `<text x="${legendTextX}" y="${yLine-1}" fill="#e5e7eb" font-size="16" font-weight="800">${li.name}</text>`;
if (li.stops.length >= 2) {
const pts = li.stops.map(code => {
const canonical = stationCanonicalByCode[code] || code;
return {
x: stationXByCanonical[canonical] || 0,
y: stationYByCanonical[code] ?? yLine
};
});
let d = `M ${pts[0].x} ${pts[0].y}`;
for (let i = 1; i < pts.length; i++) {
d += ` L ${pts[i].x} ${pts[i].y}`;
}
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
}
}
for (const canonical of Object.keys(stationCodesByCanonical)) {
const codes = (stationCodesByCanonical[canonical] || []).filter(code => Number.isFinite(stationYByCanonical[code]));
if (codes.length < 2) continue;
const x = stationXByCanonical[canonical];
const y = stationYByCanonical[codes[0]];
const labelY = y + 34;
svgContent += `<circle cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />`;
svgContent += `<rect x="${x - 42}" y="${labelY - 16}" width="84" height="24" rx="12" fill="rgba(241,245,249,0.92)" stroke="#cbd5e1" stroke-width="1.5" />`;
svgContent += `<text x="${x}" y="${labelY}" text-anchor="middle" fill="#334155" font-size="13" font-weight="800">${getStationDisplayName(canonical)}</text>`;
labelShownForCanonical.add(canonical);
}
for (const c of allStations) {
const canonical = stationCanonicalByCode[c] || c;
const x = stationXByCanonical[canonical];
const yNode = stationYByCanonical[c] ?? baseY;
const name = stationNameByCode[c] || c;
const isTransfer = stationTransfer.has(c);
const cls = isTransfer ? 'map-station transfer' : 'map-station';
const ringSvg = '';
const primaryColor = primaryColorByStation[c] || '#93a2b7';
const outer = isTransfer
? `<circle cx="${x}" cy="${yNode}" r="13" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`
: `<circle cx="${x}" cy="${yNode}" r="12" fill="#ffffff" stroke="${primaryColor}" stroke-width="5" />`;
const transferFill = isTransfer ? `<circle cx="${x}" cy="${yNode}" r="7" fill="#ffffff" stroke="#111827" stroke-width="3" />` : '';
const coreFill = isTransfer ? '#ffffff' : '#ffffff';
const showLabel = !isTransfer || !labelShownForCanonical.has(canonical);
const textSvg = showLabel
? `<text x="${x}" y="${yNode+28}" text-anchor="middle" fill="#e5e7eb" font-size="14" font-weight="700">${name}</text>`
: '';
for (const s of stationsData) {
const code = String(s.code || s.编号 || '').trim();
if (!code || s.svg_x == null || s.svg_y == null) continue;
const x = s.svg_x;
const y = s.svg_y;
const name = stationNameByCode[code] || code;
const isTransfer = stationTransfer.has(code);
// We render interactive dots using percentage coordinates over the SVG
svgContent += `
<g class="${cls}" data-code="${c}" onclick="handleStationClick('${c}')" style="cursor:pointer">
${ringSvg}
${outer}
${transferFill}
<circle class="node-core" cx="${x}" cy="${yNode}" r="8" fill="${coreFill}" stroke="#111827" stroke-width="3" />
${textSvg}
</g>
<div class="map-station-overlay map-station" data-code="${code}"
onclick="handleStationClick('${code}')"
style="position: absolute; left: ${x}%; top: ${y}%; transform: translate(-50%, -50%); cursor: pointer;">
<div class="station-hotspot" title="${name} (${code})"></div>
</div>
`;
}
const width = Math.max(560, Math.max(...allStations.map(c => stationXByCanonical[stationCanonicalByCode[c] || c] || 0)) + 120);
const height = Math.max(260, baseY + lineStops.length * lineGapY + 60);
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
// Bind click events (standard onclick attribute in SVG string might not work in some contexts, but usually fine in innerHTML)
// Better to add event listeners via delegation
mapContainer.innerHTML = `
<div class="static-map-scroll-container" style="width: 100%; max-width: 100%; overflow: auto;">
<div class="static-map-wrapper" style="position: relative; width: 100%; min-width: 800px;">
<img src="${routeMapUrl}" alt="线路图" style="display: block; width: 100%; height: auto;" />
<svg class="route-svg-overlay" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;"></svg>
<div class="static-map-overlays" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
${svgContent}
</div>
</div>
</div>
`;
} catch(e) {
console.error(e);
@@ -430,28 +311,32 @@
function getStationPoint(code) {
const normalized = String(code || '').trim();
if (!normalized) return null;
const canonical = stationCanonicalByCode[normalized] || normalized;
const x = stationXByCanonical[canonical];
const y = stationYByCanonical[normalized];
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
return { x, y };
const el = document.querySelector(`.map-station-overlay[data-code="${normalized}"]`);
if (!el) return null;
return { x: el.offsetLeft, y: el.offsetTop };
}
function renderRouteOverlay() {
const svgEl = mapContainer.querySelector('svg');
if (!svgEl) return;
const svgEl = mapContainer.querySelector('.route-svg-overlay');
if (!svgEl) return;
const existing = svgEl.querySelector('.route-overlay-group');
if (existing) existing.remove();
const existing = svgEl.querySelector('.route-overlay-group');
if (existing) existing.remove();
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
if (!Array.isArray(currentRoute) || currentRoute.length < 2) return;
const points = currentRoute.map(getStationPoint).filter(Boolean);
if (points.length < 2) return;
const points = currentRoute.map(getStationPoint).filter(Boolean);
if (points.length < 2) return;
const ns = 'http://www.w3.org/2000/svg';
const group = document.createElementNS(ns, 'g');
group.setAttribute('class', 'route-overlay-group');
// Update SVG dimensions to match the image
const wrapper = mapContainer.querySelector('.static-map-wrapper');
if (wrapper) {
svgEl.setAttribute('viewBox', `0 0 ${wrapper.offsetWidth} ${wrapper.offsetHeight}`);
}
const ns = 'http://www.w3.org/2000/svg';
const group = document.createElementNS(ns, 'g');
group.setAttribute('class', 'route-overlay-group');
const pathData = points.map((pt, idx) => `${idx === 0 ? 'M' : 'L'} ${pt.x} ${pt.y}`).join(' ');
@@ -510,9 +395,7 @@
group.appendChild(arrowG);
}
const firstStation = svgEl.querySelector('.map-station');
if (firstStation) svgEl.insertBefore(group, firstStation);
else svgEl.appendChild(group);
svgEl.appendChild(group);
}
function updateSelectionUI(skipPreview = false) {
@@ -563,6 +446,10 @@
// Load Map on Start
renderSystemMap();
window.addEventListener('resize', () => {
if (currentRoute.length > 0) renderRouteOverlay();
});
async function previewPrice(force = false){
const seq = ++previewSeq;
const type = document.querySelector('input[name="trainType"]:checked').value;
+2 -2
View File
@@ -598,8 +598,8 @@
</div>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/public-status.js?v=14"></script>
<script src="ticket-route.js?v=3"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
+3 -3
View File
@@ -7,7 +7,7 @@
<title>票务查询|FSE铁路运输票务系统</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/style.css?v=14" />
<link rel="stylesheet" href="/style.css?v=13" />
</head>
<body class="public-search jr-public-page">
@@ -120,9 +120,9 @@
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-search.js?v=12"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+2 -2
View File
@@ -125,9 +125,9 @@
</footer>
</main>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="/custom-dialog.js?v=11"></script>
<script src="/token.js?v=2"></script>
<script src="/public-status.js?v=13"></script>
<script src="/public-status.js?v=14"></script>
<script>document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group'); const links = {
home: isDomain ? 'https://ticket.fse-media.group' : '/home.html', order: isDomain ? 'https://ticket.fse-media.group/order' : '/ticket-order.html', search: isDomain ? 'https://ticket.fse-media.group/search' : '/ticket-search.html', 'card-search': isDomain ? 'https://ticket.fse-media.group/ic-card/search' : '/ic-card-search.html', 'card-order': isDomain ? 'https://ticket.fse-media.group/ic-card/order' : '/ic-card-order.html'