308 lines
14 KiB
JavaScript
308 lines
14 KiB
JavaScript
(() => {
|
||
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, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
|
||
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?.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 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();
|
||
})();
|