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

605 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
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(); };
}
})();