336 lines
15 KiB
JavaScript
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, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
|
|
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>`;
|
|
});
|
|
})();
|