Files
FSE-Ticket.sys/web/ticket-order.js
T

888 lines
38 KiB
JavaScript

(() => {
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 `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${cols[0]}" />`;
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 += `<path d="${piePath(cx, cy, r, a0, a1)}" fill="${cols[i]}" />`;
}
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 = '<div class="error">加载地图失败</div>';
}
}
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 = '<div class="loading">加载线路数据...</div>';
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 = '<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 = 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 - 40 - nameLen; // Pushed further out to avoid line overlap
textX = lx + nameLen / 2;
} else {
lx = x + 40; // Pushed further out
textX = lx + nameLen / 2;
}
let svg = `<rect x="${lx}" y="${y + yOffset}" width="${nameLen}" height="${h}" rx="6" fill="${color}" />`;
if (hasEn) {
svg += `<text x="${textX}" y="${y + yOffset + 15}" text-anchor="middle" fill="#fff" font-size="12" font-weight="bold">${name}</text>`;
svg += `<text x="${textX}" y="${y + yOffset + 28}" text-anchor="middle" fill="rgba(255,255,255,0.9)" font-size="9" font-weight="600">${enName}</text>`;
} else {
svg += `<text x="${textX}" y="${y + yOffset + 16}" text-anchor="middle" fill="#fff" font-size="12" font-weight="bold">${name}</text>`;
}
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 += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="8" stroke-linecap="round" stroke-linejoin="round" />`;
// 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 += `<circle cx="${x}" cy="${y}" r="15" fill="#ffffff" stroke="#1f2937" stroke-width="2.5" />`;
markerSvg += pieSvg(x, y, 11.5, uniqueColors);
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="6.5" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
} else {
const color = uniqueColors[0] || '#93a2b7';
markerSvg += `<circle cx="${x}" cy="${y}" r="12.5" fill="#ffffff" stroke="${color}" stroke-width="5" />`;
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="7" fill="#ffffff" stroke="#334155" stroke-width="2.25" />`;
}
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;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 8}" width="${codeW}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
textContent += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${primaryCode}</text>`;
// Text with white halo for readability
textContent += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
if (showEn) {
textContent += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
} else {
const topY = y + 24;
const codeY = topY;
const nameY = topY + 16;
const enY = nameY + 12;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 8}" width="${codeW}" height="12" rx="6" fill="#ffffff" stroke="#cbd5e1" stroke-width="1.5" />`;
textContent += `<text x="${x}" y="${codeY + 1}" text-anchor="middle" fill="#64748b" font-size="${codeFontSize}" font-weight="700" letter-spacing="0.02em">${primaryCode}</text>`;
textContent += `<text x="${x}" y="${nameY}" text-anchor="middle" fill="#0f172a" font-size="${nameFontSize}" font-weight="800" stroke="#ffffff" stroke-width="4" stroke-linejoin="round" paint-order="stroke fill">${name}</text>`;
if (showEn) {
textContent += `<text x="${x}" y="${enY}" text-anchor="middle" fill="#64748b" font-size="${enFontSize}" font-weight="700" stroke="#ffffff" stroke-width="3" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
}
labelSvg += textContent;
svgContent += `
<g class="map-station ${isTransfer ? 'transfer' : ''}" data-code="${primaryCode}" data-canonical="${canonical}" onclick="handleStationClick('${primaryCode}')" style="cursor:pointer">
<rect x="${x - 20}" y="${y - 20}" width="40" height="40" fill="transparent" />
${markerSvg}
${labelSvg}
</g>
`;
}
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 = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
} catch(e) {
console.error(e);
mapContainer.innerHTML = '<div class="error">地图加载失败</div>';
}
}
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 = '<div class="empty-state jr-empty-state"><p>请选择起点与终点</p></div>'; 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 = '<div class="list-item jr-result-row"><span class="k">提示</span><span class="v">未找到对应票价</span></div>'; 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 = `
<div class="empty-state jr-empty-state">
<p>站点解析异常,当前后端返回的站码与页面选择不一致。</p>
<p>已选:${getStationDisplayName(from)}(${from}) -> ${getStationDisplayName(to)}(${to})</p>
<p>返回:${getStationDisplayName(resolvedFrom)}(${resolvedFrom || '-'}) -> ${getStationDisplayName(resolvedTo)}(${resolvedTo || '-'})</p>
<p>请先部署最新后端并清理 CDN 缓存后再试。</p>
</div>
`;
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
? `<div class="jr-transfer-chips">${transferStops.map(c => `<span class="badge badge-secondary">${getStationDisplayName(c)}</span>`).join('')}</div>`
: `<span class="text-muted">无</span>`;
priceBox.innerHTML = `
<div class="list-item jr-result-row"><span class="k">原始票价</span><span class="v">${base}</span></div>
<div class="list-item jr-result-row"><span class="k">优惠后票价</span><span class="v">${discountedSingle}</span></div>
<div class="list-item jr-result-row"><span class="k">折扣</span><span class="v">-${Math.round((1-disc)*100)}%</span></div>
${routeText ? `<div class="list-item jr-result-row jr-result-multiline"><span class="k">路径</span><span class="v jr-route-text">${routeText}</span></div>` : ``}
<div class="list-item jr-result-row jr-result-multiline"><span class="k">换乘</span><span class="v jr-route-text">${transferHtml}</span></div>
<div class="list-item jr-result-row jr-total-row">
<span class="k">总价</span><span class="v jr-total-amount">${price}</span>
</div>
`;
updateSelectionUI(true);
}catch(e){
lastPreviewKey = '';
priceBox.innerHTML = '<div class="empty-state jr-empty-state"><p>票价预估失败,请稍后再试。</p></div>';
}
}
// 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 = `
<div class="voucher-container jr-voucher-panel">
<div class="jr-voucher-meta">凭证码</div>
<div class="voucher-code">${r.code}</div>
<div class="voucher-hint">请在游戏内任意售票机选择线上订票并输入该凭证码兑票。</div>
<div class="toolbar jr-voucher-actions">
<a class="btn jr-detail-btn" href="${buildTokenLink(r.code)}" target="_self"><i class="fas fa-eye"></i> 详情</a>
</div>
</div>
`;
}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(); };
}
})();