2026-06-21 10:00:13 +08:00
|
|
|
|
(() => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (localStorage.getItem('tm_session') !== 'ok') {
|
|
|
|
|
|
const next = encodeURIComponent(location.pathname + location.search);
|
|
|
|
|
|
location.href = `/login.html?next=${next}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) { }
|
|
|
|
|
|
})();
|
|
|
|
|
|
|
|
|
|
|
|
const { createApp, ref, onMounted, 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);
|
2026-06-21 11:21:09 +08:00
|
|
|
|
// Keep the legacy route console usable behind proxies that only allow polling.
|
|
|
|
|
|
const socket = io({ transports: ['polling', 'websocket'], timeout: 20000 });
|
2026-06-21 10:00:13 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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');
|