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

398 lines
19 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 state = {
cards: [],
selectedId: '',
selectedCard: null,
selectedEvents: []
};
const appDialog = window.appDialog || {
alert: (message) => Promise.resolve(window.alert(message)),
confirm: (message) => Promise.resolve(window.confirm(message)),
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
};
const HOLDER_NAME_PATTERN = /^[A-Za-z][A-Za-z .,'()&@/_\-+]*$/;
const displayCardId = (card) => String(card?.display_card_id || card?.card_id || '---');
const statusTextEl = $('#serverStatusText');
const listEl = $('#cardList');
const detailEl = $('#detailPanel');
const eventEl = $('#eventList');
const searchEl = $('#searchInput');
const api = {
async request(url, opts = {}) {
const res = await fetch(url, opts);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.ok === false) {
throw new Error(data.error || res.statusText || '请求失败');
}
return data;
},
fetchCards(q = '') {
return api.request(`/api/ic-cards?q=${encodeURIComponent(q)}`);
},
fetchCardDetail(id) {
return api.request(`/api/ic-cards/${encodeURIComponent(id)}`);
},
createCard(payload) {
return api.request('/api/ic-cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
},
updateCard(id, payload) {
return api.request(`/api/ic-cards/${encodeURIComponent(id)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
},
topup(id, amount) {
return api.request(`/api/ic-cards/${encodeURIComponent(id)}/topup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount })
});
}
};
const escapeHtml = (value) => String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const formatMoney = (value) => {
const n = Number(value || 0);
return Number.isFinite(n) ? n.toFixed(0) : '0';
};
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 statusInfo = (status) => {
const map = {
pending_pickup: { text: '待领卡', className: 'badge-warning' },
active: { text: '正常', className: 'badge-success' },
disabled: { text: '停用', className: 'badge-danger' },
lost: { text: '挂失', className: 'badge-danger' },
refunded: { text: '已退卡', className: 'badge-secondary' }
};
return map[String(status || '').toLowerCase()] || { text: status || '未知', className: 'badge-secondary' };
};
const eventTitle = (event) => {
const map = {
create: '后台建卡',
update: '信息更新',
topup: '余额充值',
order_created: '线上购卡',
activated: '正式启用'
};
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const renderStats = () => {
const cards = state.cards;
$('#statTotal').textContent = String(cards.length);
$('#statPending').textContent = String(cards.filter((card) => card.status === 'pending_pickup').length);
$('#statActive').textContent = String(cards.filter((card) => card.status === 'active').length);
$('#statBalance').textContent = formatMoney(cards.reduce((sum, card) => sum + (Number(card.balance || 0) || 0), 0));
$('#listCountBadge').textContent = String(cards.length);
};
const renderList = () => {
if (!state.cards.length) {
listEl.innerHTML = '<div class="empty-state" style="padding:24px 0;"><p>暂无 IC 卡记录。</p></div>';
return;
}
listEl.innerHTML = state.cards.map((card) => {
const info = statusInfo(card.status);
return `
<div class="line-item ic-card-item ${state.selectedId === card.card_id ? 'active' : ''}" data-id="${escapeHtml(card.card_id)}">
<div class="line-color-dot" style="background:${card.status === 'active' ? 'var(--success)' : (card.status === 'pending_pickup' ? 'var(--warning)' : 'var(--danger)')}"></div>
<div class="line-info">
<div class="line-name">${escapeHtml(displayCardId(card))}</div>
<div class="line-meta">${escapeHtml(card.holder_name || '未登记持卡人')} · IC 储值卡</div>
<div class="line-meta">订单 ${escapeHtml(card.order_code || '---')} · 余额 ${escapeHtml(formatMoney(card.balance))}</div>
</div>
<div class="line-actions" style="opacity:1;">
<span class="badge ${info.className}">${escapeHtml(info.text)}</span>
</div>
</div>
`;
}).join('');
listEl.querySelectorAll('[data-id]').forEach((item) => {
item.addEventListener('click', () => {
const id = item.getAttribute('data-id');
if (id) loadCard(id);
});
});
};
const renderDetail = () => {
if (!state.selectedCard) {
detailEl.className = 'empty-state';
detailEl.innerHTML = '<i class="fas fa-credit-card" style="font-size:48px; opacity:0.3;"></i><p>从左侧选择一张 IC 卡以查看详情。</p>';
eventEl.innerHTML = '<div class="loading">选择卡片后显示事件流。</div>';
return;
}
const card = state.selectedCard;
const info = statusInfo(card.status);
detailEl.className = '';
detailEl.innerHTML = `
<div class="flex between mb-4" style="align-items:flex-start;">
<div>
<div class="mono" style="font-size:1.4rem; font-weight:700;">${escapeHtml(displayCardId(card))}</div>
<div class="text-muted" style="margin-top:6px;">订单号 ${escapeHtml(card.order_code || '---')} · 来源 ${escapeHtml(card.source || '---')}</div>
</div>
<span class="badge ${info.className}">${escapeHtml(info.text)}</span>
</div>
<div class="ic-detail-grid">
<label class="ic-field">
<span>持卡人</span>
<input id="detailHolder" value="${escapeHtml(card.holder_name || '')}">
</label>
<label class="ic-field">
<span>卡片类型</span>
<input value="IC 储值卡" disabled>
</label>
<label class="ic-field">
<span>状态</span>
<select id="detailStatus">
<option value="pending_pickup" ${card.status === 'pending_pickup' ? 'selected' : ''}>待领卡</option>
<option value="active" ${card.status === 'active' ? 'selected' : ''}>正常</option>
<option value="disabled" ${card.status === 'disabled' ? 'selected' : ''}>停用</option>
<option value="lost" ${card.status === 'lost' ? 'selected' : ''}>挂失</option>
<option value="refunded" ${card.status === 'refunded' ? 'selected' : ''}>已退卡</option>
</select>
</label>
<label class="ic-field">
<span>余额</span>
<input id="detailBalance" type="number" min="0" step="1" value="${escapeHtml(Number(card.balance || 0))}">
</label>
</div>
<div class="ic-inline-meta">
<div class="list-item"><span class="k">创建时间</span><span class="v">${escapeHtml(formatTime(card.created_ts))}</span></div>
<div class="list-item"><span class="k">最后更新</span><span class="v">${escapeHtml(formatTime(card.last_update_ts))}</span></div>
<div class="list-item"><span class="k">首次充值</span><span class="v">${escapeHtml(formatMoney(card.purchase_amount ?? card.balance))}</span></div>
<div class="list-item"><span class="k">购卡金额</span><span class="v">${escapeHtml(formatMoney(card.purchase_amount))}</span></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 events = state.selectedEvents;
eventEl.innerHTML = events.length ? events.map((event) => `
<div class="timeline-item">
<div class="timeline-dot"></div>
<div class="timeline-content">
<div class="flex between">
<strong style="font-weight:600;">${escapeHtml(eventTitle(event))}</strong>
</div>
<div class="log-detail text-muted" style="font-size: 0.85rem; margin-top: 4px;">
${escapeHtml(formatIcCardEventUnified(event))}
</div>
</div>
</div>
`).join('') : '<div class="text-muted" style="padding: 20px 0; text-align: center;">暂无事件记录</div>';
};
const validateHolderName = (value) => {
const holderName = String(value || '').trim();
if (!holderName) return '请输入持卡人姓名';
if (holderName.length > 24) return '持卡人姓名不能超过 24 个字符';
if (!HOLDER_NAME_PATTERN.test(holderName)) return '持卡人姓名仅支持英文与常用符号';
return '';
};
const currentDetailPayload = () => ({
holder_name: $('#detailHolder')?.value.trim() || '',
status: $('#detailStatus')?.value || 'active',
balance: Number($('#detailBalance')?.value || 0) || 0
});
async function refreshList(keepSelection = true) {
const query = searchEl.value.trim();
const data = await api.fetchCards(query);
state.cards = data.cards || [];
renderStats();
renderList();
if (keepSelection && state.selectedId && state.cards.some((card) => card.card_id === state.selectedId)) {
await loadCard(state.selectedId, false);
} else if (state.selectedId && !state.cards.some((card) => card.card_id === state.selectedId)) {
state.selectedId = '';
state.selectedCard = null;
state.selectedEvents = [];
renderDetail();
}
}
async function loadCard(id, rerenderList = true) {
const data = await api.fetchCardDetail(id);
state.selectedId = id;
state.selectedCard = data.card || null;
state.selectedEvents = data.events || [];
if (rerenderList) renderList();
renderDetail();
}
async function createCard() {
const holder_name = $('#createHolder').value.trim();
const balance = Number($('#createBalance').value || 0) || 0;
const holderNameError = validateHolderName(holder_name);
if (holderNameError) {
alert(holderNameError);
return;
}
const data = await api.createCard({ holder_name, balance });
$('#createHolder').value = '';
$('#createBalance').value = '50';
await refreshList(false);
if (data.card_id) await loadCard(data.card_id);
alert(`IC 卡已创建:${displayCardId(data.card || data)}`);
}
async function saveCard() {
if (!state.selectedId) {
alert('请先选择一张 IC 卡');
return;
}
const payload = currentDetailPayload();
const holderNameError = validateHolderName(payload.holder_name);
if (holderNameError) {
alert(holderNameError);
return;
}
await api.updateCard(state.selectedId, payload);
await refreshList(false);
await loadCard(state.selectedId);
alert('已保存 IC 卡信息');
}
async function topupCard() {
if (!state.selectedId) {
alert('请先选择一张 IC 卡');
return;
}
const raw = await appDialog.prompt({
title: 'IC 卡充值',
message: `请输入给 ${displayCardId(state.selectedCard || { card_id: state.selectedId })} 充值的金额`,
defaultValue: '50',
placeholder: '请输入充值金额',
confirmText: '确认充值'
});
if (raw == null) return;
const amount = Number(raw);
if (!(amount > 0)) {
alert('充值金额必须大于 0');
return;
}
await api.topup(state.selectedId, amount);
await refreshList(false);
await loadCard(state.selectedId);
alert('充值成功');
}
async function pingServer() {
try {
await fetch('/api/public/health', { cache: 'no-store' });
statusTextEl.textContent = '服务状态:在线';
} catch (_) {
statusTextEl.textContent = '服务状态:离线';
}
}
$('#refreshBtn').addEventListener('click', () => refreshList(false).catch((error) => alert(error.message || String(error))));
$('#createBtn').addEventListener('click', () => createCard().catch((error) => alert(error.message || String(error))));
$('#saveBtn').addEventListener('click', () => saveCard().catch((error) => alert(error.message || String(error))));
$('#topupBtn').addEventListener('click', () => topupCard().catch((error) => alert(error.message || String(error))));
searchEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
refreshList(false).catch((error) => alert(error.message || String(error)));
}
});
searchEl.addEventListener('input', () => {
window.clearTimeout(searchEl._timer);
searchEl._timer = window.setTimeout(() => {
refreshList(false).catch((error) => alert(error.message || String(error)));
}, 240);
});
let stationMap = {};
window.getStationName = (code) => stationMap[code] || code;
(async () => {
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) {}
await pingServer();
await refreshList(false);
if (state.cards[0]) await loadCard(state.cards[0].card_id);
})().catch((error) => {
statusTextEl.textContent = '服务状态:请求失败';
listEl.innerHTML = `<div class="loading">${escapeHtml(error.message || String(error))}</div>`;
});
})();