(() => { 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(); // TVM UI State let activeLineId = null; let allLinesData = []; let allStationsData = []; 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; allLinesData = linesData; allStationsData = 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 linesByStation = new Map(); for (let i = 0; i < linesData.length; i++) { const line = linesData[i] || {}; const stopsRaw = Array.isArray(line.stops) ? line.stops : (Array.isArray(line.站点列表) ? line.站点列表 : []); const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean); for (const c of stops) { if (c === '-') continue; const arr = linesByStation.get(c) || []; arr.push(line); linesByStation.set(c, arr); } } for (const [c, arr] of linesByStation.entries()) { const uniq = Array.from(new Set(arr.map(l => l.id))); if (uniq.length >= 2) stationTransfer.add(c); } // Select first line by default if none selected if (!activeLineId && allLinesData.length > 0) { activeLineId = allLinesData[0].id; } renderTvmUI(); } catch(e) { console.error(e); mapContainer.innerHTML = '
数据加载失败
'; } } function renderTvmUI() { if (!allLinesData || allLinesData.length === 0) { mapContainer.innerHTML = '

暂无可显示的线路数据。

'; return; } const nodeSlotWidth = window.innerWidth <= 768 ? 58 : 70; const trackPadding = Math.round(nodeSlotWidth / 2); // 1. Render Line Tabs let tabsHtml = `
`; allLinesData.forEach(line => { const color = line.color || '#94a3b8'; const isActive = String(line.id) === String(activeLineId); const bg = isActive ? color : 'var(--bg-color, #ffffff)'; const fg = isActive ? '#ffffff' : 'var(--text-color, #334155)'; const border = isActive ? color : 'var(--border-color, #cbd5e1)'; tabsHtml += ` `; }); tabsHtml += `
`; // 2. Render Stations for Active Line const activeLine = allLinesData.find(l => String(l.id) === String(activeLineId)) || allLinesData[0]; const stopsRaw = Array.isArray(activeLine.stops) ? activeLine.stops : (Array.isArray(activeLine.站点列表) ? activeLine.站点列表 : []); const stops = stopsRaw.map(c => String(c || '').trim()).filter(Boolean); const visibleStops = stops.filter(code => code !== '-'); let stationsHtml = `
`; stationsHtml += `
`; stationsHtml += `
`; const getVisibleStopIndex = (targetCode) => { const targetCanonical = stationCanonicalByCode[String(targetCode || '').trim()] || String(targetCode || '').trim(); return visibleStops.findIndex(code => (stationCanonicalByCode[code] || code) === targetCanonical); }; const fromIndex = getVisibleStopIndex(selection[0]); const toIndex = getVisibleStopIndex(selection[1]); const sameLineSelection = fromIndex >= 0 && toIndex >= 0 && selection[0] && selection[1] && (!Array.isArray(currentRouteTransfers) || currentRouteTransfers.length === 0); if (sameLineSelection) { const startIndex = Math.min(fromIndex, toIndex); const endIndex = Math.max(fromIndex, toIndex); const left = (startIndex * nodeSlotWidth) + trackPadding; const width = Math.max((endIndex - startIndex) * nodeSlotWidth, 0); const arrowCount = Math.max(1, Math.floor(width / 40)); let arrowsHtml = ''; for (let i = 0; i < arrowCount; i++) { arrowsHtml += `›`; } stationsHtml += `
${arrowsHtml}
`; } let hasStations = false; stops.forEach(code => { if (code === '-') return; hasStations = true; const name = stationNameByCode[code] || code; const enName = stationEnByCode[code] || ''; const isSelectedFrom = selection[0] === code; const isSelectedTo = selection[1] === code; const inRoute = Array.isArray(currentRoute) && currentRoute.includes(code); // Check transfers const canonical = stationCanonicalByCode[code] || code; const codesInGroup = stationCodesByCanonical[canonical] || [canonical]; const linesForThisStation = new Set(); codesInGroup.forEach(c => { allLinesData.forEach(l => { const lStops = Array.isArray(l.stops) ? l.stops : (Array.isArray(l.站点列表) ? l.站点列表 : []); if (lStops.includes(c)) linesForThisStation.add(l); }); }); const transferLines = Array.from(linesForThisStation).filter(l => String(l.id) !== String(activeLineId)); const isTransfer = transferLines.length > 0; let nodeColor = activeLine.color || '#94a3b8'; let nodeSize = isTransfer ? '20px' : '14px'; let nodeBorder = isTransfer ? '4px' : '3px'; let badgeHtml = ''; if (isSelectedFrom) { nodeColor = 'var(--success, #16a34a)'; nodeSize = '24px'; badgeHtml = `
起点
`; } else if (isSelectedTo) { nodeColor = 'var(--danger, #dc2626)'; nodeSize = '24px'; badgeHtml = `
终点
`; } else if (inRoute) { nodeColor = 'var(--primary, #facc15)'; if (!isTransfer) nodeSize = '18px'; else nodeSize = '24px'; } let transferDotsHtml = ''; if (isTransfer) { transferDotsHtml = `
`; transferLines.slice(0, 3).forEach(l => { transferDotsHtml += `
`; }); transferDotsHtml += `
`; } stationsHtml += `
${badgeHtml} ${transferDotsHtml}
${code}
${name}
${enName ? `
${enName}
` : ''}
`; }); if (!hasStations) { stationsHtml += `
该线路暂无站点
`; } stationsHtml += `
`; mapContainer.innerHTML = tabsHtml + stationsHtml; } window.switchTvmLine = (lineId) => { activeLineId = lineId; renderTvmUI(); }; 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 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]) : '请在上方地图选择'; // Refresh TVM UI to show selected/route states renderTvmUI(); // Auto preview if both selected if(!skipPreview && selection[0] && selection[1]) previewPrice(); } // Load Map on Start renderSystemMap(); window.addEventListener('resize', () => { if (!allLinesData || allLinesData.length === 0) return; renderTvmUI(); }); 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(); }; } })();