Files
FSE-Ticket.sys/web/ticket-route.js
T
Henry_Du 6605c67f23 feat(web/route): 支持电脑端鼠标平移画布 & 优化动态SVG宽度
1. 修复电脑端画布无法拖拽滑动问题:
   - 新增 onPanStart/onPanMove/onPanEnd 逻辑
   - 绑定鼠标事件到 .visual-line-container,实现按住左键平移 scrollLeft
2. 修复长线路无法完全滚动的问题:
   - 将固定 100% 的宽度替换为动态宽度计算:Math.max(100, 50 + stations.length * 120 + 50) + px
   - 确保原生 overflow-x: auto 能正确感知超出容器的内部站点
3. 更新静态资源版本号避免缓存
2026-08-07 23:32:05 +08:00

1079 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
try {
if (localStorage.getItem('tm_session') !== 'ok') {
const next = encodeURIComponent(location.pathname + location.search);
location.href = `/login.html?next=${next}`;
}
} catch (_) { }
})();
const { createApp, ref, onMounted, 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);
// Keep the legacy route console usable behind proxies that only allow polling.
const socket = io({ transports: ['polling', 'websocket'], 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 formatTicketEventUnified = (ev) => {
const type = getTicketEventType(ev);
const action = getTicketEventAction(ev);
let deviceId = String(ev?.device || ev?.device_id || ev?.detail?.device || ev?.detail?.device_id || ev?.["设备编号"] || ev?.["机器ID"] || '---').trim();
const dStr = deviceId.toLowerCase();
if (dStr === 'gate') {
deviceId = '闸机';
} else if (dStr === 'refill_machine') {
deviceId = '充值机';
} else if (dStr === 'ticket_machine' || dStr === 'ticket_vending_machine') {
deviceId = '售票机';
} else if (dStr === 'unknown' || dStr === '---' || dStr === '') {
if (type === 'entry' || action === 'entry' || type === 'exit' || action === 'exit' || type === 'check') {
deviceId = '闸机';
} else {
deviceId = '售票机';
}
}
const timeStr = formatTime(ev?.ts || ev?.["时间戳"]);
if (type === 'sale' || type === '售票') {
const cost = ev?.cost ?? ev?.price ?? ev?.amount ?? ev?.["票价"] ?? ev?.["售票额"] ?? '0';
return `机器ID: ${deviceId};票价: ${cost};时间: ${timeStr}`;
}
if (type === 'entry' || action === 'entry') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code);
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
}
if (type === 'exit' || action === 'exit') {
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code);
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
}
// fallback
const code = ev?.station_code || ev?.["站点编号"] || '---';
const name = ev?.station_name || ev?.["站点中文"] || ev?.["发生站"] || (typeof getStationName === 'function' && code !== '---' ? getStationName(code) : code);
return `机器ID: ${deviceId};站点编号: ${code};站点中文: ${name};时间: ${timeStr}`;
};
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 dragActiveSvg = ref(null);
const computeStationIndexFromClientXY = (clientX, clientY) => {
const svgEl = dragActiveSvg.value;
if (!svgEl || !selectedLine.value) return -1;
const pt = svgEl.createSVGPoint();
pt.x = clientX;
pt.y = clientY;
const ctm = svgEl.getScreenCTM();
if (!ctm) return -1;
const loc = pt.matrixTransform(ctm.inverse());
return Math.max(0, Math.min(
(selectedLine.value.stations?.length || 1) - 1,
Math.round((loc.x - 50) / 120)
));
};
const swapStationByIndex = (targetIndex) => {
if (draggingStationIndex.value === null) return false;
if (targetIndex < 0) return false;
if (draggingStationIndex.value === targetIndex) return false;
const list = selectedLine.value.stations;
if (!list) return false;
const temp = list[draggingStationIndex.value];
list.splice(draggingStationIndex.value, 1);
list.splice(targetIndex, 0, temp);
draggingStationIndex.value = targetIndex;
return true;
};
const saveDragResult = 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;
dragActiveSvg.value = null;
};
/* 鼠标事件 */
const onStationDragStart = (index, event) => {
if (fareMode.value) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const onStationDragOver = (index) => {
swapStationByIndex(index);
};
const onStationDrop = async () => {
await saveDragResult();
};
const globalMouseMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
const targetIndex = computeStationIndexFromClientXY(e.clientX, e.clientY);
swapStationByIndex(targetIndex);
};
const globalMouseUpHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
};
/* 触摸事件 - 移动端拖拽支持 */
const onStationTouchStart = (index, event) => {
if (fareMode.value) return;
if (!event.touches || event.touches.length !== 1) return;
draggingStationIndex.value = index;
const svgEl = (event.currentTarget && event.currentTarget.closest)
? event.currentTarget.closest('svg')
: null;
dragActiveSvg.value = svgEl || document.querySelector('.visual-line-container svg');
};
const globalTouchMoveHandler = (e) => {
if (draggingStationIndex.value === null) return;
if (!e.touches || e.touches.length !== 1) return;
if (e.cancelable) e.preventDefault();
const t = e.touches[0];
const targetIndex = computeStationIndexFromClientXY(t.clientX, t.clientY);
swapStationByIndex(targetIndex);
};
const globalTouchEndHandler = async () => {
if (draggingStationIndex.value === null) return;
await saveDragResult();
};
/* 画布平移*/
const visualLineContainer = ref(null);
const isPanning = ref(false);
const panStartX = ref(0);
const panScrollLeft = ref(0);
const onPanStart = (e) => {
if (e.button !== 0) return;
if (e.target.closest && e.target.closest('.station-node')) return;
isPanning.value = true;
panStartX.value = e.clientX;
if (visualLineContainer.value) {
panScrollLeft.value = visualLineContainer.value.scrollLeft;
}
};
const onPanMove = (e) => {
if (!isPanning.value || !visualLineContainer.value) return;
e.preventDefault();
const dx = e.clientX - panStartX.value;
visualLineContainer.value.scrollLeft = panScrollLeft.value - dx;
};
const onPanEnd = () => {
isPanning.value = false;
};
/* 订单管理 */
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('mousemove', globalMouseMoveHandler);
window.addEventListener('mouseup', globalMouseUpHandler);
window.addEventListener('touchmove', globalTouchMoveHandler, { passive: false });
window.addEventListener('touchend', globalTouchEndHandler);
window.addEventListener('touchcancel', globalTouchEndHandler);
});
/* 计算 */
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,
onStationTouchStart,
visualLineContainer, isPanning, onPanStart, onPanMove, onPanEnd,
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
/* 订单 */
fetchOrders, deleteOrder,
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventUnified, 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');