feat(web, server): 更新品牌文案,新增IC卡批量查询并重构搜索页面

统一替换全站所有HTML页面的品牌标题为FarSight-T.N.E铁路运输,调整部分页面的中文显示文案,例如删除ticket-board.html中的冗余说明文字。格式化重构blog.html的代码结构与缩进,修复末尾无换行的问题。
后端完善/ic-cards/query接口:支持空查询返回全部IC卡列表,按创建时间倒序排序,添加卡片状态和类型的标准化标签,优化请求日志记录。
全面重构IC卡搜索页面的前端逻辑,新增批量查看所有IC卡功能,支持点击卡片查看详情与操作历史,优化状态管理与渲染流程。
This commit is contained in:
2026-06-28 11:02:32 +08:00
parent 7fe1acd9d7
commit 042720d812
12 changed files with 219 additions and 100 deletions
+10 -2
View File
@@ -345,8 +345,16 @@ router.post('/orders/:code/consume', async (req, res) => {
router.get('/ic-cards/query', async (req, res) => {
const q = String(req.query.q || '').trim();
if (!q) {
appendReqLog(req, { category: 'public', type: 'ic_card_query_invalid', level: 'warn', detail: { q } });
return res.status(400).json({ ok: false, error: 'query required' });
const cards = (DataService.getIcCards() || [])
.slice()
.sort((a, b) => Number(b?.created_ts || 0) - Number(a?.created_ts || 0))
.map((card) => ({
...presentIcCard(card),
status_label: mapIcCardStatus(card.status),
card_type_label: mapIcCardType(card.card_type)
}));
appendReqLog(req, { category: 'public', type: 'ic_card_query_all', detail: { total: cards.length } });
return res.json({ ok: true, cards });
}
const normCardId = normalizeIcCardId(q);
const normOrderCode = String(q || '').trim().toUpperCase();
+71 -63
View File
@@ -1,66 +1,74 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FMG</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=13">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
<div class="public-container">
<header class="search-header" style="text-align: left;">
<div style="margin-bottom: 10px; text-align: left;">
<a href="https://ticket.fse-media.group" id="homeLink" style="color: var(--primary); text-decoration: none; font-weight: 500;">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
FMG
</h1>
</div>
</header>
<main>
<section class="tab-panel show">
<div class="portal-grid">
<a href="http://forum.fse-media.group" class="portal-card">
<div class="portal-icon">
<i class="fas fa-comments"></i>
</div>
<h3>论坛</h3>
<p>forum.fse-media.group</p>
</a>
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
<div class="portal-icon">
<i class="fas fa-poll-h"></i>
</div>
<h3>问卷</h3>
<p>b.igtm.ooooo.ink</p>
</a>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-title" style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
<i class="fas fa-server text-primary"></i> 服务器状态</div>
<div style="overflow-x: auto; width: 100%;">
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500" style="max-width:100%; border-radius: 8px;" scrolling="no" src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
</div>
</div>
</section>
</main>
<footer style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
<p>&copy; 2026 FSE Media Group. All rights reserved.</p>
</footer>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="blog.js?v=2"></script>
</body>
</html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>FMG</title>
<link rel="icon" type="image/png" href="/FSE-ticket.png">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="style.css?v=13">
<link rel="stylesheet" href="blog.css?v=2">
</head>
<body class="public-search">
<div class="public-container">
<header class="search-header" style="text-align: left;">
<div style="margin-bottom: 10px; text-align: left;">
<a href="https://ticket.fse-media.group" id="homeLink"
style="color: var(--primary); text-decoration: none; font-weight: 500;">
<i class="fas fa-arrow-left"></i> 返回首页
</a>
</div>
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<img src="logo.png" alt="Logo" style="height: 40px; margin-right: 12px;">
<h1 style="font-weight: 800; letter-spacing: -1px; margin: 0; text-align: left; font-size: 26px;">
FMG
</h1>
</div>
</header>
<main>
<section class="tab-panel show">
<div class="portal-grid">
<a href="http://forum.fse-media.group" class="portal-card">
<div class="portal-icon">
<i class="fas fa-comments"></i>
</div>
<h3>论坛</h3>
<p>forum.fse-media.group</p>
</a>
<a href="http://b.igtm.ooooo.ink:11324" class="portal-card">
<div class="portal-icon">
<i class="fas fa-poll-h"></i>
</div>
<h3>问卷</h3>
<p>b.igtm.ooooo.ink</p>
</a>
</div>
<div class="card" style="margin-top: 20px;">
<div class="card-title"
style="font-size: 1.25rem; font-weight: 700; margin-bottom: 20px; color: var(--text);">
<i class="fas fa-server text-primary"></i> 服务器状态
</div>
<div style="overflow-x: auto; width: 100%;">
<iframe id="mc-status-traintown-online-" frameborder="0" width="1000" height="500"
style="max-width:100%; border-radius: 8px;" scrolling="no"
src="https://motd.minebbs.com/iframe?ip=traintown.online&stype=auto&dark=true"></iframe>
</div>
</div>
</section>
</main>
<footer
style="margin-top: 60px; padding: 20px 0; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.85rem; text-align: center;">
<p>&copy; 2026 FSE Media Group. All rights reserved.</p>
</footer>
<footer class="site-footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">粤ICP备2025450093号</a>
<span class="version">v1.0.12</span>
</footer>
</div>
<script src="/custom-dialog.js?v=12"></script>
<script src="blog.js?v=2"></script>
</body>
</html>
+1 -1
View File
@@ -30,7 +30,7 @@
<a href="/" class="jr-brand" id="icBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 铁路运输</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC 卡管理后台</span>
</div>
</a>
+2 -2
View File
@@ -30,8 +30,8 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE Railway</strong>
<span>IC Card Detail</span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC卡 详情</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
+2 -2
View File
@@ -30,8 +30,8 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE Railway</strong>
<span>IC Card Online Order</span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC卡 线上预定</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
+2 -2
View File
@@ -30,8 +30,8 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE Railway</strong>
<span>IC Card Search</span>
<strong>FarSight-T.N.E铁路运输</strong>
<span>IC卡 查询</span>
</div>
</a>
<nav class="jr-nav" aria-label="站点导航">
+125 -22
View File
@@ -4,6 +4,10 @@
const queryBtn = $('#queryBtn');
const summaryBoxEl = $('#summaryBox');
const eventBoxEl = $('#eventBox');
const state = {
cards: [],
selectedQuery: ''
};
const api = {
async request(url) {
@@ -19,6 +23,15 @@
}
};
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, '&amp;')
.replace(/</g, '&lt;')
@@ -44,18 +57,17 @@
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const renderSummary = (card) => {
const buildCardPreview = (card) => {
const shownCardId = card.display_card_id || card.card_id || '---';
summaryBoxEl.className = '';
summaryBoxEl.innerHTML = `
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 ${card.status === 'active' ? 'jr-status-valid' : (card.status === 'pending_pickup' ? 'jr-status-used' : 'jr-status-expired')}">${escapeHtml(card.status_label || card.status || '未知')}</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>IC 储值卡</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>
@@ -65,12 +77,11 @@
`;
};
const renderEvents = (events) => {
const buildEventsHtml = (events) => {
if (!events.length) {
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
return;
return '<div class="jr-center-empty" style="min-height:180px;"><p>暂无相关事件记录。</p></div>';
}
eventBoxEl.innerHTML = events.map((event) => `
return events.map((event) => `
<div class="jr-history-item">
<div class="jr-history-row">
<span class="jr-history-title">${escapeHtml(eventTitle(event))}</span>
@@ -81,37 +92,129 @@
`).join('');
};
const renderError = (message) => {
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) {
renderEventPrompt('请选择左侧卡片查看详情与事件记录。');
return;
}
eventBoxEl.innerHTML = `
${buildCardPreview(card)}
<div class="jr-panel-headline" style="margin:20px 0 14px;">
<h3>事件记录</h3>
<span class="jr-panel-note">Recent Events</span>
</div>
<div class="jr-history-list">${buildEventsHtml(events)}</div>
`;
};
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" data-card-query="${escapeHtml(lookupKey)}" style="${isSelected ? 'background:#f7faf7;border-left:4px solid #0b6b3a;padding-left:12px;padding-right:12px;' : ''}">
<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="text-muted" style="margin-top:6px; font-size:0.9rem;">
余额 ${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) => {
renderEventPrompt(error.message || String(error));
});
}
});
});
};
const loadCardDetail = async (q, options = {}) => {
const { updateUrl = true } = options;
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>正在加载卡片详情...</p></div>';
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>${escapeHtml(message)}</p>`;
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>暂无可显示的事件记录。</p></div>';
summaryBoxEl.innerHTML = '<p>正在加载全部 IC 卡...</p>';
renderEventPrompt('正在准备卡片详情...');
const data = await api.query('');
state.cards = Array.isArray(data.cards) ? data.cards : [];
state.selectedQuery = '';
renderCardList();
if (!state.cards.length) {
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) {
renderError('请输入卡号或凭证码');
await loadAllCards();
return;
}
summaryBoxEl.className = 'jr-center-empty';
summaryBoxEl.innerHTML = '<p>正在查询 IC 卡...</p>';
eventBoxEl.innerHTML = '<div class="jr-center-empty" style="min-height:180px;"><p>正在加载事件记录...</p></div>';
const data = await api.query(q);
renderSummary(data.card || {});
renderEvents(data.events || []);
const newUrl = `${window.location.origin}${window.location.pathname}?q=${encodeURIComponent(q)}`;
window.history.replaceState({ path: newUrl }, '', newUrl);
state.cards = [];
await loadCardDetail(q);
};
queryBtn.addEventListener('click', () => doQuery().catch((error) => renderError(error.message || String(error))));
queryBtn.addEventListener('click', () => doQuery().catch((error) => renderEventPrompt(error.message || String(error))));
inputEl.addEventListener('keydown', (event) => {
if (event.key === 'Enter') doQuery().catch((error) => renderError(error.message || String(error)));
if (event.key === 'Enter') doQuery().catch((error) => renderEventPrompt(error.message || String(error)));
});
const params = new URLSearchParams(location.search);
const q = params.get('q');
if (q) {
inputEl.value = q;
doQuery().catch((error) => renderError(error.message || String(error)));
doQuery().catch((error) => renderEventPrompt(error.message || String(error)));
} else {
loadAllCards().catch((error) => renderEventPrompt(error.message || String(error)));
}
})();
+1 -1
View File
@@ -23,7 +23,7 @@
<a href="/" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 铁路运输</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>控制台登录</span>
</div>
</a>
+2 -2
View File
@@ -63,7 +63,7 @@
<a href="javascript:void(0)" @click="goHome" class="jr-brand">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路票务系统</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>电子客票信息</span>
</div>
</a>
@@ -80,7 +80,7 @@
<section class="jr-page-intro">
<span class="jr-kicker">ELECTRONIC TICKET</span>
<h1>查看车票状态与最近流转记录</h1>
<p>用于查看单张电子客票的乘车信息、状态与进出站记录,便于旅客和工作人员快速确认票据状态。</p>
<p>用于查看单张电子客票的乘车信息、状态与进出站记录</p>
</section>
<div v-if="loading" class="jr-panel-card">
<div class="jr-center-empty">
+1 -1
View File
@@ -30,7 +30,7 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路票务系统</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>线上预定</span>
</div>
</a>
+1 -1
View File
@@ -34,7 +34,7 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="routeBrandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE 铁路运输</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>线路规划后台</span>
</div>
</a>
+1 -1
View File
@@ -31,7 +31,7 @@
<a href="https://ticket.fse-media.group" class="jr-brand" id="brandLink">
<img src="/FSE-ticket.png" alt="FSE Railway" class="jr-brand-logo" />
<div class="jr-brand-copy">
<strong>FSE铁路售票系统</strong>
<strong>FarSight-T.N.E铁路运输</strong>
<span>票务查询</span>
</div>
</a>