605 lines
26 KiB
JavaScript
605 lines
26 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();
|
||
|
||
// TVM UI State
|
||
let activeLineId = null;
|
||
let allLinesData = [];
|
||
let allStationsData = [];
|
||
|
||
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;
|
||
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 = '<div class="error">数据加载失败</div>';
|
||
}
|
||
}
|
||
|
||
function renderTvmUI() {
|
||
if (!allLinesData || allLinesData.length === 0) {
|
||
mapContainer.innerHTML = '<div class="empty-state jr-empty-state"><p>暂无可显示的线路数据。</p></div>';
|
||
return;
|
||
}
|
||
|
||
const nodeSlotWidth = window.innerWidth <= 768 ? 58 : 70;
|
||
const trackPadding = Math.round(nodeSlotWidth / 2);
|
||
|
||
// 1. Render Line Tabs
|
||
let tabsHtml = `<div class="tvm-lines-nav" style="display: flex; gap: 8px; overflow-x: auto; padding: 12px; background: var(--surface-color, #f8fafc); border-bottom: 1px solid var(--border-color, #e2e8f0); border-radius: 12px 12px 0 0; -webkit-overflow-scrolling: touch; scrollbar-width: none;">`;
|
||
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 += `
|
||
<button class="tvm-line-tab" onclick="window.switchTvmLine('${line.id}')" style="
|
||
flex-shrink: 0;
|
||
padding: 8px 16px;
|
||
border-radius: 20px;
|
||
border: 2px solid ${border};
|
||
background: ${bg};
|
||
color: ${fg};
|
||
font-weight: 700;
|
||
font-size: 14px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
box-shadow: ${isActive ? '0 2px 4px rgba(0,0,0,0.1)' : 'none'};
|
||
white-space: nowrap;
|
||
">
|
||
<span style="width: 10px; height: 10px; border-radius: 50%; background: ${isActive ? 'var(--bg-color, #fff)' : color}; display: inline-block;"></span>
|
||
${line.name || line.线路名称 || '未知线路'}
|
||
</button>
|
||
`;
|
||
});
|
||
tabsHtml += `</div>`;
|
||
|
||
// 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 = `<div class="tvm-stations-line-container" style="overflow-x: auto; padding: 40px 20px 20px 20px; background: var(--bg-color, #ffffff); border-radius: 0 0 12px 12px; -webkit-overflow-scrolling: touch; scrollbar-width: thin;">`;
|
||
|
||
stationsHtml += `<div style="display: flex; position: relative; min-width: max-content; align-items: flex-start;">`;
|
||
|
||
stationsHtml += `<div style="position: absolute; top: 14px; left: ${trackPadding}px; right: ${trackPadding}px; height: 8px; background-color: ${activeLine.color || '#94a3b8'}; border-radius: 4px; z-index: 1;"></div>`;
|
||
|
||
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 += `<span style="font-size: 16px; line-height: 1; color: #854d0e; font-weight: 800;">›</span>`;
|
||
}
|
||
stationsHtml += `
|
||
<div style="
|
||
position: absolute;
|
||
top: 14px;
|
||
left: ${left}px;
|
||
width: ${width}px;
|
||
height: 8px;
|
||
background: linear-gradient(90deg, rgba(250,204,21,0.92) 0%, #facc15 100%);
|
||
border-radius: 999px;
|
||
z-index: 2;
|
||
box-shadow: 0 0 0 2px rgba(255,255,255,0.85), 0 4px 12px rgba(250, 204, 21, 0.35);
|
||
overflow: visible;
|
||
">
|
||
<div style="
|
||
position: absolute;
|
||
inset: -7px 8px auto 8px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-around;
|
||
pointer-events: none;
|
||
">${arrowsHtml}</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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 = `<div style="position: absolute; top: -32px; background: var(--success, #16a34a); color: white; font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: bold; white-space: nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">起点</div>`;
|
||
} else if (isSelectedTo) {
|
||
nodeColor = 'var(--danger, #dc2626)';
|
||
nodeSize = '24px';
|
||
badgeHtml = `<div style="position: absolute; top: -32px; background: var(--danger, #dc2626); color: white; font-size: 11px; padding: 2px 6px; border-radius: 4px; font-weight: bold; white-space: nowrap; box-shadow: 0 2px 4px rgba(0,0,0,0.2); z-index: 10;">终点</div>`;
|
||
} else if (inRoute) {
|
||
nodeColor = 'var(--primary, #facc15)';
|
||
if (!isTransfer) nodeSize = '18px';
|
||
else nodeSize = '24px';
|
||
}
|
||
|
||
let transferDotsHtml = '';
|
||
if (isTransfer) {
|
||
transferDotsHtml = `<div style="display: flex; gap: 3px; position: absolute; top: -14px;">`;
|
||
transferLines.slice(0, 3).forEach(l => {
|
||
transferDotsHtml += `<div title="可换乘 ${l.name}" style="width: 8px; height: 8px; border-radius: 50%; background: ${l.color || '#999'}; border: 1px solid #fff; box-shadow: 0 1px 2px rgba(0,0,0,0.2);"></div>`;
|
||
});
|
||
transferDotsHtml += `</div>`;
|
||
}
|
||
|
||
stationsHtml += `
|
||
<div class="tvm-station-node" onclick="window.handleStationClick('${code}')" style="
|
||
position: relative;
|
||
z-index: 2;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
width: ${nodeSlotWidth}px;
|
||
flex-shrink: 0;
|
||
cursor: pointer;
|
||
">
|
||
|
||
<!-- Fixed height container for vertical alignment of the track nodes -->
|
||
<div style="height: 36px; display: flex; align-items: center; justify-content: center; position: relative; width: 100%;">
|
||
${badgeHtml}
|
||
${transferDotsHtml}
|
||
<div style="
|
||
width: ${nodeSize};
|
||
height: ${nodeSize};
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
border: ${nodeBorder} solid ${nodeColor};
|
||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||
z-index: 2;
|
||
transition: all 0.2s;
|
||
"></div>
|
||
</div>
|
||
|
||
<!-- Labels -->
|
||
<div style="text-align: center; margin-top: 8px; width: 100%; display: flex; flex-direction: column; align-items: center;">
|
||
<div style="font-size: 10px; color: var(--text-muted, #64748b); font-weight: 700; margin-bottom: 2px;">${code}</div>
|
||
<div style="font-size: ${window.innerWidth <= 768 ? '13px' : '14px'}; font-weight: 800; color: var(--text-color, #0f172a); line-height: 1.2; word-break: break-all; max-width: ${Math.max(nodeSlotWidth - 10, 46)}px;">${name}</div>
|
||
${enName ? `<div style="font-size: 9px; color: var(--text-muted, #94a3b8); font-weight: 600; margin-top: 2px; transform: scale(0.9);">${enName}</div>` : ''}
|
||
</div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
if (!hasStations) {
|
||
stationsHtml += `<div style="width: 100%; text-align: center; padding: 40px; color: var(--text-muted, #64748b);">该线路暂无站点</div>`;
|
||
}
|
||
|
||
stationsHtml += `</div></div>`;
|
||
|
||
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 = '<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(); };
|
||
}
|
||
})();
|