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);
|
2026-06-21 11:21:09 +08:00
|
|
|
|
// Prefer polling first so admin remains connected even when the proxy
|
|
|
|
|
|
// does not support WebSocket upgrades reliably.
|
|
|
|
|
|
const socket = io({ transports: ['polling', 'websocket'] });
|
2026-06-21 15:39:59 +08:00
|
|
|
|
// #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;
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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);
|
2026-06-21 11:21:09 +08:00
|
|
|
|
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))
|
|
|
|
|
|
};
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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);
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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) {}
|
|
|
|
|
|
}
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-21 11:21:09 +08:00
|
|
|
|
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 () => {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
if (loadingState.orders) return;
|
|
|
|
|
|
loadingState.orders = true;
|
2026-06-21 10:00:13 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const res = await requestJson('/api/orders');
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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) => {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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());
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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) => {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
const requestSeq = ++icDetailRequestSeq;
|
2026-06-21 10:00:13 +08:00
|
|
|
|
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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';
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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(() => {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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 () => {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
if (logLoading.value) return;
|
2026-06-21 10:00:13 +08:00
|
|
|
|
logLoading.value = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await requestJson(buildLogsUrl());
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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}`;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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 {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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;
|
|
|
|
|
|
});
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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', {}),
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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) {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
const found = lines.value.find((line) => line.id === selectedLine.value.id);
|
|
|
|
|
|
selectedLine.value = found || null;
|
2026-06-21 10:00:13 +08:00
|
|
|
|
}
|
2026-06-21 10:37:25 +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
|
|
|
|
|
2026-06-21 10:37:25 +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) {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
console.error('Failed to fetch tickets', e);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
loadingState.tickets = false;
|
2026-06-21 10:00:13 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-21 10:37:25 +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;
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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) => {
|
2026-06-21 11:21:09 +08:00
|
|
|
|
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
|
2026-06-21 15:39:59 +08:00
|
|
|
|
// #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
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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;
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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;
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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(() => {
|
2026-06-21 10:37:25 +08:00
|
|
|
|
ensureViewData(currentView.value, { force: true }).catch(console.error);
|
2026-06-21 10:00:13 +08:00
|
|
|
|
if (currentView.value === 'iccards') {
|
|
|
|
|
|
startIcCardSync();
|
|
|
|
|
|
}
|
2026-06-21 10:37:25 +08:00
|
|
|
|
appMouseupHandler = async () => {
|
2026-06-21 11:21:09 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-21 10:37:25 +08:00
|
|
|
|
};
|
|
|
|
|
|
window.addEventListener('mouseup', appMouseupHandler);
|
2026-06-21 10:00:13 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
stopIcCardSync();
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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);
|
2026-06-21 11:21:09 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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,
|
2026-06-21 10:37:25 +08:00
|
|
|
|
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,
|
2026-06-21 11:21:09 +08:00
|
|
|
|
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');
|