(() => {
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;
};
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();
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);
for (const c of stops) {
const arr = linesByStation.get(c) || [];
arr.push(lineStops.length - 1);
linesByStation.set(c, arr);
}
}
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 = 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 = {};
for (const c of Object.keys(primaryLineByStation)) {
const li = primaryLineByStation[c];
if (li == null) continue;
stationYByCanonical[c] = baseY + li * lineGapY;
}
const allStations = Object.keys(primaryLineByStation);
if (allStations.length === 0) {
mapContainer.innerHTML = '';
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';
}
let svgContent = '';
for (const li of lineStops) {
const yLine = baseY + li.idx * lineGapY;
svgContent += ``;
svgContent += `${li.name}`;
if (li.stops.length >= 2) {
const firstX = stationXByCanonical[stationCanonicalByCode[li.stops[0]] || li.stops[0]];
let d = `M ${firstX} ${yLine}`;
for (let i = 1; i < li.stops.length; i++) {
const x = stationXByCanonical[stationCanonicalByCode[li.stops[i]] || li.stops[i]];
d += ` L ${x} ${yLine}`;
}
svgContent += ``;
}
}
for (const canonical of Object.keys(stationCodesByCanonical)) {
const codes = (stationCodesByCanonical[canonical] || []).filter(code => Number.isFinite(stationYByCanonical[code]));
if (codes.length < 2) continue;
const ys = codes.map(code => stationYByCanonical[code]).sort((a, b) => a - b);
const x = stationXByCanonical[canonical];
const labelY = ys[ys.length - 1] + 34;
svgContent += ``;
svgContent += ``;
svgContent += ``;
svgContent += `${getStationDisplayName(canonical)}`;
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
? ``
: ``;
const transferFill = isTransfer ? `` : '';
const coreFill = isTransfer ? '#ffffff' : '#ffffff';
const showLabel = !isTransfer || !labelShownForCanonical.has(canonical);
const textSvg = showLabel
? `${name}`
: '';
svgContent += `
${ringSvg}
${outer}
${transferFill}
${textSvg}
`;
}
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 = ``;
// 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
} 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 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');
el.classList.remove('start', 'end', 'selected', 'route', 'route-transfer');
if(code === selection[0]) el.classList.add('start');
if(code === selection[1]) el.classList.add('end');
});
if (Array.isArray(currentRoute) && currentRoute.length > 0) {
for (const c of currentRoute) {
const el = document.querySelector(`.map-station[data-code="${c}"]`);
if (el) el.classList.add('route');
}
}
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.length > 0) {
for (const c of currentRouteTransfers) {
const el = document.querySelector(`.map-station[data-code="${c}"]`);
if (el) el.classList.add('route-transfer');
}
}
// Auto preview if both selected
if(!skipPreview && selection[0] && selection[1]) previewPrice();
}
// Load Map on Start
renderSystemMap();
async function previewPrice(force = false){
const seq = ++previewSeq;
const from = (fromEl.value||'').trim();
const to = (toEl.value||'').trim();
const type = document.querySelector('input[name="trainType"]:checked').value;
const trips = normalizeTrips();
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.floor(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 from = (fromEl.value||'').trim();
const to = (toEl.value||'').trim();
const type = document.querySelector('input[name="trainType"]:checked').value;
const trips = normalizeTrips();
const ride_date = new Date().toISOString().slice(0, 10);
if(!from || !to){ alert('请完整填写信息'); return; }
// Auto-fetch price before creating
await previewPrice(true);
try{
const payload = { start: from, terminal: to, train_type: type, trips, ride_date };
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(); };
}
})();