Files
2026-06-21 10:00:13 +08:00

336 lines
15 KiB
JavaScript

(() => {
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 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">
<span style="font-weight:600;">${escapeHtml(eventTitle(event))}</span>
<span class="text-muted" style="font-size:0.8rem;">${escapeHtml(formatTime(event.ts))}</span>
</div>
<div class="log-detail" style="margin-top:8px;">${escapeHtml(typeof event.detail === 'string' ? event.detail : JSON.stringify(event.detail || {}, null, 2))}</div>
</div>
</div>
`).join('') : '<div class="loading">暂无事件记录。</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>`;
});
})();