Files
FSE-Ticket.sys/web/index.js
T
Henry_Du b614ff663c chore(web): 移除过时的socket调试与服务器状态监控代码
移除了登录页与后台管理页的服务器状态展示UI、public-status.js脚本引用,删除了index.js中的socket运行时日志上报逻辑与连接状态追踪代码,同时删除了用于排查socket polling 400问题的调试文档。
2026-06-21 16:11:54 +08:00

1589 lines
68 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.
(() => {
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] || '票价图';
});
// Prefer polling first so admin remains connected even when the proxy
// does not support WebSocket upgrades reliably.
const socket = io({ transports: ['polling', 'websocket'] });
// 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);
// 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
});
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();
};
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;
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();
};
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;
};
// --- Order Management ---
const fetchOrders = async () => {
if (loadingState.orders) return;
loadingState.orders = true;
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;
}
};
const fetchIcCards = async (keepSelection = true) => {
if (loadingState.iccards) return;
loadingState.iccards = true;
const requestSeq = ++icListRequestSeq;
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;
}
}
};
const loadIcCard = async (id) => {
const requestSeq = ++icDetailRequestSeq;
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
if (requestSeq !== icDetailRequestSeq) return;
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();
};
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);
};
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;
logLoading.value = true;
try {
const res = await requestJson(buildLogsUrl());
if (res && res.ok) {
logs.value = res.logs || [];
logDataLoaded = true;
markSynced();
}
} 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;
try {
const safeFetch = (url, defaultVal) => requestJson(url).catch((e) => {
console.error(`Fetch failed for ${url}`, e);
lastActionError.value = e?.message || String(e);
return defaultVal;
});
const [s, l, f, c, st] = await Promise.all([
safeFetch('/api/stations', []),
safeFetch('/api/lines', []),
safeFetch('/api/fares', []),
safeFetch('/api/config', {}),
safeFetch('/api/stats/ticket/total', {}).then((d) => d.total || {})
]);
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;
}
coreLoaded = true;
markSynced();
} catch (e) {
console.error('Failed to fetch core data', e);
} finally {
loadingState.core = false;
}
};
const fetchTicketData = async () => {
if (loadingState.tickets) return;
loadingState.tickets = true;
try {
const res = await requestJson('/api/tickets');
tickets.value = res?.tickets || [];
ticketDataLoaded = true;
markSynced();
} catch (e) {
console.error('Failed to fetch tickets', e);
} finally {
loadingState.tickets = false;
}
};
const loadFareMap = async ({ force = false } = {}) => {
if (fareMapLoading.value) return;
if (fareMapLoaded && !force) return;
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();
} 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 });
};
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;
}
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
socket.on('stations:updated', (data) => {
stations.value = data;
// Refresh map when stations change
fareMapLoaded = false;
if (currentView.value === 'faremap') {
loadFareMap({ force: true });
}
});
socket.on('lines:updated', (data) => {
lines.value = data;
coreLoaded = true;
// 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 });
}
});
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 });
}
});
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);
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);
if (currentView.value === 'iccards') {
startIcCardSync();
}
appMouseupHandler = async () => {
endLineViewportPan();
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);
});
onUnmounted(() => {
stopIcCardSync();
if (appMouseupHandler) {
window.removeEventListener('mouseup', appMouseupHandler);
}
});
// 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] || '后台模块已就绪';
});
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, sidebarOpen,
loadingState, isViewBusy, lastSyncText, currentViewSummary,
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,
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');