Files
FSE-Ticket.sys/web/ticket-search.js
T
2026-07-10 14:55:42 +08:00

387 lines
17 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
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 = String(ev.device || ev.device_id || ev.detail?.device || ev.detail?.device_id || ev["设备编号"] || ev["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else {
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();
})();