Files
FSE-Ticket.sys/web/ticket-route.js
T
Henry_Du b1cb84f736 feat(管理后台): 新增线路编辑器拖拽平移并修复代理下Socket连接问题
调整socket.io传输顺序优先使用轮询以适配代理服务器,新增可视化线路编辑器拖拽平移功能,修复多处CSS布局问题并更新静态资源缓存版本。
2026-06-21 11:21:09 +08:00

972 lines
41 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 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');