Files
FSE-Ticket.sys/web/ic-card-search.js
T
Henry_Du 8b10117487 feat: 优化设备标识识别与单程票检票逻辑
- 升级项目版本至v1.0.16
- 重构多文件的设备ID标准化逻辑,新增空格修剪、大小写不敏感处理,支持更多设备类型
- 基于事件类型智能区分闸机、充值机与售票机
- 新增站点线路校验工具,修复单程票检票的站点合法性校验
- 修正单程票票价计算使用实际进站站点而非初始起点
- 添加事件元数据格式化的空值安全检查
2026-07-10 12:02:50 +08:00

306 lines
14 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 inputEl = $('#queryInput');
const queryBtn = $('#queryBtn');
const summaryBoxEl = $('#summaryBox');
const detailBoxEl = $('#detailBox');
const eventBoxEl = $('#eventBox');
const state = {
cards: [],
selectedQuery: ''
};
const api = {
async request(url) {
const res = await fetch(url);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || res.statusText || '请求失败');
}
return data;
},
query(q) {
return api.request(`/api/public/ic-cards/query?q=${encodeURIComponent(q)}`);
}
};
const getStatusClass = (status) => {
const s = String(status || '').trim().toLowerCase();
if (s === 'active') return 'jr-status-valid';
if (s === 'pending_pickup') return 'jr-status-used';
return 'jr-status-expired';
};
const getLookupKey = (card) => String(card?.card_id || '').trim();
const escapeHtml = (value) => String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatTime = (value) => {
if (value == null || value === '') return '---';
const ts = Number(value);
const date = Number.isFinite(ts) ? new Date(ts) : new Date(value);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('zh-CN', { hour12: false });
};
const eventTitle = (event) => {
const map = {
create: '后台建卡',
update: '信息更新',
topup: '余额充值',
order_created: '线上购卡',
activated: '正式启用'
};
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const buildCardPreview = (card) => {
const shownCardId = card.display_card_id || card.card_id || '---';
const detailHref = window.location.hostname.includes('fse-media.group')
? `https://ticket.fse-media.group/ic/${encodeURIComponent(card.card_id || shownCardId)}`
: `/ic/${encodeURIComponent(card.card_id || shownCardId)}`;
return `
<div class="jr-ticket-preview">
<div class="jr-ticket-row-head">
<span class="jr-ticket-id mono">${escapeHtml(shownCardId)}</span>
<span class="jr-status-pill ${getStatusClass(card.status)}">${escapeHtml(card.status_label || card.status || '未知')}</span>
</div>
<div class="jr-meta-grid">
<div class="jr-meta-item"><span>持卡人</span><strong>${escapeHtml(card.holder_name || '未登记')}</strong></div>
<div class="jr-meta-item"><span>卡片类型</span><strong>${escapeHtml(card.card_type_label || 'IC 储值卡')}</strong></div>
<div class="jr-meta-item"><span>余额</span><strong>${escapeHtml(card.balance ?? 0)}</strong></div>
<div class="jr-meta-item"><span>首次充值</span><strong>${escapeHtml(card.purchase_amount ?? card.balance ?? 0)}</strong></div>
<div class="jr-meta-item"><span>凭证码</span><strong>${escapeHtml(card.voucher_code || card.code || card.order_code || '---')}</strong></div>
<div class="jr-meta-item"><span>购卡时间</span><strong>${escapeHtml(formatTime(card.created_ts))}</strong></div>
</div>
<div class="jr-action-row">
<a href="${detailHref}" class="btn jr-secondary-btn" target="_blank" rel="noopener noreferrer">
<i class="fas fa-id-card"></i>
打开卡片页
</a>
</div>
</div>
`;
};
const formatIcCardEventUnified = (ev) => {
const type = String(ev?.type || '').toLowerCase();
const action = String(ev?.action || '').toLowerCase();
let deviceId = String(ev?.device || ev?.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' || /^\d+$/.test(dStr) || dStr.includes('computer') || dStr === 'unknown') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
deviceId = '充值机';
} else {
deviceId = '售票机';
}
}
const timeStr = formatTime(ev?.ts);
const balance = ev?.balance ?? ev?.["余额"] ?? 0;
if (type === 'create' || type === 'order_created' || type === 'activated') {
const cardType = ev?.detail?.card_type || ev?.card_type || ev?.["类型"] || '储值卡';
return `[${cardType}];余额: ${balance};时间: ${timeStr}`;
}
if (type === 'topup' || type === 'refill' || action === 'topup' || type === '充值') {
const amount = ev?.amount ?? ev?.["充值数额"] ?? 0;
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code);
if (code !== '---' || ev?.station_name) {
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};充值数额: ${amount};余额: ${balance};时间: ${timeStr}`;
}
return `机器ID: ${deviceId};充值数额: ${amount};余额: ${balance};时间: ${timeStr}`;
}
if (action === 'entry' || type === 'entry' || type === '进站') {
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};余额: ${balance};时间: ${timeStr}`;
}
if (action === 'exit' || type === 'exit' || type === '出站') {
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};余额: ${balance};时间: ${timeStr}`;
}
// fallback
return `时间: ${timeStr};${typeof ev?.detail === 'string' ? ev.detail : JSON.stringify(ev?.detail || {}, null, 2)}`;
};
const buildEventsHtml = (events) => {
if (!events.length) {
return '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
}
return events.map((event) => `
<div class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">${escapeHtml(eventTitle(event))}</span>
</div>
<div class="jr-history-desc">${escapeHtml(formatIcCardEventUnified(event))}</div>
</div>
`).join('');
};
const renderDetailPrompt = (message) => {
detailBoxEl.innerHTML = `<div class="jr-center-empty" style="min-height:180px;"><p>${escapeHtml(message)}</p></div>`;
};
const renderEventPrompt = (message) => {
eventBoxEl.innerHTML = `<div class="jr-center-empty" style="min-height:180px;"><p>${escapeHtml(message)}</p></div>`;
};
const renderSelectedCard = (card, events) => {
if (!card) {
renderDetailPrompt('请选择左侧卡片查看详情。');
renderEventPrompt('请选择左侧卡片查看详情与事件记录。');
return;
}
detailBoxEl.innerHTML = buildCardPreview(card);
eventBoxEl.innerHTML = buildEventsHtml(events);
};
const renderCardList = () => {
if (!state.cards.length) {
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = '<p>暂无可显示的 IC 卡记录。</p>';
return;
}
summaryBoxEl.className = 'jr-scroll-box';
summaryBoxEl.innerHTML = state.cards.map((card) => {
const lookupKey = getLookupKey(card);
const shownCardId = card.display_card_id || card.card_id || '---';
const voucherCode = card.voucher_code || card.code || card.order_code || '---';
const isSelected = lookupKey && state.selectedQuery === lookupKey;
return `
<div class="jr-ticket-row${isSelected ? ' is-active' : ''}" data-card-query="${escapeHtml(lookupKey)}">
<div class="jr-ticket-row-head">
<span class="jr-ticket-id mono">${escapeHtml(shownCardId)}</span>
<span class="jr-status-pill ${getStatusClass(card.status)}">${escapeHtml(card.status_label || card.status || '未知')}</span>
</div>
<div class="jr-ticket-route">${escapeHtml(card.holder_name || '未登记持卡人')}</div>
<div class="jr-list-meta">
余额 ${escapeHtml(card.balance ?? 0)} · 凭证码 ${escapeHtml(voucherCode)}
</div>
</div>
`;
}).join('');
summaryBoxEl.querySelectorAll('[data-card-query]').forEach((item) => {
item.addEventListener('click', () => {
const q = item.getAttribute('data-card-query');
if (q) {
loadCardDetail(q).catch((error) => {
renderQueryError(error.message || String(error));
});
}
});
});
};
const loadCardDetail = async (q, options = {}) => {
const { updateUrl = true } = options;
renderDetailPrompt('正在加载卡片详情...');
renderEventPrompt('正在加载事件记录...');
const data = await api.query(q);
const card = data.card || null;
const events = data.events || [];
const lookupKey = getLookupKey(card) || q;
if (card) {
const existingIdx = state.cards.findIndex((item) => getLookupKey(item) === lookupKey);
if (existingIdx >= 0) state.cards[existingIdx] = card;
else state.cards = [card];
}
state.selectedQuery = lookupKey;
renderCardList();
renderSelectedCard(card, events);
if (updateUrl) {
const newUrl = `${window.location.origin}${window.location.pathname}?q=${encodeURIComponent(q)}`;
window.history.replaceState({ path: newUrl }, '', newUrl);
}
};
const loadAllCards = async () => {
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = '<p>正在加载全部 IC 卡...</p>';
renderDetailPrompt('正在准备卡片详情...');
renderEventPrompt('正在准备事件记录...');
const data = await api.query('');
state.cards = Array.isArray(data.cards) ? data.cards : [];
state.selectedQuery = '';
renderCardList();
if (!state.cards.length) {
renderDetailPrompt('当前暂无 IC 卡记录。');
renderEventPrompt('当前暂无 IC 卡记录。');
const newUrl = `${window.location.origin}${window.location.pathname}`;
window.history.replaceState({ path: newUrl }, '', newUrl);
return;
}
await loadCardDetail(getLookupKey(state.cards[0]), { updateUrl: false });
const newUrl = `${window.location.origin}${window.location.pathname}`;
window.history.replaceState({ path: newUrl }, '', newUrl);
};
const doQuery = async () => {
const q = inputEl.value.trim();
if (!q) {
await loadAllCards();
return;
}
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = '<p>正在查询 IC 卡...</p>';
renderDetailPrompt('正在查询卡片详情...');
renderEventPrompt('正在查询事件记录...');
state.cards = [];
await loadCardDetail(q);
};
const renderQueryError = (message) => {
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = `<p>${escapeHtml(message)}</p>`;
renderDetailPrompt(message);
renderEventPrompt(message);
};
queryBtn.addEventListener('click', () => doQuery().catch((error) => renderQueryError(error.message || String(error))));
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') doQuery().catch((error) => renderQueryError(error.message || String(error)));
});
const params = new URLSearchParams(location.search);
const q = params.get('q');
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 (q) {
inputEl.value = q;
doQuery().catch((error) => renderQueryError(error.message || String(error)));
} else {
loadAllCards().catch((error) => renderQueryError(error.message || String(error)));
}
}
init();
})();