Files
FSE-Ticket.sys/web/ic-card-search.js
T
Henry_Du 95893a5431 refactor(web): 重构全站界面与事件日志,优化功能与样式
1. 重构并统一票务、IC卡的事件日志格式化展示逻辑,替换原有零散的格式化函数,提升事件展示的一致性与可读性
2. 为AI助手面板新增折叠按钮与折叠逻辑,支持收起/展开面板,优化界面空间利用率
3. 移除多个页面的冗余介绍、提醒区块,精简页面结构
4. 修复IC卡购卡页面的充值选项按钮布局问题,将button元素改为div适配样式规范
5. 新增移动端卡片布局的横向滚动适配,优化小屏设备上的卡片展示与浏览体验
6. 更新后台管理页面的导航样式、文案与统计数据展示,新增车票、凭证的统计卡片
7. 统一全站品牌文案与页面格式,修复部分页面的DOCTYPE多余空格问题
8. 简化车票订单页面的地图加载逻辑,修复原有的冗余代码与加载异常问题
2026-07-05 14:55:12 +08:00

271 lines
12 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();
const deviceId = ev?.device || ev?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---';
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') {
const amount = ev?.amount ?? ev?.["充值数额"] ?? 0;
return \`机器ID: \${deviceId};充值数额: \${amount};余额: \${balance};时间: \${timeStr}\`;
}
if (action === 'entry' || type === 'entry') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
return \`机器ID: \${deviceId};站点编号: \${code};站点中文: \${name};余额: \${balance};时间: \${timeStr}\`;
}
if (action === 'exit' || type === 'exit') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || '---';
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');
if (q) {
inputEl.value = q;
doQuery().catch((error) => renderQueryError(error.message || String(error)));
} else {
loadAllCards().catch((error) => renderQueryError(error.message || String(error)));
}
})();