Files
FSE-Ticket.sys/web/ic-card-search.js
T
Henry_Du 8435717a58 fix(web, ticketmachine): 修复web设备ID匹配,清理无用代码并优化界面显示
调整web端设备ID匹配正则,支持带#前缀的数字ID
移除ticketmachine.lua中未使用的变量和工具函数
替换界面成本显示文本,将$改为cogs:
2026-07-10 13:25:54 +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();
})();