Files
FSE-Ticket.sys/web/ic-card-admin.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

367 lines
17 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();
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 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);
});
(async () => {
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>`;
});
})();