(() => { const $ = (sel) => document.querySelector(sel); const fromEl = $('#from'); const toEl = $('#to'); const tripsEl = $('#trips'); const priceBox = $('#priceBox'); const voucherBox = $('#voucherBox'); let previewSeq = 0; let lastPreviewKey = ''; const api = { fareQuery: async (from, to) => { const r = await fetch(`/api/public/fares/query?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); return r.json(); }, createOrder: async (payload) => { const r = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) }); return r.json(); } }; // Fetch and Render Map const mapContainer = $('#stationMap'); let selection = [null, null]; let currentRoute = []; let currentRouteTransfers = []; let stationNameByCode = {}; let stationEnByCode = {}; let stationCanonicalByCode = {}; let stationCodesByCanonical = {}; let stationXByCanonical = {}; let stationYByCanonical = {}; let stationTransfer = new Set(); const piePath = (cx, cy, r, a0, a1) => { const x0 = cx + r * Math.cos(a0); const y0 = cy + r * Math.sin(a0); const x1 = cx + r * Math.cos(a1); const y1 = cy + r * Math.sin(a1); const large = (a1 - a0) > Math.PI ? 1 : 0; return `M ${cx} ${cy} L ${x0} ${y0} A ${r} ${r} 0 ${large} 1 ${x1} ${y1} Z`; }; const pieSvg = (cx, cy, r, colors) => { const cols = (Array.isArray(colors) ? colors.filter(Boolean) : []).slice(0, 4); if (cols.length === 0) return ''; if (cols.length === 1) return ``; const step = (Math.PI * 2) / cols.length; let out = ''; for (let i = 0; i < cols.length; i++) { const a0 = -Math.PI / 2 + i * step; const a1 = a0 + step; out += ``; } return out; }; const buildTransitPath = (points, radius = 12) => { if (!points || points.length === 0) return ''; if (points.length === 1) return `M ${points[0].x} ${points[0].y}`; const expanded = [points[0]]; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const cur = points[i]; const dx = cur.x - prev.x; const dy = cur.y - prev.y; if (dx === 0 || dy === 0 || Math.abs(dx) === Math.abs(dy)) { expanded.push(cur); } else { const absDx = Math.abs(dx); const absDy = Math.abs(dy); if (absDx > absDy) { const signX = Math.sign(dx); const straightLen = absDx - absDy; expanded.push({ x: prev.x + signX * (straightLen / 2), y: prev.y }); expanded.push({ x: cur.x - signX * (straightLen / 2), y: cur.y }); expanded.push(cur); } else { const signY = Math.sign(dy); const straightLen = absDy - absDx; expanded.push({ x: prev.x, y: prev.y + signY * (straightLen / 2) }); expanded.push({ x: cur.x, y: cur.y - signY * (straightLen / 2) }); expanded.push(cur); } } } const cleaned = [expanded[0]]; for (let i = 1; i < expanded.length; i++) { const last = cleaned[cleaned.length - 1]; const p = expanded[i]; if (Math.abs(last.x - p.x) > 0.1 || Math.abs(last.y - p.y) > 0.1) { cleaned.push(p); } } let d = `M ${cleaned[0].x} ${cleaned[0].y}`; for (let i = 1; i < cleaned.length; i++) { const prev = cleaned[i - 1]; const cur = cleaned[i]; const next = cleaned[i + 1]; if (!next) { d += ` L ${cur.x} ${cur.y}`; break; } const dx1 = cur.x - prev.x; const dy1 = cur.y - prev.y; const len1 = Math.sqrt(dx1*dx1 + dy1*dy1); const dx2 = next.x - cur.x; const dy2 = next.y - cur.y; const len2 = Math.sqrt(dx2*dx2 + dy2*dy2); if (len1 < 0.1 || len2 < 0.1) { d += ` L ${cur.x} ${cur.y}`; continue; } const cross = dx1 * dy2 - dy1 * dx2; if (Math.abs(cross) < 0.1) { d += ` L ${cur.x} ${cur.y}`; continue; } const r = Math.min(radius, len1 / 2, len2 / 2); const startX = cur.x - (dx1 / len1) * r; const startY = cur.y - (dy1 / len1) * r; const endX = cur.x + (dx2 / len2) * r; const endY = cur.y + (dy2 / len2) * r; d += ` L ${startX} ${startY} Q ${cur.x} ${cur.y} ${endX} ${endY}`; } return d; }; async function loadMap() { try { const res = await fetch('/api/public/fares/map/light'); const svg = await res.text(); mapContainer.innerHTML = svg; const svgEl = mapContainer.querySelector('svg'); if(!svgEl) return; renderLineMap(); } catch(e) { mapContainer.innerHTML = '
加载地图失败
'; } } async function renderLineMap() { try { const [lines, stations] = await Promise.all([ fetch('/api/public/lines').then(r=>r.json()), fetch('/api/public/stations').then(r=>r.json()) ]); let html = ''; lines.forEach(line => { const stList = line.站序 || []; }); } catch(e) {} } async function renderSystemMap() { mapContainer.innerHTML = '
加载线路数据...
'; try { const [linesData, stationsData] = await Promise.all([ fetch('/api/public/lines').then(r=>r.json()), fetch('/api/public/stations').then(r=>r.json()) ]); window.cachedStationsData = stationsData; stationTransfer = new Set(); stationCanonicalByCode = {}; stationCodesByCanonical = {}; stationNameByCode = {}; stationEnByCode = {}; for (const s of stationsData) { const code = String(s.code || s.编号 || '').trim(); if (!code) continue; stationNameByCode[code] = String(s.name || s.名称 || code); stationEnByCode[code] = String(s.en_name || s.英文名 || ''); } const transferGroups = buildTransferGroups(stationsData); stationCanonicalByCode = transferGroups.canonicalByCode; stationCodesByCanonical = transferGroups.codesByCanonical; for (const codes of Object.values(stationCodesByCanonical)) { if (codes.length >= 2) codes.forEach(code => stationTransfer.add(code)); } const lineStops = []; const lineColors = []; const linesByStation = new Map(); const lineIdxsByCanonical = {}; // 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; let currentSegment = []; let addedAny = false; for (const c of stops) { if (c === '-') { if (currentSegment.length > 0) { lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment }); lineColors.push(color); for (const stopCode of currentSegment) { const arr = linesByStation.get(stopCode) || []; arr.push(currentLineIdx); linesByStation.set(stopCode, arr); const canonical = stationCanonicalByCode[stopCode] || stopCode; if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set(); lineIdxsByCanonical[canonical].add(currentLineIdx); } currentSegment = []; addedAny = true; } } else { currentSegment.push(c); } } if (currentSegment.length > 0) { lineStops.push({ idx: currentLineIdx, name: line.name || line.线路名称 || '', enName: line.en_name || line.英文名称 || line.英文名 || '', color, stops: currentSegment }); lineColors.push(color); for (const stopCode of currentSegment) { const arr = linesByStation.get(stopCode) || []; arr.push(currentLineIdx); linesByStation.set(stopCode, arr); const canonical = stationCanonicalByCode[stopCode] || stopCode; if (!lineIdxsByCanonical[canonical]) lineIdxsByCanonical[canonical] = new Set(); lineIdxsByCanonical[canonical].add(currentLineIdx); } addedAny = true; } if (addedAny) { currentLineIdx++; } } if (lineStops.length === 0) { mapContainer.innerHTML = '

暂无可显示的线路数据。

'; 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 = 10; const startX = 140; const baseY = 80; 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]; if (code === '-') continue; const canonical = stationCanonicalByCode[code] || code; if (!occurrences[canonical]) occurrences[canonical] = []; occurrences[canonical].push(i * minGapX); } } const stationDegreeByCanonical = {}; for (const canonical of Object.keys(lineIdxsByCanonical)) { stationDegreeByCanonical[canonical] = lineIdxsByCanonical[canonical].size; } 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 sortedCanonicals = Object.keys(stationXByCanonical).sort((a, b) => stationXByCanonical[a] - stationXByCanonical[b]); for (let i = 1; i < sortedCanonicals.length; i++) { const prev = sortedCanonicals[i - 1]; const cur = sortedCanonicals[i]; const minSpacing = (stationDegreeByCanonical[prev] >= 2 || stationDegreeByCanonical[cur] >= 2) ? 108 : 82; if (stationXByCanonical[cur] < stationXByCanonical[prev] + minSpacing) { stationXByCanonical[cur] = stationXByCanonical[prev] + minSpacing; } } for (let pass = 0; pass < 3; pass++) { for (const li of lineStops) { let prevX = startX - minGapX; for (const code of li.stops) { if (code === '-') continue; const canonical = stationCanonicalByCode[code] || code; const x = stationXByCanonical[canonical] ?? (prevX + minGapX); const spacing = stationDegreeByCanonical[canonical] >= 2 ? 104 : minGapX; const nx = Math.max(x, prevX + spacing); if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) stationXByCanonical[canonical] = nx; prevX = stationXByCanonical[canonical]; } } } const hubYByCanonical = {}; for (const canonical of Object.keys(lineIdxsByCanonical)) { const idxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b); if (!idxs.length) continue; const ys = idxs.map(idx => baseY + idx * lineGapY); hubYByCanonical[canonical] = ys.reduce((sum, y) => sum + y, 0) / ys.length; } stationYByCanonical = {}; for (const li of lineStops) { const yNode = baseY + li.idx * lineGapY; for (const c of li.stops) { const canonical = stationCanonicalByCode[c] || c; stationYByCanonical[c] = hubYByCanonical[canonical] ?? yNode; } } const labelPlacements = {}; const placeLabel = (x, y, isStart, name, enName, color) => { const key = `${x},${y},${isStart ? 'left' : 'right'}`; const count = labelPlacements[key] || 0; labelPlacements[key] = count + 1; const hasEn = !!enName; const nameLen = Math.max(name.length * 13 + 16, hasEn ? enName.length * 6.5 + 16 : 0); const h = hasEn ? 36 : 24; const yOffset = count * 42 - (h / 2); let lx, textX; if (isStart) { lx = x - 28 - nameLen; textX = lx + nameLen / 2; } else { lx = x + 28; textX = lx + nameLen / 2; } let svg = ``; if (hasEn) { svg += `${name}`; svg += `${enName}`; } else { svg += `${name}`; } return svg; }; let svgContent = ''; for (const li of lineStops) { if (li.stops.length >= 2) { const pts = li.stops.map(code => { const canonical = stationCanonicalByCode[code] || code; return { x: stationXByCanonical[canonical] || 0, y: stationYByCanonical[code] ?? (baseY + li.idx * lineGapY) }; }); const d = buildTransitPath(pts, 16); svgContent += ``; // Add line name labels at ends const pStart = pts[0]; const pEnd = pts[pts.length - 1]; svgContent += placeLabel(pStart.x, pStart.y, true, li.name, li.enName, li.color); svgContent += placeLabel(pEnd.x, pEnd.y, false, li.name, li.enName, li.color); } } const sortedStations = Object.keys(stationXByCanonical).sort((a, b) => stationXByCanonical[a] - stationXByCanonical[b]); for (let i = 0; i < sortedStations.length; i++) { const canonical = sortedStations[i]; const x = stationXByCanonical[canonical]; const codes = stationCodesByCanonical[canonical] || [canonical]; const lineIdxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b); const colors = lineIdxs.map(idx => lineColors[idx]).filter(Boolean); const uniqueColors = Array.from(new Set(colors)); const primaryCode = codes[0]; const y = hubYByCanonical[canonical] ?? stationYByCanonical[primaryCode] ?? baseY; const isTransfer = uniqueColors.length >= 2 || codes.length >= 2; const name = getStationDisplayName(primaryCode); const enName = stationEnByCode[primaryCode] || ''; const showEn = !!enName; let markerSvg = ''; if (isTransfer) { markerSvg += ``; markerSvg += pieSvg(x, y, 11.5, uniqueColors); markerSvg += ``; } else { const color = uniqueColors[0] || '#93a2b7'; markerSvg += ``; markerSvg += ``; } const preferTop = isTransfer || (i % 2 === 0 && y >= baseY + 20); let labelSvg = ''; let textContent = ''; const codeFontSize = 9; const nameFontSize = 13; const enFontSize = 8.5; const codeW = primaryCode.length * 6 + 10; if (preferTop) { const bottomY = y - 14; const enY = bottomY; const nameY = showEn ? bottomY - 12 : bottomY; const codeY = nameY - 16; if (isTransfer) { const labelW = Math.max(72, name.length * 14 + 18, codeW + 16, showEn ? enName.length * 5.5 + 16 : 0); const labelH = showEn ? 46 : 34; const rectY = codeY - 10; labelSvg += ``; } textContent += ``; textContent += `${primaryCode}`; textContent += `${name}`; if (showEn) { textContent += `${enName}`; } } else { const topY = y + 24; const codeY = topY; const nameY = topY + 16; const enY = nameY + 12; if (isTransfer) { const labelW = Math.max(72, name.length * 14 + 18, codeW + 16, showEn ? enName.length * 5.5 + 16 : 0); const labelH = showEn ? 46 : 34; const rectY = codeY - 10; labelSvg += ``; } textContent += ``; textContent += `${primaryCode}`; textContent += `${name}`; if (showEn) { textContent += `${enName}`; } } labelSvg += textContent; svgContent += ` ${markerSvg} ${labelSvg} `; } const allX = Object.values(stationXByCanonical); const maxX = allX.length > 0 ? Math.max(...allX) : 0; const width = Math.max(800, maxX + 180); const height = Math.max(260, baseY + currentLineIdx * lineGapY + 60); mapContainer.innerHTML = `${svgContent}`; } catch(e) { console.error(e); mapContainer.innerHTML = '
地图加载失败
'; } } window.handleStationClick = (code) => { code = String(code || '').trim(); // Toggle if already selected if (selection[0] === code) { selection[0] = null; } else if (selection[1] === code) { selection[1] = null; } else { // Add new selection if (!selection[0]) { selection[0] = code; } else if (!selection[1]) { selection[1] = code; } else { // When both ends are already selected, start a new selection flow. selection[0] = code; selection[1] = null; currentRoute = []; currentRouteTransfers = []; } } // Ensure no gaps (if start removed, move end to start) if (!selection[0] && selection[1]) { selection[0] = selection[1]; selection[1] = null; } updateSelectionUI(); }; function normalizeTrips() { const raw = Number(tripsEl?.value || 1); const trips = Number.isFinite(raw) ? Math.max(1, Math.floor(raw)) : 1; if (tripsEl) tripsEl.value = String(trips); return trips; } function getStationDisplayName(code) { return stationNameByCode[String(code || '').trim()] || String(code || '').trim(); } function getStationNameKey(code) { const cn = getStationDisplayName(code).replace(/\s+/g, ''); const en = String(stationEnByCode[String(code || '').trim()] || '').toLowerCase().replace(/\s+/g, ''); return `${cn}|${en}`; } function buildTransferGroups(stationsData) { const parent = {}; const codesByName = {}; const find = (code) => { if (!parent[code]) parent[code] = code; if (parent[code] !== code) parent[code] = find(parent[code]); return parent[code]; }; const union = (a, b) => { if (!a || !b) return; const ra = find(a); const rb = find(b); if (ra !== rb) parent[rb] = ra; }; for (const s of stationsData) { const code = String(s?.code || s?.编号 || '').trim(); if (!code) continue; parent[code] = code; const cn = String(s?.name || s?.名称 || '').replace(/\s+/g, ''); const en = String(s?.en_name || s?.英文名 || '').toLowerCase().replace(/\s+/g, ''); const nameKey = `${cn}|${en}`; if (cn || en) { if (!codesByName[nameKey]) codesByName[nameKey] = []; codesByName[nameKey].push(code); } } for (const s of stationsData) { const code = String(s?.code || s?.编号 || '').trim(); if (!code) continue; const list = Array.isArray(s?.transfer_to) ? s.transfer_to : []; for (const item of list) { const to = String((typeof item === 'string') ? item : (item?.code || item?.station || item?.id || item?.[0] || '')).trim(); if (to) union(code, to); } } for (const codes of Object.values(codesByName)) { if (!Array.isArray(codes) || codes.length < 2) continue; for (let i = 1; i < codes.length; i++) { union(codes[0], codes[i]); } } const byRoot = {}; for (const code of Object.keys(parent)) { const root = find(code); if (!byRoot[root]) byRoot[root] = []; byRoot[root].push(code); } const canonicalByCode = {}; const codesByCanonical = {}; for (const codes of Object.values(byRoot)) { codes.sort((a, b) => a.localeCompare(b)); const canonical = codes[0]; codesByCanonical[canonical] = codes; for (const code of codes) canonicalByCode[code] = canonical; } return { canonicalByCode, codesByCanonical }; } function buildDisplayRouteCodes(route, from, to) { const middle = Array.isArray(route) ? route.filter(c => c && c !== from && c !== to) : []; const merged = []; for (const code of [from, ...middle, to].filter(Boolean)) { const prev = merged[merged.length - 1]; const prevName = prev ? getStationDisplayName(prev).replace(/\s+/g, '') : ''; const nextName = getStationDisplayName(code).replace(/\s+/g, ''); if (prev && prevName && prevName === nextName) continue; merged.push(code); } return merged; } 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 }; } function getMapStationEl(code) { const normalized = String(code || '').trim(); if (!normalized) return null; const canonical = stationCanonicalByCode[normalized] || normalized; return document.querySelector(`.map-station[data-canonical="${canonical}"]`); } function renderRouteOverlay() { const svgEl = mapContainer.querySelector('svg'); if (!svgEl) return; const existing = svgEl.querySelector('.route-overlay-group'); if (existing) existing.remove(); if (!Array.isArray(currentRoute) || currentRoute.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'); const pathData = buildTransitPath(points, 16); if (!pathData) return; const glow = document.createElementNS(ns, 'path'); glow.setAttribute('d', pathData); glow.setAttribute('fill', 'none'); glow.setAttribute('stroke', 'rgba(250, 204, 21, 0.38)'); glow.setAttribute('stroke-width', '18'); glow.setAttribute('stroke-linecap', 'round'); glow.setAttribute('stroke-linejoin', 'round'); const main = document.createElementNS(ns, 'path'); main.setAttribute('d', pathData); main.setAttribute('fill', 'none'); main.setAttribute('stroke', '#facc15'); main.setAttribute('stroke-width', '8'); main.setAttribute('stroke-linecap', 'round'); main.setAttribute('stroke-linejoin', 'round'); group.appendChild(glow); group.appendChild(main); // Dynamic arrows pointing to the destination let totalDist = 0; for (let i = 0; i < points.length - 1; i++) { const dx = points[i+1].x - points[i].x; const dy = points[i+1].y - points[i].y; totalDist += Math.sqrt(dx * dx + dy * dy); } const speed = 150; // pixels per second const duration = Math.max(1, totalDist / speed); const numArrows = Math.max(1, Math.floor(duration / 0.8)); const interval = duration / numArrows; for (let i = 0; i < numArrows; i++) { const arrowG = document.createElementNS(ns, 'g'); const arrowPath = document.createElementNS(ns, 'path'); // Chevron shape arrowPath.setAttribute('d', 'M -6 -5 L 4 0 L -6 5'); arrowPath.setAttribute('fill', 'none'); arrowPath.setAttribute('stroke', '#854d0e'); // Dark brown/orange to contrast with yellow arrowPath.setAttribute('stroke-width', '2.5'); arrowPath.setAttribute('stroke-linecap', 'round'); arrowPath.setAttribute('stroke-linejoin', 'round'); const animateMotion = document.createElementNS(ns, 'animateMotion'); animateMotion.setAttribute('dur', `${duration}s`); animateMotion.setAttribute('repeatCount', 'indefinite'); animateMotion.setAttribute('path', pathData); animateMotion.setAttribute('rotate', 'auto'); animateMotion.setAttribute('begin', `-${i * interval}s`); // Use keyPoints/keyTimes to ensure constant speed along the path animateMotion.setAttribute('calcMode', 'linear'); arrowG.appendChild(arrowPath); arrowG.appendChild(animateMotion); group.appendChild(arrowG); } const firstStation = svgEl.querySelector('.map-station'); if (firstStation) svgEl.insertBefore(group, firstStation); else svgEl.appendChild(group); } function updateSelectionUI(skipPreview = false) { if (!(selection[0] && selection[1])) { currentRoute = []; currentRouteTransfers = []; } // Update Inputs fromEl.value = selection[0] || ''; toEl.value = selection[1] || ''; // Update Displays with Chinese names const getName = (code) => { return stationNameByCode[String(code || '').trim()] || code; }; $('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择'; $('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择'; // Update Map Styles document.querySelectorAll('.map-station').forEach(el => { const code = el.getAttribute('data-code'); const canonical = el.getAttribute('data-canonical'); el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer'); if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) el.classList.add('start'); if (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) el.classList.add('end'); }); if (Array.isArray(currentRoute) && currentRoute.length > 0) { for (const c of currentRoute) { const el = getMapStationEl(c); if (el) el.classList.add('route'); } } if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.length > 0) { for (const c of currentRouteTransfers) { const el = getMapStationEl(c); if (el) el.classList.add('route-transfer'); } } renderRouteOverlay(); // Auto preview if both selected if(!skipPreview && selection[0] && selection[1]) previewPrice(); } // 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; const trips = normalizeTrips(); const from = (fromEl.value||'').trim(); const to = (toEl.value||'').trim(); if(!from || !to){ priceBox.innerHTML = '

请选择起点与终点

'; return; } const previewKey = `${from}|${to}|${type}|${trips}`; if (!force && previewKey === lastPreviewKey) return; lastPreviewKey = previewKey; try{ const fare = await api.fareQuery(from, to); if (seq !== previewSeq) return; if(fare && (fare.error || fare['错误'])){ priceBox.innerHTML = '
提示未找到对应票价
'; return; } const resolvedFrom = String(fare?.from_code || '').trim(); const resolvedTo = String(fare?.to_code || '').trim(); if ((resolvedFrom && resolvedFrom !== from) || (resolvedTo && resolvedTo !== to)) { lastPreviewKey = ''; priceBox.innerHTML = `

站点解析异常,当前后端返回的站码与页面选择不一致。

已选:${getStationDisplayName(from)}(${from}) -> ${getStationDisplayName(to)}(${to})

返回:${getStationDisplayName(resolvedFrom)}(${resolvedFrom || '-'}) -> ${getStationDisplayName(resolvedTo)}(${resolvedTo || '-'})

请先部署最新后端并清理 CDN 缓存后再试。

`; currentRoute = []; currentRouteTransfers = []; updateSelectionUI(true); return; } const discRaw = Number(fare?.discount ?? fare?.['折扣'] ?? 1); const disc = Number.isFinite(discRaw) && discRaw > 0 ? discRaw : 1; const base = (type==='Express') ? Number(fare?.express_fare ?? fare?.['特快票价'] ?? 0) : Number(fare?.regular_fare ?? fare?.['常规票价'] ?? 0); const discountedRaw = (type==='Express') ? (fare?.discounted_express_fare ?? fare?.['优惠后特快票价']) : (fare?.discounted_regular_fare ?? fare?.['优惠后常规票价']); const discountedSingle = Number(discountedRaw ?? Math.round(base * disc) ?? 0); const price = discountedSingle * trips; const routeKey = (type === 'Express') ? 'express_path' : 'regular_path'; const transferKey = (type === 'Express') ? 'express_transfers' : 'regular_transfers'; currentRoute = Array.isArray(fare?.[routeKey]) ? fare[routeKey] : []; currentRouteTransfers = Array.isArray(fare?.[transferKey]) ? fare[transferKey] : []; const displayRoute = buildDisplayRouteCodes(currentRoute, from, to); const routeText = displayRoute.length > 0 ? displayRoute.map(getStationDisplayName).join(' → ') : ''; const transferStops = []; const seenTransferNames = new Set(); for (const code of currentRouteTransfers) { if (code === from || code === to) continue; const nameKey = getStationDisplayName(code).replace(/\s+/g, ''); if (!nameKey || seenTransferNames.has(nameKey)) continue; seenTransferNames.add(nameKey); transferStops.push(code); } const transferHtml = transferStops.length ? `
${transferStops.map(c => `${getStationDisplayName(c)}`).join('')}
` : `无`; priceBox.innerHTML = `
原始票价${base}
优惠后票价${discountedSingle}
折扣-${Math.round((1-disc)*100)}%
${routeText ? `
路径${routeText}
` : ``}
换乘${transferHtml}
总价${price}
`; updateSelectionUI(true); }catch(e){ lastPreviewKey = ''; priceBox.innerHTML = '

票价预估失败,请稍后再试。

'; } } // Create Order with auto-preview async function createOrder(){ const type = document.querySelector('input[name="trainType"]:checked').value; const trips = normalizeTrips(); const ride_date = new Date().toISOString().slice(0, 10); let payload = null; const from = (fromEl.value||'').trim(); const to = (toEl.value||'').trim(); if(!from || !to){ alert('请完整填写信息'); return; } // Auto-fetch price before creating await previewPrice(true); payload = { start: from, terminal: to, train_type: type, trips, ride_date }; try{ const res = await fetch('/api/public/orders', { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify(payload) }); const r = await res.json(); if(r && r.ok){ // Link to external subdomain or local page const buildTokenLink = (code) => { if (location.hostname.includes('fse-media.group')) { return `https://ticket.fse-media.group/token?code=${encodeURIComponent(code)}`; } return `/token.html?code=${encodeURIComponent(code)}`; }; voucherBox.innerHTML = `
凭证码
${r.code}
请在游戏内任意售票机选择线上订票并输入该凭证码兑票。
`; }else{ alert('创建失败: ' + (r.error || '未知错误')); } }catch(e){ alert('创建失败'); } } const btnPreview = $('#previewPrice'); if(btnPreview) btnPreview.onclick = previewPrice; const btnCreate = $('#createOrder'); if(btnCreate) btnCreate.onclick = createOrder; // Listen for Type change to auto-update price document.querySelectorAll('input[name="trainType"]').forEach(el => { el.onchange = () => { if(fromEl.value && toEl.value) previewPrice(); }; }); if (tripsEl) { tripsEl.oninput = () => { if(fromEl.value && toEl.value) previewPrice(); }; tripsEl.onchange = () => { normalizeTrips(); if(fromEl.value && toEl.value) previewPrice(); }; } })();