Files
FSE-Ticket.sys/web/index.js
T

1654 lines
71 KiB
JavaScript
Raw Normal View History

2026-06-21 10:00:13 +08:00
(() => {
try {
if (localStorage.getItem('tm_session') !== 'ok') {
const next = encodeURIComponent(location.pathname + location.search);
location.href = `/login.html?next=${next}`;
}
} catch (_) { }
})();
const { createApp, ref, onMounted, onUnmounted, computed, reactive, watch } = Vue;
createApp({
setup() {
const detectInitialView = () => {
const view = new URLSearchParams(location.search).get('view');
if (view) return view;
if (location.pathname === '/admin/ic-card' || location.pathname === '/ic-card-admin') return 'iccards';
return 'dashboard';
};
const currentView = ref(detectInitialView());
const sidebarOpen = ref(false);
const viewTitle = computed(() => {
const map = {
dashboard: '仪表盘',
management: '线路与票价管理',
faremap: '票价地图',
tickets: '车票记录',
vouchers: '凭证管理',
iccards: 'IC 卡管理',
assets: '线路图',
settings: '系统设置',
logs: '日志'
};
return map[currentView.value] || '票价图';
});
const connected = ref(false);
// Prefer polling first so admin remains connected even when the proxy
// does not support WebSocket upgrades reliably.
const socket = io({ transports: ['polling', 'websocket'] });
// #region debug-point socket-runtime-admin
const reportSocketRuntime = (type, detail = {}) => {
try {
const payload = JSON.stringify({
category: 'admin',
source: 'socket-runtime',
level: type.includes('error') ? 'error' : 'info',
type,
detail: {
page: location.pathname,
href: location.href,
online: navigator.onLine,
socket_id: socket.id || '',
connected: !!socket.connected,
transport: socket.io?.engine?.transport?.name || '',
...detail
}
});
if (navigator.sendBeacon) {
const blob = new Blob([payload], { type: 'application/json' });
navigator.sendBeacon('/api/log', blob);
return;
}
fetch('/api/log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
keepalive: true
}).catch(() => {});
} catch (_) {}
};
// #endregion
2026-06-21 10:00:13 +08:00
// Data State
const stations = ref([]);
const lines = ref([]);
const fares = ref([]);
const tickets = ref([]);
const stats = reactive({ sold_tickets: 0, revenue: 0 });
const config = reactive({ api_base: '', promotion: { name: '', discount: 1 } });
const logs = ref([]);
const logCategory = ref('');
const logTypeFilter = ref('');
const logQuery = ref('');
const logMax = ref(200);
const logLoading = ref(false);
const orders = ref([]);
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null });
const assetsFarePreview = reactive({ headers: [], rows: [] });
const assetsRouteMapUrl = ref('');
const assetsFareTableUrl = ref('');
const icCards = ref([]);
const icCardSearch = ref('');
const icSelectedId = ref('');
const icSelectedCard = ref(null);
const icSelectedEvents = ref([]);
const icCreateForm = reactive({ holder_name: '', balance: 50 });
const icDetailForm = reactive({ holder_name: '', status: 'active' });
let icCardSyncTimer = null;
let icCardSyncBusy = false;
let icListRequestSeq = 0;
let icDetailRequestSeq = 0;
let appMouseupHandler = null;
let coreLoaded = false;
let ticketDataLoaded = false;
let orderDataLoaded = false;
let logDataLoaded = false;
let assetsLoaded = false;
let fareMapLoaded = false;
const loadingState = reactive({
core: false,
tickets: false,
orders: false,
logs: false,
iccards: false
});
const lastSyncAt = ref(0);
2026-06-21 10:00:13 +08:00
// UI State
const showAddLine = ref(false);
const showAddStation = ref(false);
const newLine = reactive({ id: '', name: '', en_name: '', color: '#3366cc' });
const newStation = reactive({ code: '', name: '', en_name: '' });
// Ticket View State
const showTicketModal = ref(false);
const selectedTicket = ref(null);
// Management View State
const selectedLine = ref(null);
const fareMode = ref(false);
const stationEditMode = ref(false);
const fareSelection = ref([]);
const showFareModal = ref(false);
const currentFare = reactive({ exists: false, cost_regular: 0, cost_express: 0 });
const draggingStationIndex = ref(null);
const visualLineViewport = ref(null);
const lineViewportPan = reactive({
active: false,
startX: 0,
startY: 0,
scrollLeft: 0,
scrollTop: 0,
moved: false
});
2026-06-21 10:00:13 +08:00
const showStationModal = ref(false);
const stationForm = reactive({ code: '', name: '', en_name: '', transfer_enabled: false, transfer_to: [] });
const stationFormOriginalCode = ref('');
const showLineModal = ref(false);
const lineFormOriginalId = ref('');
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
// Legacy/Other State
const fareMapSvg = ref('');
const fareMapScale = ref(1);
const fareMapLoading = ref(false);
const fareMapError = ref('');
const ticketSearch = ref('');
const lastActionError = ref('');
const lastActionOkTs = ref(0);
const mutationBusy = ref(false);
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 markSynced = () => {
lastSyncAt.value = Date.now();
};
2026-06-21 10:00:13 +08:00
const buildAssetUrl = (name) => {
if (!name) return '';
const v = assetsManifest.updatedAt ? String(assetsManifest.updatedAt) : String(Date.now());
return `/assets/${encodeURIComponent(name)}?v=${encodeURIComponent(v)}`;
};
const parseJsonSafe = (text) => {
if (text == null) return null;
const t = String(text);
if (!t) return null;
try { return JSON.parse(t); } catch (e) { return null; }
};
const parseCsvSimple = (text) => {
const out = { headers: [], rows: [] };
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const lines = raw.split('\n').map(x => x.trim()).filter(Boolean);
if (!lines.length) return out;
const splitLine = (line) => {
const parts = [];
let cur = '';
let inQ = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQ && line[i + 1] === '"') { cur += '"'; i++; continue; }
inQ = !inQ;
continue;
}
if (!inQ && ch === ',') { parts.push(cur); cur = ''; continue; }
cur += ch;
}
parts.push(cur);
return parts.map(x => x.trim());
};
const headers = splitLine(lines[0]).filter(Boolean);
if (!headers.length) return out;
out.headers = headers;
for (let i = 1; i < lines.length; i++) {
const cols = splitLine(lines[i]);
const row = {};
for (let j = 0; j < headers.length; j++) row[headers[j]] = (cols[j] == null ? '' : cols[j]);
out.rows.push(row);
}
return out;
};
const requestJson = async (url, opts = {}, { expectOk = false, timeoutMs = 15000 } = {}) => {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const r = await fetch(url, { ...opts, signal: controller.signal });
const text = await r.text();
const data = parseJsonSafe(text) ?? (text ? { raw: text } : null);
if (!r.ok) {
const msg = (data && (data.error || data.错误)) || r.statusText || '请求失败';
throw new Error(`${r.status} ${msg}`);
}
if (expectOk) {
if (data && data.ok === false) throw new Error(data.error || data.错误 || '操作失败');
if (data && data.ok == null && (data.error || data.错误)) throw new Error(data.error || data.错误);
}
return data;
} catch (e) {
const msg = (e && e.name === 'AbortError') ? '请求超时' : (e?.message || String(e));
throw new Error(msg);
} finally {
clearTimeout(timer);
}
};
const runMutation = async (action, { successMessage } = {}) => {
if (mutationBusy.value) return;
mutationBusy.value = true;
lastActionError.value = '';
try {
await action();
lastActionOkTs.value = Date.now();
if (successMessage) alert(successMessage);
await fetchData();
} catch (e) {
lastActionError.value = e?.message || String(e);
alert(`操作失败:${lastActionError.value}`);
await fetchData();
} finally {
mutationBusy.value = false;
}
};
// Methods
const formatTime = (ts) => {
if (ts == null || ts === '') return '---';
let value = Number(ts);
if (Number.isFinite(value)) {
if (value > 0 && value < 1000000000000) value *= 1000;
const d = new Date(value);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleString('zh-CN', { hour12: false });
}
}
const d = new Date(ts);
if (!Number.isNaN(d.getTime())) {
return d.toLocaleString('zh-CN', { hour12: false });
}
return String(ts);
};
const formatLogType = (type) => {
const map = {
'update_config_generic': { icon: 'fa-cog', text: '配置更新', class: 'text-primary' },
'update_station': { icon: 'fa-map-marker-alt', text: '站点更新', class: 'text-info' },
'add_line': { icon: 'fa-plus-circle', text: '新增线路', class: 'text-success' },
'update_line': { icon: 'fa-edit', text: '线路更新', class: 'text-warning' },
'update_fare': { icon: 'fa-coins', text: '票价更新', class: 'text-warning' },
'delete_fare': { icon: 'fa-trash', text: '票价删除', class: 'text-danger' },
'ticket_sold': { icon: 'fa-ticket-alt', text: '售票成功', class: 'text-success' },
'gate_entry': { icon: 'fa-sign-in-alt', text: '进站', class: 'text-info' },
'gate_exit': { icon: 'fa-sign-out-alt', text: '出站', class: 'text-info' }
};
return map[type] || { icon: 'fa-info-circle', text: type, class: 'text-muted' };
};
const formatTicketStatus = (status) => {
const map = {
'valid': { text: '有效', class: 'badge-success' },
'used': { text: '已使用', class: 'badge-secondary' },
'expired': { text: '已过期', class: 'badge-danger' },
'refunded': { text: '已退票', class: 'badge-warning' }
};
return map[status] || { text: status || '未知', class: 'badge-secondary' };
};
const formatTrainType = (type) => {
if (!type) return '普通';
const t = type.toLowerCase();
if (t === 'local') return '普通';
if (t === 'ltd.exp' || t === 'express') return '特急';
return type;
};
const getTicketEventType = (event) => String((event && (event["类型"] || event.type)) || '').toLowerCase();
const getTicketEventAction = (event) => String((event && (event["动作"] || event.action)) || '').toLowerCase();
const formatTicketEvent = (eventOrType) => {
const event = eventOrType && typeof eventOrType === 'object' ? eventOrType : { type: eventOrType };
const type = getTicketEventType(event);
const action = getTicketEventAction(event);
if (type === 'sale' || type === '售票') return '售票成功';
if (type === 'entry' || action === 'entry') return '进站成功';
if (type === 'exit' || action === 'exit') return '出站成功';
if (type === 'status' || type === '状态') {
return { entry: '进站成功', exit: '出站成功' }[action] || '状态变更';
}
const map = {
'entry': '进站',
'exit': '出站',
'check': '验票',
'status': '状态变更',
'refund': '退票'
};
return map[type] || event["类型"] || event.type || '状态变更';
};
const formatTicketEventLocation = (event) => {
const type = getTicketEventType(event);
const stationName = event?.["售票站"] || event?.["发生站"] || event?.station_name || '';
const stationCode = event?.["站点编号"] || event?.station_code || '';
if (type === 'sale' || type === '售票') {
return stationName || '线上售票';
}
if (!stationName && !stationCode) return '---';
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
};
const formatTicketEventAttachment = (value) => {
if (value == null || value === '') return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
return value.map(formatTicketEventAttachment).filter(Boolean).join(' | ');
}
if (typeof value === 'object') {
return Object.entries(value)
.filter(([, item]) => item != null && item !== '')
.map(([key, item]) => `${key}: ${formatTicketEventAttachment(item)}`)
.filter(Boolean)
.join(' | ');
}
return String(value);
};
const formatTicketEventExtra = (event) => {
const type = getTicketEventType(event);
const amount = event?.["售票额"] ?? event?.amount ?? event?.price ?? event?.cost;
const stationEn = event?.["站点英文"] || event?.station_en || '';
const deviceId = event?.["设备编号"] || event?.device_id || event?.device || '';
const attachment = formatTicketEventAttachment(
event?.["附加信息"] ?? event?.extra ?? event?.info ?? event?.meta ?? event?.detail
);
const parts = [];
if ((type === 'sale' || type === '售票') && amount != null && amount !== '') {
parts.push(`票价:¥ ${amount}`);
}
if (stationEn && deviceId) parts.push(`${stationEn} (${deviceId})`);
else if (deviceId) parts.push(`设备:${deviceId}`);
else if (stationEn) parts.push(stationEn);
if (attachment) parts.push(attachment);
return parts.join(' | ');
};
const formatLogDetail = (l) => {
if (!l.detail) return '';
if (typeof l.detail === 'string') return l.detail;
try {
return JSON.stringify(l.detail, null, 2);
} catch (e) {
return String(l.detail);
}
};
const formatMoney = (value) => {
const n = Number(value || 0);
return Number.isFinite(n) ? n.toFixed(0) : '0';
};
const HOLDER_NAME_PATTERN = /^[A-Za-z][A-Za-z .,'()&@/_\-+]*$/;
const validateIcHolderName = (value) => {
const holderName = String(value || '').trim();
if (!holderName) return '请输入持卡人姓名';
if (holderName.length > 24) return '持卡人姓名不能超过 24 个字符';
if (!HOLDER_NAME_PATTERN.test(holderName)) return '持卡人姓名仅支持英文与常用符号';
return '';
};
const icStatusInfo = (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 icStatusColor = (status) => {
const s = String(status || '').toLowerCase();
if (s === 'active') return 'var(--success)';
if (s === 'pending_pickup') return 'var(--warning)';
return 'var(--danger)';
};
const cardOrderCode = (card) => {
const resolved = String(
card?.order_code ||
card?.voucher_code ||
card?.code ||
card?.orderCode ||
card?.card_server_data?.order_code ||
card?.card_server_data?.voucher_code ||
''
).trim();
if (resolved) return resolved;
return String(card?.source || '').trim() ? '现场办卡' : '---';
};
const displayIcCardId = (card) => {
return String(card?.display_card_id || card?.card_id || '---').trim() || '---';
};
const icEventTitle = (event) => {
const map = {
create: '后台建卡',
update: '信息更新',
topup: '余额充值',
open: '现场办卡',
redeem: '线上兑卡',
order_created: '线上购卡',
check: '进出站检查',
activated: '正式启用',
delete: '删除卡片'
};
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
};
const formatIcEventDetail = (event) => {
const detail = event?.detail != null ? event.detail : event;
if (typeof detail === 'string') return detail;
try {
return JSON.stringify(detail || {}, null, 2);
} catch (_) {
return String(detail || '');
}
};
const getStationName = (code) => {
const s = stations.value.find(x => x.code === code);
return s ? (s.name || s.cn_name) : code;
};
const getStationInfo = (code) => {
return stations.value.find(x => x.code === code) || { code, name: code, en_name: '' };
};
const fetchAssetsManifest = async () => {
const data = await requestJson('/api/assets/manifest');
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
assetsManifest.fareTable = data ? (data.fareTable || null) : null;
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
assetsLoaded = true;
2026-06-21 10:00:13 +08:00
assetsFarePreview.headers = [];
assetsFarePreview.rows = [];
if (assetsManifest.fareTable) {
try {
const r = await fetch(assetsFareTableUrl.value);
const text = await r.text();
const name = String(assetsManifest.fareTable || '').toLowerCase();
if (name.endsWith('.json')) {
const obj = parseJsonSafe(text);
if (Array.isArray(obj)) {
const headers = [];
for (const it of obj) {
if (it && typeof it === 'object' && !Array.isArray(it)) {
for (const k of Object.keys(it)) if (!headers.includes(k)) headers.push(k);
}
}
assetsFarePreview.headers = headers;
assetsFarePreview.rows = obj.filter(x => x && typeof x === 'object' && !Array.isArray(x));
} else if (obj && typeof obj === 'object' && Array.isArray(obj.rows) && Array.isArray(obj.headers)) {
assetsFarePreview.headers = obj.headers;
const rows = [];
for (const row of obj.rows) {
const r2 = {};
for (let i = 0; i < obj.headers.length; i++) r2[obj.headers[i]] = row[i];
rows.push(r2);
}
assetsFarePreview.rows = rows;
}
} else if (name.endsWith('.csv')) {
const parsed = parseCsvSimple(text);
assetsFarePreview.headers = parsed.headers;
assetsFarePreview.rows = parsed.rows;
}
} catch (e) {}
}
markSynced();
2026-06-21 10:00:13 +08:00
};
const uploadAssetFile = async (url, file) => {
const fd = new FormData();
fd.append('file', file);
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
await fetchAssetsManifest();
};
const uploadRouteMap = async (ev) => {
const f = ev && ev.target && ev.target.files && ev.target.files[0];
if (!f) return;
ev.target.value = '';
await uploadAssetFile('/api/assets/route-map', f);
};
const uploadFareTable = async (ev) => {
const f = ev && ev.target && ev.target.files && ev.target.files[0];
if (!f) return;
ev.target.value = '';
await uploadAssetFile('/api/assets/fare-table', f);
};
const deleteRouteMap = async () => {
await requestJson('/api/assets/route-map', { method: 'DELETE' }, { expectOk: true });
await fetchAssetsManifest();
};
const deleteFareTable = async () => {
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
await fetchAssetsManifest();
};
const transferIndex = computed(() => {
const out = new Map();
const inn = new Map();
for (const s of (stations.value || [])) {
const from = String(s?.code || '').trim();
if (!from) continue;
if (!s?.transfer_enabled) continue;
const list = Array.isArray(s.transfer_to) ? s.transfer_to : [];
for (const t of list) {
const to = String((t && typeof t === 'object') ? (t.code || t.station || t.id || '') : t).trim();
if (!to || to === from) continue;
const o = out.get(from) || [];
o.push(to);
out.set(from, o);
const i = inn.get(to) || [];
i.push(from);
inn.set(to, i);
}
}
const uniq = (arr) => Array.from(new Set((arr || []).filter(Boolean)));
const getOut = (code) => uniq(out.get(code));
const getIn = (code) => uniq(inn.get(code));
return { getOut, getIn };
});
const stationLinesIndex = computed(() => {
const map = new Map();
for (const li of (lines.value || [])) {
const id = String(li?.id || '').trim();
const color = li?.color || '#93a2b7';
if (!id) continue;
const arr = Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : []);
for (const sc of arr) {
const code = String(sc || '').trim();
if (!code) continue;
const cur = map.get(code) || [];
cur.push({ id, color });
map.set(code, cur);
}
}
const uniqById = (arr) => {
const seen = new Set();
const out = [];
for (const x of (arr || [])) {
if (!x?.id || seen.has(x.id)) continue;
seen.add(x.id);
out.push({ id: x.id, color: x.color || '#93a2b7' });
}
return out;
};
return {
getLines: (code) => uniqById(map.get(String(code || '').trim()) || [])
};
});
const isTransferStation = (code) => {
const c = String(code || '').trim();
if (!c) return false;
const { getOut, getIn } = transferIndex.value;
return (getOut(c).length + getIn(c).length) > 0;
};
const getTransferLineBadges = (code) => {
const c = String(code || '').trim();
if (!c) return [];
const { getOut, getIn } = transferIndex.value;
const partners = [...getOut(c), ...getIn(c)];
const lineId = selectedLine.value?.id;
const badges = [];
const seen = new Set();
for (const p of partners) {
for (const li of stationLinesIndex.value.getLines(p)) {
if (lineId && li.id === lineId) continue;
if (seen.has(li.id)) continue;
seen.add(li.id);
badges.push(li);
}
}
return badges.slice(0, 6);
};
const getTransferTitleSuffix = (code) => {
const c = String(code || '').trim();
if (!c) return '';
const { getOut, getIn } = transferIndex.value;
const out = getOut(c);
const inn = getIn(c);
if (out.length === 0 && inn.length === 0) return '';
const fmt = (arr) => arr.map(x => `${getStationName(x)} (${x})`).join(', ');
let s = '\nXFER';
if (out.length > 0) s += `\nTo: ${fmt(out)}`;
if (inn.length > 0) s += `\nFrom: ${fmt(inn)}`;
return s;
};
const viewTicketDetails = async (ticket) => {
selectedTicket.value = null;
showTicketModal.value = true;
try {
const res = await requestJson(`/api/tickets/${encodeURIComponent(ticket.ticket_id)}`);
if (res && res.ok) selectedTicket.value = res;
} catch (e) {
console.error(e);
}
};
const closeTicketModal = () => {
showTicketModal.value = false;
selectedTicket.value = null;
};
// --- Drag & Drop Logic ---
const onStationDragStart = (index) => {
if (fareMode.value) return; // Disable drag in fare mode
draggingStationIndex.value = index;
};
const onStationDragOver = (index) => {
// Only swap if we are actually dragging
if (draggingStationIndex.value === null) return;
if (draggingStationIndex.value === index) return;
// Swap in local array for visual feedback
const list = selectedLine.value.stations;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(index, 0, temp);
draggingStationIndex.value = index;
};
const onStationDrop = async () => {
if (draggingStationIndex.value === null) return;
try {
await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
} catch (e) {
alert(`保存站序失败:${e?.message || String(e)}`);
await fetchData();
}
draggingStationIndex.value = null;
};
const startLineViewportPan = (event) => {
const viewport = visualLineViewport.value;
if (!viewport) return;
if (event.button !== 0) return;
if (event.target && event.target.closest('.station-node')) return;
lineViewportPan.active = true;
lineViewportPan.moved = false;
lineViewportPan.startX = event.clientX;
lineViewportPan.startY = event.clientY;
lineViewportPan.scrollLeft = viewport.scrollLeft;
lineViewportPan.scrollTop = viewport.scrollTop;
};
const moveLineViewportPan = (event) => {
if (!lineViewportPan.active) return;
const viewport = visualLineViewport.value;
if (!viewport) return;
const deltaX = event.clientX - lineViewportPan.startX;
const deltaY = event.clientY - lineViewportPan.startY;
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
lineViewportPan.moved = true;
}
viewport.scrollLeft = lineViewportPan.scrollLeft - deltaX;
viewport.scrollTop = lineViewportPan.scrollTop - deltaY;
};
const endLineViewportPan = () => {
lineViewportPan.active = false;
};
2026-06-21 10:00:13 +08:00
// --- Order Management ---
const fetchOrders = async () => {
if (loadingState.orders) return;
loadingState.orders = true;
2026-06-21 10:00:13 +08:00
try {
const res = await requestJson('/api/orders');
if (res && res.ok) {
orders.value = res.orders || [];
orderDataLoaded = true;
markSynced();
}
} catch (e) {
console.error(e);
} finally {
loadingState.orders = false;
}
2026-06-21 10:00:13 +08:00
};
const fetchIcCards = async (keepSelection = true) => {
if (loadingState.iccards) return;
loadingState.iccards = true;
const requestSeq = ++icListRequestSeq;
2026-06-21 10:00:13 +08:00
const sp = new URLSearchParams();
if (icCardSearch.value.trim()) sp.set('q', icCardSearch.value.trim());
try {
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
if (requestSeq !== icListRequestSeq) return;
icCards.value = res?.cards || [];
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
await loadIcCard(icSelectedId.value);
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
icSelectedId.value = '';
icSelectedCard.value = null;
icSelectedEvents.value = [];
}
markSynced();
} finally {
if (requestSeq === icListRequestSeq) {
loadingState.iccards = false;
}
2026-06-21 10:00:13 +08:00
}
};
const loadIcCard = async (id) => {
const requestSeq = ++icDetailRequestSeq;
2026-06-21 10:00:13 +08:00
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
if (requestSeq !== icDetailRequestSeq) return;
2026-06-21 10:00:13 +08:00
const card = res?.card || null;
icSelectedId.value = id;
icSelectedCard.value = card;
icSelectedEvents.value = res?.events || [];
icDetailForm.holder_name = card?.holder_name || '';
icDetailForm.status = card?.status || 'active';
markSynced();
2026-06-21 10:00:13 +08:00
};
const syncSelectedIcCard = async () => {
if (!icSelectedId.value) return;
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(icSelectedId.value)}`);
const card = res?.card || null;
if (!card) return;
icSelectedCard.value = card;
icSelectedEvents.value = res?.events || [];
};
const stopIcCardSync = () => {
if (icCardSyncTimer) {
clearInterval(icCardSyncTimer);
icCardSyncTimer = null;
}
};
const startIcCardSync = () => {
stopIcCardSync();
if (currentView.value !== 'iccards') return;
icCardSyncTimer = setInterval(() => {
if (document.hidden || icCardSyncBusy) return;
icCardSyncBusy = true;
Promise.all([
fetchIcCards(false).catch(console.error),
icSelectedId.value ? syncSelectedIcCard().catch(console.error) : Promise.resolve()
]).finally(() => {
icCardSyncBusy = false;
});
}, 5000);
2026-06-21 10:00:13 +08:00
};
const createIcCard = async () => {
const holder_name = String(icCreateForm.holder_name || '').trim();
const balance = Number(icCreateForm.balance || 0) || 0;
const holderError = validateIcHolderName(holder_name);
if (holderError) return alert(holderError);
await runMutation(async () => {
const res = await requestJson('/api/ic-cards', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ holder_name, balance })
}, { expectOk: true });
icCreateForm.holder_name = '';
icCreateForm.balance = 50;
await fetchIcCards(false);
if (res?.card_id) await loadIcCard(res.card_id);
}, { successMessage: 'IC 卡已创建' });
};
const saveIcCard = async () => {
if (!icSelectedId.value) return alert('请先选择一张 IC 卡');
const payload = {
holder_name: String(icDetailForm.holder_name || '').trim(),
status: icDetailForm.status || 'active'
};
const holderError = validateIcHolderName(payload.holder_name);
if (holderError) return alert(holderError);
await runMutation(async () => {
await requestJson(`/api/ic-cards/${encodeURIComponent(icSelectedId.value)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
}, { expectOk: true });
await fetchIcCards(false);
await loadIcCard(icSelectedId.value);
}, { successMessage: '已保存 IC 卡信息' });
};
const topupIcCard = async () => {
if (!icSelectedId.value) return alert('请先选择一张 IC 卡');
const raw = await appDialog.prompt({
title: 'IC 卡充值',
message: `请输入给 ${icSelectedId.value} 充值的金额`,
defaultValue: '50',
placeholder: '请输入充值金额',
confirmText: '确认充值'
});
if (raw == null) return;
const amount = Number(raw);
if (!(amount > 0)) return alert('充值金额必须大于 0');
await runMutation(async () => {
await requestJson(`/api/ic-cards/${encodeURIComponent(icSelectedId.value)}/topup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount })
}, { expectOk: true });
await fetchIcCards(false);
await loadIcCard(icSelectedId.value);
}, { successMessage: '充值成功' });
};
const deleteIcCard = async () => {
if (!icSelectedId.value || !icSelectedCard.value) return alert('请先选择一张 IC 卡');
if (!await appDialog.confirm({
title: '删除 IC 卡',
message: `确定删除 IC 卡 ${icSelectedId.value} 吗?此操作不可撤销。`,
confirmText: '确认删除'
})) return;
const removingId = icSelectedId.value;
await runMutation(async () => {
await requestJson(`/api/ic-cards/${encodeURIComponent(removingId)}`, { method: 'DELETE' }, { expectOk: true });
icSelectedId.value = '';
icSelectedCard.value = null;
icSelectedEvents.value = [];
await fetchIcCards(false);
}, { successMessage: 'IC 卡已删除' });
};
const buildLogsUrl = () => {
const sp = new URLSearchParams();
sp.set('max', String(logMax.value || 200));
if (logCategory.value) sp.set('category', logCategory.value);
if (logTypeFilter.value) sp.set('type', logTypeFilter.value);
if (logQuery.value) sp.set('q', logQuery.value);
return `/api/logs?${sp.toString()}`;
};
const fetchLogs = async () => {
if (logLoading.value) return;
2026-06-21 10:00:13 +08:00
logLoading.value = true;
try {
const res = await requestJson(buildLogsUrl());
if (res && res.ok) {
logs.value = res.logs || [];
logDataLoaded = true;
markSynced();
}
2026-06-21 10:00:13 +08:00
} catch (e) {
console.error(e);
} finally {
logLoading.value = false;
}
};
const deleteOrder = async (code) => {
if (!await appDialog.confirm({
title: '删除凭证',
message: `确定删除凭证 ${code} 吗?`,
confirmText: '确认删除'
})) return;
await runMutation(async () => {
await requestJson(`/api/orders/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
await fetchOrders();
});
};
const updateLineInfo = async () => {
if (!selectedLine.value) return;
await runMutation(async () => {
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(selectedLine.value)
}, { expectOk: true });
});
};
const getFareText = (idx) => {
if (!selectedLine.value || !selectedLine.value.stations) return '';
const s1 = selectedLine.value.stations[idx];
const s2 = selectedLine.value.stations[idx + 1];
if (!s1 || !s2) return '';
const f = fares.value.find(x => (x.from === s1 && x.to === s2) || (x.from === s2 && x.to === s1));
if (!f) return '';
const reg = f.cost_regular ?? f.cost ?? 0;
const exp = f.cost_express ?? f.cost ?? 0;
return `¥${reg} / ¥${exp}`;
};
const fetchCoreData = async ({ force = false } = {}) => {
if (loadingState.core) return;
if (coreLoaded && !force) return;
loadingState.core = true;
2026-06-21 10:00:13 +08:00
try {
const safeFetch = (url, defaultVal) => requestJson(url).catch((e) => {
2026-06-21 10:00:13 +08:00
console.error(`Fetch failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return defaultVal;
});
const [s, l, f, c, st] = await Promise.all([
2026-06-21 10:00:13 +08:00
safeFetch('/api/stations', []),
safeFetch('/api/lines', []),
safeFetch('/api/fares', []),
safeFetch('/api/config', {}),
safeFetch('/api/stats/ticket/total', {}).then((d) => d.total || {})
2026-06-21 10:00:13 +08:00
]);
stations.value = s;
lines.value = l;
fares.value = f;
Object.assign(config, c);
Object.assign(stats, st);
if (selectedLine.value) {
const found = lines.value.find((line) => line.id === selectedLine.value.id);
selectedLine.value = found || null;
2026-06-21 10:00:13 +08:00
}
coreLoaded = true;
markSynced();
} catch (e) {
console.error('Failed to fetch core data', e);
} finally {
loadingState.core = false;
}
};
2026-06-21 10:00:13 +08:00
const fetchTicketData = async () => {
if (loadingState.tickets) return;
loadingState.tickets = true;
try {
const res = await requestJson('/api/tickets');
tickets.value = res?.tickets || [];
ticketDataLoaded = true;
markSynced();
2026-06-21 10:00:13 +08:00
} catch (e) {
console.error('Failed to fetch tickets', e);
} finally {
loadingState.tickets = false;
2026-06-21 10:00:13 +08:00
}
};
const loadFareMap = async ({ force = false } = {}) => {
if (fareMapLoading.value) return;
if (fareMapLoaded && !force) return;
2026-06-21 10:00:13 +08:00
fareMapLoading.value = true;
fareMapError.value = '';
try {
const r = await fetch(`/api/public/fares/map/light?t=${Date.now()}`);
const svg = await r.text();
fareMapSvg.value = svg;
fareMapLoaded = true;
markSynced();
2026-06-21 10:00:13 +08:00
} catch (e) {
console.error("Failed to load fare map", e);
fareMapError.value = '加载失败';
} finally {
fareMapLoading.value = false;
}
};
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
await fetchCoreData({ force });
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
if (view === 'iccards') {
await fetchIcCards(true);
if (icSelectedId.value) {
await syncSelectedIcCard().catch(console.error);
}
}
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
};
const fetchData = async () => {
await ensureViewData(currentView.value, { force: true });
};
2026-06-21 10:00:13 +08:00
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
const zoomFareMapReset = () => { fareMapScale.value = 1; };
// --- Management Actions ---
const createStation = async () => {
if (!newStation.code || !newStation.name) return alert('请填写完整');
await runMutation(async () => {
await requestJson('/api/stations', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(newStation) }, { expectOk: true });
showAddStation.value = false;
Object.assign(newStation, { code: '', name: '', en_name: '' });
});
};
const deleteStation = async (code) => {
if (!await appDialog.confirm({
title: '删除站点',
message: '确定从库中删除该站点?这不会影响已存在于线路中的引用,但建议先从线路移除。',
confirmText: '确认删除'
})) return;
await runMutation(async () => {
await requestJson(`/api/stations/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
});
};
const createLine = async () => {
if (!newLine.id) return alert('请填写ID');
await runMutation(async () => {
await requestJson('/api/lines', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(newLine) }, { expectOk: true });
showAddLine.value = false;
Object.assign(newLine, { id: '', name: '', en_name: '', color: '#3366cc' });
});
};
const deleteLine = async (id) => {
if (!await appDialog.confirm({
title: '删除线路',
message: '确定删除此线路?',
confirmText: '确认删除'
})) return;
await runMutation(async () => {
await requestJson(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }, { expectOk: true });
if (selectedLine.value && selectedLine.value.id === id) selectedLine.value = null;
});
};
// --- Visual Editor Logic ---
const selectLine = (l) => {
selectedLine.value = l;
fareMode.value = false;
fareSelection.value = [];
};
const availableStations = computed(() => {
return stations.value;
});
const isStationInLine = (code) => {
return selectedLine.value && (selectedLine.value.stations || []).includes(code);
};
const addStationToLine = async () => {
if (!selectedLine.value) return;
const { code, name, en_name } = newStation;
if (!code || !name) return alert('请填写编号和名称');
await runMutation(async () => {
const existing = stations.value.find(s => s.code === code);
if (!existing) {
await requestJson('/api/stations', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ code, name, en_name })
}, { expectOk: true });
}
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
const newStations = [...(selectedLine.value.stations || [])];
newStations.push(code);
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
Object.assign(newStation, { code: '', name: '', en_name: '' });
});
};
const removeStationFromLine = async (code) => {
if (!selectedLine.value) return;
const newStations = selectedLine.value.stations.filter(s => s !== code);
await updateLineStations(selectedLine.value.id, newStations);
};
const updateLineStations = async (lineId, stationsList, { skipFetchData } = {}) => {
// Need to update the whole line object usually, or a specific endpoint
// Assuming PUT /api/lines/:id updates fields provided
const line = lines.value.find(l => l.id === lineId);
if (!line) return;
const updated = { ...line, stations: stationsList };
const r = await requestJson(`/api/lines/${encodeURIComponent(lineId)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(updated)
}, { expectOk: true });
if (!skipFetchData) await fetchData();
return r;
};
const openStationModal = (code) => {
const s = stations.value.find(x => x.code === code) || { code, name: code, en_name: '' };
stationFormOriginalCode.value = s.code || code;
stationForm.code = s.code || code;
stationForm.name = s.name || s.cn_name || '';
stationForm.en_name = s.en_name || s.enName || '';
stationForm.transfer_enabled = !!s.transfer_enabled;
stationForm.transfer_to = Array.isArray(s.transfer_to) ? [...s.transfer_to] : [];
showStationModal.value = true;
};
const closeStationModal = () => {
showStationModal.value = false;
};
const saveStationSettings = async () => {
if (!stationFormOriginalCode.value) return;
if (!stationForm.code) return alert('请填写站点编号');
const oldCode = String(stationFormOriginalCode.value || '').trim();
const newCode = String(stationForm.code || '').trim();
if (!newCode) return alert('请填写站点编号');
if (newCode !== oldCode) {
if (!await appDialog.confirm({
title: '修改站点编号',
message: `确定将站点编号从 ${oldCode} 修改为 ${newCode} 吗?这会同步更新线路、票价、凭证等引用。`,
confirmText: '确认修改'
})) return;
}
const payload = {
code: newCode,
name: stationForm.name,
en_name: stationForm.en_name,
transfer_enabled: !!stationForm.transfer_enabled,
transfer_to: stationForm.transfer_enabled ? (Array.isArray(stationForm.transfer_to) ? stationForm.transfer_to : []) : []
};
await runMutation(async () => {
await requestJson(`/api/stations/${encodeURIComponent(oldCode)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
showStationModal.value = false;
});
};
const transferTargets = computed(() => {
const fromCode = stationForm.code;
return stations.value
.filter(s => s && s.code && s.code !== fromCode)
.map(s => ({ code: s.code, name: s.name || s.cn_name || s.code, en_name: s.en_name || s.enName || '' }));
});
const openLineModal = () => {
if (!selectedLine.value) return;
lineFormOriginalId.value = selectedLine.value.id;
lineForm.id = selectedLine.value.id || '';
lineForm.name = selectedLine.value.name || '';
lineForm.en_name = selectedLine.value.en_name || '';
lineForm.color = selectedLine.value.color || '#3366cc';
lineForm.stations = Array.isArray(selectedLine.value.stations) ? [...selectedLine.value.stations] : [];
showLineModal.value = true;
};
const closeLineModal = () => {
showLineModal.value = false;
};
const saveLineSettings = async () => {
if (!lineFormOriginalId.value) return;
const oldId = String(lineFormOriginalId.value || '').trim();
const newId = String(lineForm.id || '').trim();
if (!newId) return alert('请填写线路编号');
if (newId !== oldId) {
if (!await appDialog.confirm({
title: '修改线路编号',
message: `确定将线路编号从 ${oldId} 修改为 ${newId} 吗?`,
confirmText: '确认修改'
})) return;
}
const payload = {
id: newId,
name: lineForm.name,
en_name: lineForm.en_name,
color: lineForm.color,
stations: Array.isArray(lineForm.stations) ? [...lineForm.stations] : []
};
await runMutation(async () => {
await requestJson(`/api/lines/${encodeURIComponent(oldId)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
showLineModal.value = false;
const next = lines.value.find(l => l.id === newId);
if (next) selectLine(next);
});
};
const handleStationClick = async (code) => {
if (lineViewportPan.moved) {
lineViewportPan.moved = false;
return;
}
2026-06-21 10:00:13 +08:00
if (stationEditMode.value) {
openStationModal(code);
return;
}
if (fareMode.value) {
// Toggle selection
const idx = fareSelection.value.indexOf(code);
if (idx >= 0) {
fareSelection.value.splice(idx, 1);
} else {
if (fareSelection.value.length < 2) {
fareSelection.value.push(code);
} else {
// Replace the second one or reset? Let's shift
fareSelection.value.shift();
fareSelection.value.push(code);
}
}
// If we have 2, check fare
if (fareSelection.value.length === 2) {
checkAndOpenFareModal();
}
} else {
// Normal mode: Ask to delete?
if (await appDialog.confirm({
title: '移除站点',
message: `从线路 ${selectedLine.value.id} 中移除站点 ${getStationName(code)}`,
confirmText: '确认移除'
})) {
await removeStationFromLine(code);
}
}
};
watch(fareMode, (v) => {
if (v) stationEditMode.value = false;
});
watch(stationEditMode, (v) => {
if (v) {
fareMode.value = false;
fareSelection.value = [];
}
});
watch(icCardSearch, () => {
window.clearTimeout(icCardSearch._timer);
icCardSearch._timer = window.setTimeout(() => {
fetchIcCards(false).catch(console.error);
}, 240);
});
const isStationSelected = (code) => {
return fareSelection.value.includes(code);
};
const checkAndOpenFareModal = () => {
const [from, to] = fareSelection.value;
// Find existing fare
// Fare direction is usually bidirectional or defined one way. Let's check both.
let f = fares.value.find(x => (x.from === from && x.to === to) || (x.from === to && x.to === from));
if (f) {
currentFare.exists = true;
currentFare.cost_regular = f.cost_regular || f.cost || 0;
currentFare.cost_express = f.cost_express || f.cost || 0;
} else {
currentFare.exists = false;
currentFare.cost_regular = 0;
currentFare.cost_express = 0;
}
showFareModal.value = true;
};
const closeFareModal = () => {
showFareModal.value = false;
fareSelection.value = [];
};
const saveCurrentFare = async () => {
const [from, to] = fareSelection.value;
await runMutation(async () => {
if (selectedLine.value) {
const stations = selectedLine.value.stations || [];
const idx1 = stations.indexOf(from);
const idx2 = stations.indexOf(to);
if (idx1 !== -1 && idx2 !== -1) {
const start = Math.min(idx1, idx2);
const end = Math.max(idx1, idx2);
if (end - start > 1) {
if (!await appDialog.confirm({
title: '区间票价应用',
message: `检测到所选站点间有 ${end - start - 1} 个中间站,是否将此票价应用到该区间内的每一段?`,
confirmText: '应用到整段',
cancelText: '仅保存当前区间'
})) {
await submitFare(from, to);
} else {
for (let k = start; k < end; k++) {
await submitFare(stations[k], stations[k+1]);
}
}
} else {
await submitFare(from, to);
}
}
}
closeFareModal();
});
};
const submitFare = async (from, to) => {
const payload = {
from, to,
cost_regular: currentFare.cost_regular,
cost_express: currentFare.cost_express
};
await requestJson('/api/fares', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
};
const deleteCurrentFare = async () => {
const [from, to] = fareSelection.value;
await runMutation(async () => {
await requestJson('/api/fares', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ from, to }) }, { expectOk: true });
closeFareModal();
});
};
const saveConfig = async () => {
await runMutation(async () => {
await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true });
}, { successMessage: '保存成功' });
};
const exportData = () => {
window.open('/api/export', '_blank');
};
// Socket Listeners
// #region debug-point socket-runtime-admin
socket.on('connect', () => {
connected.value = true;
reportSocketRuntime('socket_connect');
});
socket.on('disconnect', (reason) => {
connected.value = false;
reportSocketRuntime('socket_disconnect', { reason: reason || '' });
});
socket.on('connect_error', (error) => {
reportSocketRuntime('socket_connect_error', {
message: error?.message || '',
description: error?.description || '',
context: error?.context || null
});
});
if (socket.io) {
socket.io.on('reconnect_attempt', (attempt) => {
reportSocketRuntime('socket_reconnect_attempt', { attempt: Number(attempt) || 0 });
});
socket.io.on('reconnect_error', (error) => {
reportSocketRuntime('socket_reconnect_error', {
message: error?.message || '',
description: error?.description || ''
});
});
socket.io.on('reconnect_failed', () => {
reportSocketRuntime('socket_reconnect_failed');
});
}
// #endregion
2026-06-21 10:00:13 +08:00
socket.on('stations:updated', (data) => {
stations.value = data;
// Refresh map when stations change
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
2026-06-21 10:00:13 +08:00
});
socket.on('lines:updated', (data) => {
lines.value = data;
coreLoaded = true;
2026-06-21 10:00:13 +08:00
// Update selectedLine reference if it exists
if (selectedLine.value) {
const updated = data.find(l => l.id === selectedLine.value.id);
if (updated) {
selectedLine.value = updated;
} else {
selectedLine.value = null; // Line was deleted
}
}
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
2026-06-21 10:00:13 +08:00
});
socket.on('fares:updated', (data) => {
fares.value = data;
coreLoaded = true;
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('config:updated', (data) => {
Object.assign(config, data);
coreLoaded = true;
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
2026-06-21 10:00:13 +08:00
});
socket.on('stats:ticket:updated', (item) => {
stats.sold_tickets += item.sold_tickets;
stats.revenue += item.revenue;
});
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
socket.on('ic-card:check', () => {
fetchIcCards(false).catch(console.error);
syncSelectedIcCard().catch(console.error);
});
socket.on('ic-card:topup', () => {
fetchIcCards(false).catch(console.error);
syncSelectedIcCard().catch(console.error);
});
socket.on('ic-card:sync', () => {
fetchIcCards(false).catch(console.error);
syncSelectedIcCard().catch(console.error);
});
watch(currentView, (v) => {
sidebarOpen.value = false;
if (v === 'iccards') {
startIcCardSync();
} else {
stopIcCardSync();
}
ensureViewData(v).catch(console.error);
2026-06-21 10:00:13 +08:00
const sp = new URLSearchParams(location.search);
if (v === 'dashboard') sp.delete('view');
else sp.set('view', v);
const q = sp.toString();
const target = `${location.pathname}${q ? `?${q}` : ''}`;
history.replaceState(null, '', target);
});
// Initial Load
onMounted(() => {
ensureViewData(currentView.value, { force: true }).catch(console.error);
2026-06-21 10:00:13 +08:00
if (currentView.value === 'iccards') {
startIcCardSync();
}
appMouseupHandler = async () => {
endLineViewportPan();
2026-06-21 10:00:13 +08:00
if (draggingStationIndex.value !== null) {
if (selectedLine.value) {
try {
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ...(lines.value.find(l => l.id === selectedLine.value.id) || selectedLine.value), stations: selectedLine.value.stations })
}, { expectOk: true });
await fetchData();
} catch (e) {
alert(`保存站序失败:${e?.message || String(e)}`);
await fetchData();
}
}
draggingStationIndex.value = null;
}
};
window.addEventListener('mouseup', appMouseupHandler);
2026-06-21 10:00:13 +08:00
});
onUnmounted(() => {
stopIcCardSync();
if (appMouseupHandler) {
window.removeEventListener('mouseup', appMouseupHandler);
}
2026-06-21 10:00:13 +08:00
});
// Computed
const recentLogs = computed(() => logs.value);
const orderList = computed(() => orders.value);
const lineEditorSvgWidth = computed(() => {
const count = Array.isArray(selectedLine.value?.stations) ? selectedLine.value.stations.length : 0;
return Math.max(960, 100 + Math.max(0, count - 1) * 120 + 120);
});
const lastSyncText = computed(() => lastSyncAt.value ? formatTime(lastSyncAt.value) : '尚未同步');
const isViewBusy = computed(() => {
if (loadingState.core) return true;
if (currentView.value === 'tickets') return loadingState.tickets;
if (currentView.value === 'vouchers') return loadingState.orders;
if (currentView.value === 'logs') return logLoading.value;
if (currentView.value === 'iccards') return loadingState.iccards;
if (currentView.value === 'faremap') return fareMapLoading.value;
return false;
});
const currentViewSummary = computed(() => {
const map = {
dashboard: `已同步 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
management: `当前可编辑 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
faremap: fareMapLoading.value ? '票价图正在生成中' : '可导出当前铁路票价图',
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
settings: '可维护优惠活动与导出数据',
logs: `当前筛选结果 ${logs.value.length} 条日志`
};
return map[currentView.value] || '后台模块已就绪';
});
2026-06-21 10:00:13 +08:00
const icCardStats = computed(() => ({
total: icCards.value.length,
pending: icCards.value.filter((card) => card.status === 'pending_pickup').length,
active: icCards.value.filter((card) => card.status === 'active').length,
balance: icCards.value.reduce((sum, card) => sum + (Number(card.balance || 0) || 0), 0)
}));
const ticketList = computed(() => {
if (!ticketSearch.value) return tickets.value.slice(0, 50);
const q = ticketSearch.value.toLowerCase();
return tickets.value.filter(t =>
t.ticket_id.toLowerCase().includes(q) ||
(t.start && t.start.toLowerCase().includes(q)) ||
(t.terminal && t.terminal.toLowerCase().includes(q))
).slice(0, 50);
});
const exportFareMap = () => {
const svgData = fareMapSvg.value;
if (!svgData) return alert('地图尚未加载');
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const img = new Image();
// Extract width/height from SVG string or default
const matchW = svgData.match(/width="([\d.]+)"/);
const matchH = svgData.match(/height="([\d.]+)"/);
const w = matchW ? Number(matchW[1]) : 1000;
const h = matchH ? Number(matchH[1]) : 1000;
const scale = 3;
canvas.width = Math.max(1, Math.round(w * scale));
canvas.height = Math.max(1, Math.round(h * scale));
const blob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'});
const url = URL.createObjectURL(blob);
img.onload = () => {
ctx.setTransform(scale, 0, 0, scale, 0, 0);
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, w, h);
ctx.drawImage(img, 0, 0);
URL.revokeObjectURL(url);
const pngUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = pngUrl;
a.download = 'fare-map.png';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
img.src = url;
};
return {
currentView, viewTitle, connected, sidebarOpen,
loadingState, isViewBusy, lastSyncText, currentViewSummary,
2026-06-21 10:00:13 +08:00
stations, lines, fares, stats, config, recentLogs, ticketList,
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
orders, orderList, fetchOrders, deleteOrder,
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
// Management
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
2026-06-21 10:00:13 +08:00
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
isStationInLine, addStationToLine, removeStationFromLine,
handleStationClick, isStationSelected,
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
// Tickets
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventExtra, formatLogType, formatTrainType,
saveCurrentFare, deleteCurrentFare, closeFareModal,
saveConfig, exportData, exportFareMap,
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
isTransferStation, getTransferTitleSuffix, getTransferLineBadges
};
}
}).mount('#app');