(() => { 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, computed, reactive, watch } = Vue; createApp({ setup() { const currentView = ref('management'); const sidebarOpen = ref(false); const viewTitle = computed(() => { const map = { dashboard: '仪表盘', management: '线路与票价管理', tickets: '车票记录', settings: '系统设置' }; return map[currentView.value] || '线路规划系统'; }); const connected = ref(false); const socket = io({ transports: ['websocket'], upgrade: false, timeout: 20000 }); 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 orders = ref([]); const showAddLine = ref(false); const showAddStation = ref(false); const newLine = reactive({ id: '', name: '', en_name: '', color: '#3366cc' }); const newStation = reactive({ code: '', name: '', en_name: '' }); const showTicketModal = ref(false); const selectedTicket = ref(null); 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 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: [] }); 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 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 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; } }; 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 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 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; }; /* 拖动调整站序 */ const onStationDragStart = (index) => { if (fareMode.value) return; draggingStationIndex.value = index; }; const onStationDragOver = (index) => { if (draggingStationIndex.value === null) return; if (draggingStationIndex.value === index) return; 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 fetchOrders = async () => { try { const res = await requestJson('/api/orders'); if (res && res.ok) orders.value = res.orders; } catch (e) { console.error(e); } }; 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 fetchData = async () => { 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 safeFetchList = (url, key) => requestJson(url).then(d => d?.[key] || []).catch(e => { console.error(`Fetch list failed for ${url}`, e); lastActionError.value = e?.message || String(e); return []; }); const [s, l, f, c, t, lg, st, ord] = await Promise.all([ safeFetch('/api/stations', []), safeFetch('/api/lines', []), safeFetch('/api/fares', []), safeFetch('/api/config', {}), safeFetchList('/api/tickets', 'tickets'), safeFetchList('/api/logs?max=50', 'logs'), safeFetch('/api/stats/ticket/total', {}).then(d => d.total || {}), safeFetchList('/api/orders', 'orders') ]); stations.value = s; lines.value = l; fares.value = f; Object.assign(config, c); tickets.value = t; logs.value = lg; Object.assign(stats, st); orders.value = ord; if (selectedLine.value) { const found = lines.value.find(l => l.id === selectedLine.value.id); if (found) selectedLine.value = found; } loadFareMap(); } catch (e) { console.error("Failed to fetch data", e); } }; const loadFareMap = async () => { 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; } catch (e) { console.error("Failed to load fare map", e); fareMapError.value = '加载失败'; } finally { fareMapLoading.value = false; } }; 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; }; 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; }); }; /* 可视化编辑 */ 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 } = {}) => { 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 (stationEditMode.value) { openStationModal(code); return; } if (fareMode.value) { 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 { fareSelection.value.shift(); fareSelection.value.push(code); } } if (fareSelection.value.length === 2) { checkAndOpenFareModal(); } } else { 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 = []; } }); const isStationSelected = (code) => { return fareSelection.value.includes(code); }; const checkAndOpenFareModal = () => { const [from, to] = fareSelection.value; 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连接状态 */ socket.on('connect', () => { connected.value = true; }); socket.on('disconnect', () => { connected.value = false; }); socket.on('stations:updated', (data) => { stations.value = data; loadFareMap(); }); socket.on('lines:updated', (data) => { lines.value = data; if (selectedLine.value) { const updated = data.find(l => l.id === selectedLine.value.id); if (updated) { selectedLine.value = updated; } else { selectedLine.value = null; } } loadFareMap(); }); socket.on('fares:updated', (data) => { fares.value = data; loadFareMap(); }); socket.on('config:updated', (data) => { Object.assign(config, data); loadFareMap(); }); socket.on('stats:ticket:updated', (item) => { stats.sold_tickets += item.sold_tickets; stats.revenue += item.revenue; }); watch(currentView, () => { sidebarOpen.value = false; }); /* 失败 */ onMounted(() => { fetchData(); loadFareMap(); window.addEventListener('mouseup', async () => { 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; } }); }); /* 计算 */ const recentLogs = computed(() => logs.value); const orderList = computed(() => orders.value); 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(); 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, stations, lines, fares, stats, config, recentLogs, ticketList, orders, orderList, fetchOrders, deleteOrder, showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch, /* 管理 */ selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations, 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, /* 订单 */ fetchOrders, deleteOrder, 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');