feat(web): 撤销静态SVG图片渲染,回退到自动线条生成模式,并保留断点支持

This commit is contained in:
2026-07-11 22:23:12 +08:00
parent 0c1341e9c7
commit 1ce7fd630b
6 changed files with 207 additions and 192 deletions
+4 -4
View File
@@ -16,8 +16,8 @@
"mtimeMs": 1783602619602.658
},
"ticket-order.js": {
"size": 25105,
"mtimeMs": 1783775559758.2654
"size": 32589,
"mtimeMs": 1783779020571.2231
},
"ticket-search.js": {
"size": 17269,
@@ -28,8 +28,8 @@
"mtimeMs": 1783666241687.1482
},
"index.js": {
"size": 84094,
"mtimeMs": 1783777527829.4636
"size": 82754,
"mtimeMs": 1783779774480.386
},
"ic-card-admin.js": {
"size": 19095,
+2 -2
View File
@@ -3,10 +3,10 @@
"blog.css": 2,
"custom-dialog.js": 11,
"public-status.js": 14,
"ticket-order.js": 17,
"ticket-order.js": 18,
"ticket-search.js": 12,
"ticket-route.js": 3,
"index.js": 4,
"index.js": 5,
"ic-card-admin.js": 3,
"ic-card-order.js": 3,
"ic-card-detail.js": 2,
+2 -60
View File
@@ -816,65 +816,7 @@
</div>
<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>
<optgroup v-for="g in unboundStationsGrouped" :key="g.name" :label="g.name + (g.isTransfer ? ' (换乘站)' : '') + (g.isBound ? ' - 已绑定' : '')">
<option v-for="s in g.stations" :key="s.code" :value="s.code">
{{ s.name }} ({{ s.code }})
</option>
</optgroup>
</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>
<img :src="assetsRouteMapUrl" alt="线路图" style="display:block; width: 100%; height: auto;" ref="mapImageRef">
</div>
<div v-else class="loading">未上传线路图</div>
</div>
@@ -978,7 +920,7 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="index.js?v=4"></script>
<script src="index.js?v=5"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
-75
View File
@@ -1139,80 +1139,6 @@ 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 unboundStationsGrouped = computed(() => {
const groups = {};
stations.value.forEach(s => {
const name = String(s.name || s.cn_name || '').trim();
if (!groups[name]) groups[name] = [];
groups[name].push(s);
});
const out = [];
for (const [name, list] of Object.entries(groups)) {
out.push({
name,
isTransfer: list.length > 1,
stations: list,
isBound: list.every(s => s.svg_x != null && s.svg_y != null)
});
}
return out.sort((a, b) => {
if (a.isBound !== b.isBound) return a.isBound ? 1 : -1;
if (a.isTransfer !== b.isTransfer) return a.isTransfer ? -1 : 1;
return 0;
});
});
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;
const pctX = Number(percentX.toFixed(2));
const pctY = Number(percentY.toFixed(2));
const targetName = String(station.name || station.cn_name || '').trim().toLowerCase();
// Auto-bind all stations with the same name (transfers)
const stationsToUpdate = stations.value.filter(s => {
if (s.code === mapBindingStation.value) return true;
const sName = String(s.name || s.cn_name || '').trim().toLowerCase();
return targetName && sName === targetName;
});
try {
for (const s of stationsToUpdate) {
s.svg_x = pctX;
s.svg_y = pctY;
await requestJson(`/api/stations/${encodeURIComponent(s.code)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(s)
}, { expectOk: true });
}
// 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 () => {
@@ -1845,7 +1771,6 @@ 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,
+1 -1
View File
@@ -158,7 +158,7 @@
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="/ticket-order.js?v=17"></script>
<script src="/ticket-order.js?v=18"></script>
<script src="/public-status.js?v=14"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
+196 -48
View File
@@ -90,10 +90,9 @@
async function renderSystemMap() {
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
try {
const [linesData, stationsData, manifestData] = await Promise.all([
const [linesData, stationsData] = await Promise.all([
fetch('/api/public/lines').then(r=>r.json()),
fetch('/api/public/stations').then(r=>r.json()),
fetch('/api/assets/manifest').then(r=>r.json()).catch(() => ({ ok: true }))
fetch('/api/public/stations').then(r=>r.json())
]);
window.cachedStationsData = stationsData;
@@ -117,19 +116,55 @@
}
const lineStops = [];
const lineColors = [];
const linesByStation = new Map();
// NEW logic for breaking lines with '-'
let currentLineIdx = 0;
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, stops });
let currentSegment = [];
let addedAny = false;
for (const c of stops) {
if (c === '-') continue;
const arr = linesByStation.get(c) || [];
arr.push(lineStops.length - 1);
linesByStation.set(c, arr);
if (c === '-') {
if (currentSegment.length > 0) {
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', color, stops: currentSegment });
lineColors.push(color);
for (const stopCode of currentSegment) {
const arr = linesByStation.get(stopCode) || [];
arr.push(currentLineIdx);
linesByStation.set(stopCode, arr);
}
currentSegment = [];
addedAny = true;
}
} else {
currentSegment.push(c);
}
}
if (currentSegment.length > 0) {
lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', color, stops: currentSegment });
lineColors.push(color);
for (const stopCode of currentSegment) {
const arr = linesByStation.get(stopCode) || [];
arr.push(currentLineIdx);
linesByStation.set(stopCode, arr);
}
addedAny = true;
}
if (addedAny) {
currentLineIdx++;
}
}
if (lineStops.length === 0) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
return;
}
for (const [c, arr] of linesByStation.entries()) {
@@ -137,46 +172,161 @@
if (uniq.length >= 2) stationTransfer.add(c);
}
// Build Interactive Map Over Static SVG
if (!manifestData || !manifestData.routeMap) {
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂未上传静态线路图,请在控制台进行配置。</p></div>';
return;
// 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);
}
const routeMapUrl = `/assets/${manifestData.routeMap}?v=${manifestData.updatedAt || Date.now()}`;
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>';
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 => lineColors[i]).filter(Boolean);
transferColorsByStation[c] = Array.from(new Set(colors));
primaryColorByStation[c] = colors[0] || '#93a2b7';
}
let svgContent = '';
const drawnLegends = new Set();
for (const s of stationsData) {
const code = String(s.code || s.编号 || '').trim();
if (!code || s.svg_x == null || s.svg_y == null) continue;
for (const li of lineStops) {
const yLine = baseY + li.idx * lineGapY;
const x = s.svg_x;
const y = s.svg_y;
const name = stationNameByCode[code] || code;
const isTransfer = stationTransfer.has(code);
if (!drawnLegends.has(li.idx)) {
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>`;
drawnLegends.add(li.idx);
}
// We render interactive dots using percentage coordinates over the SVG
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>`
: '';
svgContent += `
<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>
<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>
`;
}
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>
`;
const width = Math.max(560, Math.max(...allStations.map(c => stationXByCanonical[stationCanonicalByCode[c] || c] || 0)) + 120);
const height = Math.max(260, baseY + currentLineIdx * lineGapY + 60);
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
} catch(e) {
console.error(e);
@@ -311,13 +461,15 @@
function getStationPoint(code) {
const normalized = String(code || '').trim();
if (!normalized) return null;
const el = document.querySelector(`.map-station-overlay[data-code="${normalized}"]`);
if (!el) return null;
return { x: el.offsetLeft, y: el.offsetTop };
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 };
}
function renderRouteOverlay() {
const svgEl = mapContainer.querySelector('.route-svg-overlay');
const svgEl = mapContainer.querySelector('svg');
if (!svgEl) return;
const existing = svgEl.querySelector('.route-overlay-group');
@@ -328,12 +480,6 @@
const points = currentRoute.map(getStationPoint).filter(Boolean);
if (points.length < 2) return;
// 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');
@@ -395,7 +541,9 @@
group.appendChild(arrowG);
}
svgEl.appendChild(group);
const firstStation = svgEl.querySelector('.map-station');
if (firstStation) svgEl.insertBefore(group, firstStation);
else svgEl.appendChild(group);
}
function updateSelectionUI(skipPreview = false) {