376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
(() => {
|
|
const $ = (sel) => document.querySelector(sel);
|
|
const listEl = $('#list');
|
|
const detailEl = $('#detail');
|
|
const qEl = $('#q');
|
|
const btn = $('#searchBtn');
|
|
const state = {
|
|
items: [],
|
|
selectedId: ''
|
|
};
|
|
|
|
const api = {
|
|
searchTickets: async (q) => {
|
|
const r = await fetch(`/api/public/tickets?q=${encodeURIComponent(q || '')}`);
|
|
return r.json();
|
|
},
|
|
ticketDetail: async (id) => {
|
|
const r = await fetch(`/api/public/tickets/${encodeURIComponent(id)}`);
|
|
return r.json();
|
|
},
|
|
popular: async () => {
|
|
const r = await fetch('/api/public/popular');
|
|
return r.json();
|
|
}
|
|
};
|
|
|
|
const formatTime = (value) => {
|
|
if (value == null || value === '') return '---';
|
|
|
|
let ts = Number(value);
|
|
if (Number.isFinite(ts)) {
|
|
if (ts > 0 && ts < 1000000000000) ts *= 1000;
|
|
const date = new Date(ts);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
return date.toLocaleString('zh-CN', {
|
|
hour12: false
|
|
});
|
|
}
|
|
}
|
|
|
|
const date = new Date(value);
|
|
if (!Number.isNaN(date.getTime())) {
|
|
return date.toLocaleString('zh-CN', {
|
|
hour12: false
|
|
});
|
|
}
|
|
|
|
return String(value);
|
|
};
|
|
|
|
const formatTrainType = (type) => {
|
|
if (!type) return '普通';
|
|
const t = type.toLowerCase();
|
|
if (t === 'local') return '普通';
|
|
if (t === 'ltd.exp' || t === 'express') return '特急';
|
|
return type;
|
|
};
|
|
|
|
const escapeHtml = (value) => String(value == null ? '' : value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
const getTicketId = (obj) => (obj && (obj.ticket_id || obj["车票编号"] || obj.id)) || '';
|
|
|
|
const isValidStatus = (status) => {
|
|
const s = String(status || '').toLowerCase();
|
|
return s === '有效' || s === 'valid' || s === 'unused' || s === 'active' || s.includes('有效') || s.includes('未使用');
|
|
};
|
|
|
|
const formatStatusText = (status) => {
|
|
const s = String(status || '').toLowerCase();
|
|
if (s === '有效' || s === 'valid' || s === 'unused' || s === 'active' || s.includes('有效') || s.includes('未使用')) return '有效';
|
|
if (s === '已使用' || s === 'used') return '已使用';
|
|
if (!s) return '未知';
|
|
if (s === 'expired') return '失效';
|
|
if (s === 'refunded') return '已退票';
|
|
return String(status);
|
|
};
|
|
|
|
const getEventType = (event) => String(event.type || event["类型"] || '').toLowerCase();
|
|
|
|
const formatEventTitle = (event) => {
|
|
const type = getEventType(event);
|
|
const action = String(event.action || event["动作"] || '').toLowerCase();
|
|
|
|
if (type === 'sale' || type === '售票') return '售票成功';
|
|
if (type === 'entry' || action === 'entry') return '进站成功';
|
|
if (type === 'exit' || action === 'exit') return '出站成功';
|
|
if (type === 'status' || type === '状态') return '状态变更';
|
|
|
|
return event.type || event["类型"] || '状态更新';
|
|
};
|
|
|
|
const formatEventLocation = (event) => {
|
|
const type = getEventType(event);
|
|
const stationName = event.station_name || event["售票站"] || event["发生站"] || '';
|
|
const stationCode = event.station_code || event["站点编号"] || '';
|
|
|
|
if (type === 'sale' || type === '售票') {
|
|
return stationName || '线上售票';
|
|
}
|
|
|
|
if (!stationName && !stationCode) return '---';
|
|
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
|
|
};
|
|
|
|
const formatTicketEventUnified = (ev) => {
|
|
const type = getEventType(ev);
|
|
const action = String(ev.action || ev["动作"] || '').toLowerCase();
|
|
let deviceId = ev.device || ev.device_id || ev["设备编号"] || ev["机器ID"] || '---';
|
|
if (/^\d+$/.test(deviceId)) deviceId = 'ticket_machine';
|
|
if (deviceId === 'ticket_machine') deviceId = '售票机';
|
|
if (deviceId === 'gate') deviceId = '闸机';
|
|
if (deviceId === 'refill_machine') deviceId = '充值机';
|
|
const timeStr = formatTime(ev.ts || ev["时间戳"]);
|
|
|
|
if (type === 'sale' || type === '售票') {
|
|
const cost = ev.cost ?? ev.price ?? ev.amount ?? ev["票价"] ?? ev["售票额"] ?? '0';
|
|
return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`;
|
|
}
|
|
if (type === 'entry' || action === 'entry') {
|
|
const code = ev.station_code || ev["站点编号"] || '---';
|
|
const name = ev.station_name || ev["站点中文"] || ev["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code);
|
|
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
|
|
}
|
|
if (type === 'exit' || action === 'exit') {
|
|
const code = ev.station_code || ev["站点编号"] || '---';
|
|
const name = ev.station_name || ev["站点中文"] || ev["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code);
|
|
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
|
|
}
|
|
|
|
// fallback
|
|
const code = ev.station_code || ev["站点编号"] || '---';
|
|
const name = ev.station_name || ev["站点中文"] || ev["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code);
|
|
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
|
|
};
|
|
|
|
function renderList(items) {
|
|
state.items = items;
|
|
listEl.innerHTML = '';
|
|
if (items.length === 0) {
|
|
listEl.innerHTML = '<div class="jr-center-empty"><p>未找到匹配结果。</p></div>';
|
|
return;
|
|
}
|
|
items.forEach(it => {
|
|
const id = it.ticket_id || it["车票编号"] || '';
|
|
const row = document.createElement('div');
|
|
const statusText = formatStatusText(it.status || it["状态"] || '');
|
|
const isSelected = state.selectedId === id;
|
|
row.className = `jr-ticket-row${isSelected ? ' is-active' : ''}`;
|
|
|
|
const overview = it.overview || it["概览"] || null;
|
|
const startName = overview ? (overview.start_name || overview["起点"]) : (it.start_name || it["起点"] || '---');
|
|
const terminalName = overview ? (overview.terminal_name || overview["终点"]) : (it.terminal_name || it["终点"] || '---');
|
|
const trainType = overview ? (overview.train_type || overview["车型"]) : (it.train_type || it["车型"]);
|
|
const isSingle = String(trainType || '').toLowerCase() === 'single';
|
|
|
|
const updateTime = formatTime(
|
|
(overview && (overview.last_update_ts || overview["上次更新时间"])) ||
|
|
it.last_update_ts ||
|
|
it["上次更新时间"] ||
|
|
''
|
|
);
|
|
|
|
let routeHtml = '';
|
|
if (isSingle) {
|
|
routeHtml = `${escapeHtml(startName)} <span style="font-size: 0.8em; margin-left: 8px; color: #666; font-weight: normal;">单程单区间</span>`;
|
|
} else {
|
|
routeHtml = `${escapeHtml(startName)} → ${escapeHtml(terminalName)}`;
|
|
}
|
|
|
|
row.innerHTML = `
|
|
<div class="jr-ticket-row-head">
|
|
<span class="jr-ticket-id mono">${escapeHtml(id)}</span>
|
|
<span class="jr-status-pill ${isValidStatus(statusText) ? 'jr-status-valid' : (statusText === '已使用' ? 'jr-status-used' : 'jr-status-expired')}">${escapeHtml(statusText)}</span>
|
|
</div>
|
|
<div class="jr-ticket-route">
|
|
${routeHtml}
|
|
</div>
|
|
<div class="jr-list-meta">最近更新 ${escapeHtml(updateTime)}</div>
|
|
`;
|
|
row.onclick = () => {
|
|
state.selectedId = id;
|
|
renderList(state.items);
|
|
loadDetail(id);
|
|
};
|
|
listEl.appendChild(row);
|
|
});
|
|
}
|
|
|
|
function openDetail(id) {
|
|
if (window.location.hostname.includes('fse-media.group')) {
|
|
window.open(`https://ticket.fse-media.group/detail/${id}`, '_blank');
|
|
} else {
|
|
window.open(`/${id}`, '_blank');
|
|
}
|
|
}
|
|
|
|
function renderDetail(d) {
|
|
const ov = d.overview || d["概览"] || {};
|
|
const evs = d.events || d["事件"] || [];
|
|
const id = getTicketId(d) || getTicketId(ov);
|
|
const stRaw = ov.status || ov["状态"] || d.status || d["状态"] || '';
|
|
const statusText = formatStatusText(stRaw);
|
|
const statusClass = isValidStatus(stRaw) ? 'jr-status-valid' : (statusText === '已使用' ? 'jr-status-used' : 'jr-status-expired');
|
|
const isSingle = String(ov.train_type || ov["车型"] || '').toLowerCase() === 'single';
|
|
|
|
let routeBoardHtml = '';
|
|
if (isSingle) {
|
|
routeBoardHtml = `
|
|
<div class="jr-route-board" style="justify-content: center; text-align: center;">
|
|
<div class="jr-station-block">
|
|
<div class="jr-station-line" style="justify-content: center;">
|
|
<span class="jr-station-title">${escapeHtml(ov.start_name || ov["起点"] || '---')}</span>
|
|
</div>
|
|
<div class="jr-station-en" style="text-align: center; margin-top: 4px;">单程单区间</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
routeBoardHtml = `
|
|
<div class="jr-route-board">
|
|
<div class="jr-station-block">
|
|
<div class="jr-station-line">
|
|
<span class="jr-station-title">${escapeHtml(ov.start_name || ov["起点"] || '---')}</span>
|
|
<span class="jr-station-code">${escapeHtml(ov.start_code || ov["起点编号"] || '')}</span>
|
|
</div>
|
|
<div class="jr-station-en">${escapeHtml(ov.start_en || ov["起点英文"] || '')}</div>
|
|
</div>
|
|
<div class="jr-route-track"><i class="fas fa-train"></i></div>
|
|
<div class="jr-station-block is-end">
|
|
<div class="jr-station-line">
|
|
<span class="jr-station-title">${escapeHtml(ov.terminal_name || ov["终点"] || '---')}</span>
|
|
<span class="jr-station-code">${escapeHtml(ov.terminal_code || ov["终点编号"] || '')}</span>
|
|
</div>
|
|
<div class="jr-station-en">${escapeHtml(ov.terminal_en || ov["终点英文"] || '')}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let metaGridHtml = '';
|
|
if (isSingle) {
|
|
metaGridHtml = `
|
|
<div class="jr-meta-grid" style="grid-template-columns: repeat(2, 1fr);">
|
|
<div class="jr-meta-item"><span>票价</span><strong>${escapeHtml(ov.amount ?? ov["金额"] ?? 0)}</strong></div>
|
|
<div class="jr-meta-item"><span>更新</span><strong>${escapeHtml(formatTime(ov.last_update_ts ?? ov["上次更新时间"]))}</strong></div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
metaGridHtml = `
|
|
<div class="jr-meta-grid">
|
|
<div class="jr-meta-item"><span>车型</span><strong>${escapeHtml(formatTrainType(ov.train_type || ov["车型"]))}</strong></div>
|
|
<div class="jr-meta-item"><span>乘次</span><strong>${escapeHtml(ov.trips_total ?? ov["总乘次"] ?? 0)}</strong></div>
|
|
<div class="jr-meta-item"><span>票价</span><strong>${escapeHtml(ov.amount ?? ov["金额"] ?? 0)}</strong></div>
|
|
<div class="jr-meta-item"><span>更新</span><strong>${escapeHtml(formatTime(ov.last_update_ts ?? ov["上次更新时间"]))}</strong></div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
detailEl.innerHTML = `
|
|
<div class="jr-ticket-preview" onclick="openTicketDetail('${id}')" style="cursor:pointer;" title="点击查看电子票看板">
|
|
<div class="jr-ticket-row-head">
|
|
<span class="jr-ticket-id mono">${id} <i class="fas fa-external-link-alt" style="font-size:0.8em; margin-left:4px"></i></span>
|
|
<span class="jr-status-pill ${statusClass}">${statusText}</span>
|
|
</div>
|
|
${routeBoardHtml}
|
|
${metaGridHtml}
|
|
</div>
|
|
|
|
<div class="jr-panel-headline" style="margin:20px 0 14px;">
|
|
<h3>流转记录</h3>
|
|
<span class="jr-panel-note">Recent Events</span>
|
|
</div>
|
|
<div class="jr-history-list">
|
|
${evs.map(e => `
|
|
<div class="jr-history-item">
|
|
<div class="jr-history-row">
|
|
<span class="jr-history-title">${escapeHtml(formatEventTitle(e))}</span>
|
|
</div>
|
|
<div class="jr-history-desc">
|
|
<div style="margin-top:4px;">${escapeHtml(formatTicketEventUnified(e))}</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function loadDetail(id) {
|
|
state.selectedId = id;
|
|
renderList(state.items);
|
|
detailEl.innerHTML = '<div class="jr-center-empty"><p>正在加载详情...</p></div>';
|
|
try {
|
|
const d = await api.ticketDetail(id);
|
|
if (d && getTicketId(d) && (d.overview || d["概览"])) {
|
|
renderDetail(d);
|
|
} else {
|
|
detailEl.innerHTML = '<div class="jr-center-empty"><p>未找到车票详情。</p></div>';
|
|
}
|
|
// Update URL without reload
|
|
const newUrl = window.location.origin + window.location.pathname + '?id=' + encodeURIComponent(id);
|
|
window.history.pushState({ path: newUrl }, '', newUrl);
|
|
} catch (e) {
|
|
detailEl.innerHTML = '<div class="jr-center-empty"><p>加载详情失败。</p></div>';
|
|
}
|
|
}
|
|
|
|
async function doSearch() {
|
|
const q = (qEl.value || '').trim();
|
|
listEl.innerHTML = '<div class="jr-center-empty"><p>正在搜索...</p></div>';
|
|
try {
|
|
const d = await api.searchTickets(q);
|
|
state.selectedId = state.selectedId && d.some((item) => getTicketId(item) === state.selectedId) ? state.selectedId : '';
|
|
renderList(d);
|
|
} catch (e) {
|
|
listEl.innerHTML = '<div class="jr-center-empty"><p>搜索失败。</p></div>';
|
|
}
|
|
}
|
|
|
|
async function loadPopular() {
|
|
try {
|
|
const d = await api.popular();
|
|
const ps = $('#popularStations');
|
|
const pr = $('#popularRoutes');
|
|
|
|
ps.innerHTML = d.topStations.map(s => `
|
|
<div class="jr-popular-item">
|
|
<span><strong>${s.name}</strong></span>
|
|
<span>${s.count} 次</span>
|
|
</div>
|
|
`).join('');
|
|
|
|
pr.innerHTML = d.topRoutes.map(r => `
|
|
<div class="jr-popular-item">
|
|
<span><strong>${r.from} → ${r.to}</strong></span>
|
|
<span>${r.count} 次</span>
|
|
</div>
|
|
`).join('');
|
|
} catch (_) { }
|
|
}
|
|
|
|
btn.onclick = doSearch;
|
|
qEl.addEventListener('keydown', (e) => { if (e.key === 'Enter') doSearch(); });
|
|
window.openTicketDetail = openDetail;
|
|
|
|
const sp = new URLSearchParams(location.search);
|
|
const qid = sp.get('id');
|
|
let stationMap = {};
|
|
window.getStationName = (code) => stationMap[code] || code;
|
|
|
|
async function init() {
|
|
try {
|
|
const res = await fetch('/api/public/stations');
|
|
const list = await res.json();
|
|
if (Array.isArray(list)) {
|
|
list.forEach(s => { stationMap[s.code] = s.name || s.cn_name; });
|
|
}
|
|
} catch(e) {}
|
|
|
|
if (qid) {
|
|
qEl.value = qid;
|
|
loadDetail(qid);
|
|
doSearch();
|
|
} else {
|
|
doSearch();
|
|
}
|
|
loadPopular();
|
|
}
|
|
init();
|
|
})();
|
|
|