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

706 lines
31 KiB
JavaScript

/*
* @Author: HenryDu8133 813367384@qq.com
* @Date: 2025-12-06 23:59:11
* @LastEditors: HenryDu8133 813367384@qq.com
* @LastEditTime: 2026-07-15 23:25:36
* @FilePath: \TicketMachine\web\ticket-order.js
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
(() => {
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();
}
};
const mapContainer = $('#stationMap');
let selection = [null, null];
let currentRoute = [];
let currentRouteTransfers = [];
let stationNameByCode = {};
let stationEnByCode = {};
let stationCanonicalByCode = {};
let stationCodesByCanonical = {};
let stationTransfer = new Set();
let lineStops = [];
let lineColors = [];
let linesByStation = new Map();
let lineIdxsByCanonical = {};
let activeLineIdx = 0;
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 getContrastColor = (hexcolor) => {
if (!hexcolor) return '#ffffff';
let hex = hexcolor.replace('#', '');
if (hex.length === 3) hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
const r = parseInt(hex.substr(0,2),16) || 0;
const g = parseInt(hex.substr(2,2),16) || 0;
const b = parseInt(hex.substr(4,2),16) || 0;
const yiq = ((r*299)+(g*587)+(b*114))/1000;
return (yiq >= 140) ? '#0f172a' : '#ffffff';
};
function renderStationNode(x, y, code, i, forceColor = null) {
const canonical = stationCanonicalByCode[code] || code;
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 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="12" fill="#ffffff" stroke="#1f2937" stroke-width="2" />`;
markerSvg += pieSvg(x, y, 9, uniqueColors);
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="5" fill="#ffffff" stroke="#334155" stroke-width="2" />`;
} else {
const color = forceColor || uniqueColors[0] || '#93a2b7';
markerSvg += `<circle cx="${x}" cy="${y}" r="10" fill="${color}" stroke="${color}" stroke-width="2.5" />`;
markerSvg += `<circle class="node-core" cx="${x}" cy="${y}" r="5" fill="#ffffff" stroke="${color}" stroke-width="2" />`;
}
const preferTop = i % 2 === 0;
let labelSvg = '';
const codeFontSize = 8;
const nameFontSize = 11.5;
const enFontSize = 7.5;
const codeW = primaryCode.length * 5.5 + 8;
let textContent = '';
if (preferTop) {
const bottomY = y - 18;
const enY = bottomY;
const nameY = showEn ? bottomY - 10 : bottomY;
const codeY = nameY - 13;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 7}" width="${codeW}" height="10" rx="5" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" />`;
textContent += `<text x="${x}" y="${codeY + 1.5}" 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="3" 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="2.5" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
} else {
const topY = y + 26;
const codeY = topY;
const nameY = topY + 13;
const enY = nameY + 10;
textContent += `<rect x="${x - codeW / 2}" y="${codeY - 7}" width="${codeW}" height="10" rx="5" fill="#ffffff" stroke="#cbd5e1" stroke-width="1" />`;
textContent += `<text x="${x}" y="${codeY + 1.5}" 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="3" 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="2.5" stroke-linejoin="round" paint-order="stroke fill">${enName}</text>`;
}
}
labelSvg += textContent;
let classList = 'map-station';
if (isTransfer) classList += ' transfer';
if (selection[0] && canonical === (stationCanonicalByCode[selection[0]] || selection[0])) classList += ' start';
if (selection[1] && canonical === (stationCanonicalByCode[selection[1]] || selection[1])) classList += ' end';
if (Array.isArray(currentRoute) && currentRoute.includes(primaryCode)) classList += ' route';
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(primaryCode)) classList += ' route-transfer';
return `
<g class="${classList}" data-code="${primaryCode}" data-canonical="${canonical}" onclick="handleStationClick('${primaryCode}')" style="cursor:pointer">
<rect x="${x - 16}" y="${y - 16}" width="32" height="32" fill="transparent" />
${markerSvg}
${labelSvg}
</g>`;
}
function renderTvmLines() {
const container = document.getElementById('tvmLines');
if (!container) return;
let html = '';
for (let i = 0; i < lineStops.length; i++) {
const li = lineStops[i];
const isActive = (i === activeLineIdx);
const bg = isActive ? li.color : '#f8fafc';
const fg = isActive ? getContrastColor(li.color) : '#334155';
const border = isActive ? li.color : '#cbd5e1';
html += `<button class="tvm-line-btn" style="padding: 6px 12px; border: 1.5px solid ${border}; background: ${bg}; color: ${fg}; border-radius: 6px; cursor: pointer; font-weight: bold; font-size: 13px; transition: all 0.25s ease;" onclick="selectTvmLine(${i})">${li.name}</button>`;
}
container.innerHTML = html;
}
window.selectTvmLine = (idx) => {
activeLineIdx = idx;
renderTvmLines();
renderTvmSingleLine();
};
function renderTvmSingleLine() {
const container = document.getElementById('tvmSingleLine');
if (!container) return;
const li = lineStops[activeLineIdx];
if (!li) {
container.innerHTML = '';
return;
}
const startX = 40;
const gapX = 65;
const y = 60;
const pts = [];
for (let i = 0; i < li.stops.length; i++) {
const code = li.stops[i];
if (code === '-') continue;
const x = startX + pts.length * gapX;
pts.push({ x, y, code });
}
if (pts.length === 0) {
container.innerHTML = '<div style="color:#94a3b8; padding:20px;">该线路暂无站点</div>';
return;
}
let svgContent = '';
if (pts.length >= 2) {
let d = `M ${pts[0].x} ${y}`;
for(let i=1; i<pts.length; i++) d += ` L ${pts[i].x} ${y}`;
svgContent += `<path d="${d}" fill="none" stroke="${li.color}" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />`;
}
for (let i = 0; i < pts.length; i++) {
svgContent += renderStationNode(pts[i].x, pts[i].y, pts[i].code, i, li.color);
}
const width = Math.max(container.clientWidth || 800, startX + pts.length * gapX + 40);
const height = 120;
// 添加 tvm-animate 类实现动画过渡
container.innerHTML = `<svg class="tvm-animate" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block;">${svgContent}</svg>`;
}
function renderTvmRoute() {
const container = document.getElementById('tvmRoute');
const title = document.getElementById('tvmRouteTitle');
if (!container) return;
if (!selection[0] || !selection[1] || !currentRoute || currentRoute.length < 2) {
container.style.display = 'none';
if (title) title.style.display = 'none';
container.innerHTML = '';
return;
}
const routeCodes = buildDisplayRouteCodes(currentRoute, selection[0], selection[1]);
if (routeCodes.length < 2) {
container.style.display = 'none';
if (title) title.style.display = 'none';
container.innerHTML = '';
return;
}
container.style.display = 'flex';
if (title) title.style.display = 'block';
const startX = 40;
const gapX = 65;
const y = 60;
let svgContent = '';
const segments = [];
for (let i = 0; i < routeCodes.length - 1; i++) {
const c1 = routeCodes[i];
const c2 = routeCodes[i+1];
let color = '#93a2b7';
const can1 = stationCanonicalByCode[c1] || c1;
const can2 = stationCanonicalByCode[c2] || c2;
const canLines1 = Array.from(lineIdxsByCanonical[can1] || []);
const canLines2 = Array.from(lineIdxsByCanonical[can2] || []);
const canCommon = canLines1.filter(l => canLines2.includes(l));
if (canCommon.length > 0) {
color = lineColors[canCommon[0]];
}
segments.push({
x1: startX + i * gapX,
x2: startX + (i + 1) * gapX,
color
});
}
for (const seg of segments) {
svgContent += `<line x1="${seg.x1}" y1="${y}" x2="${seg.x2}" y2="${y}" stroke="${seg.color}" stroke-width="6" stroke-linecap="round" />`;
}
const pathD = `M ${startX} ${y} L ${startX + (routeCodes.length - 1) * gapX} ${y}`;
const totalDist = (routeCodes.length - 1) * gapX;
const duration = Math.max(1, totalDist / 150);
const numArrows = Math.max(1, Math.floor(duration / 0.8));
const interval = duration / numArrows;
for (let i = 0; i < numArrows; i++) {
svgContent += `
<g>
<path d="M -5 -4 L 3 0 L -5 4" fill="none" stroke="#854d0e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<animateMotion dur="${duration}s" repeatCount="indefinite" path="${pathD}" rotate="auto" begin="-${i * interval}s" calcMode="linear" />
</g>
`;
}
for (let i = 0; i < routeCodes.length; i++) {
svgContent += renderStationNode(startX + i * gapX, y, routeCodes[i], i);
}
const width = Math.max(container.clientWidth || 800, startX + routeCodes.length * gapX + 40);
const height = 120;
container.innerHTML = `<svg class="tvm-animate" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" style="display:block;">${svgContent}</svg>`;
}
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));
}
lineStops = [];
lineColors = [];
linesByStation = new Map();
lineIdxsByCanonical = {};
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);
}
const fadeStyle = `<style>
@keyframes tvmFadeIn {
from { opacity: 0; transform: translateX(-10px); }
to { opacity: 1; transform: translateX(0); }
}
.tvm-animate { animation: tvmFadeIn 0.3s ease-out forwards; }
.tvm-scroll-container { scroll-behavior: smooth; -webkit-overflow-scrolling: touch; }
.tvm-scroll-container::-webkit-scrollbar { height: 4px; }
.tvm-scroll-container::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 2px; }
.tvm-scroll-container::-webkit-scrollbar-track { background: transparent; }
</style>`;
mapContainer.innerHTML = `
${fadeStyle}
<div class="tvm-layout" style="display: flex; flex-direction: column; gap: 16px; width: 100%;">
<div class="tvm-lines" id="tvmLines" style="display: flex; flex-wrap: wrap; gap: 6px;"></div>
<div class="tvm-single-line tvm-scroll-container" id="tvmSingleLine" style="overflow-x: auto; padding: 10px 0; min-height: 120px; display: flex; align-items: center; justify-content: flex-start;"></div>
<div class="tvm-route-title" style="font-weight: bold; margin-bottom: -12px; color: #475569; display: none; font-size: 14px;" id="tvmRouteTitle">已选路径</div>
<div class="tvm-route tvm-scroll-container" id="tvmRoute" style="overflow-x: auto; padding: 10px 0; border-top: 1.5px dashed #cbd5e1; min-height: 120px; display: none; align-items: center; justify-content: flex-start;"></div>
</div>
`;
renderTvmLines();
renderTvmSingleLine();
} catch(e) {
console.error(e);
mapContainer.innerHTML = '<div class="error">数据加载失败</div>';
}
}
window.handleStationClick = (code) => {
code = String(code || '').trim();
if (selection[0] === code) {
selection[0] = null;
} else if (selection[1] === code) {
selection[1] = null;
} else {
if (!selection[0]) {
selection[0] = code;
} else if (!selection[1]) {
selection[1] = code;
} else {
selection[0] = code;
selection[1] = null;
currentRoute = [];
currentRouteTransfers = [];
}
}
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 = [];
}
fromEl.value = selection[0] || '';
toEl.value = selection[1] || '';
const getName = (code) => {
return stationNameByCode[String(code || '').trim()] || code;
};
$('#fromDisplay').textContent = selection[0] ? getName(selection[0]) : '请在上方地图选择';
$('#toDisplay').textContent = selection[1] ? getName(selection[1]) : '请在上方地图选择';
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.includes(code)) el.classList.add('route');
if (Array.isArray(currentRouteTransfers) && currentRouteTransfers.includes(code)) el.classList.add('route-transfer');
});
renderTvmRoute();
if(!skipPreview && selection[0] && selection[1]) previewPrice();
}
renderSystemMap();
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>';
}
}
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; }
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){
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;
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(); };
}
})();