b1cb84f736
调整socket.io传输顺序优先使用轮询以适配代理服务器,新增可视化线路编辑器拖拽平移功能,修复多处CSS布局问题并更新静态资源缓存版本。
1593 lines
69 KiB
JavaScript
1593 lines
69 KiB
JavaScript
(() => {
|
||
try {
|
||
if (localStorage.getItem('tm_session') !== 'ok') {
|
||
const next = encodeURIComponent(location.pathname + location.search);
|
||
location.href = `/login.html?next=${next}`;
|
||
}
|
||
} catch (_) { }
|
||
})();
|
||
|
||
const { createApp, ref, onMounted, onUnmounted, computed, reactive, watch } = Vue;
|
||
|
||
createApp({
|
||
setup() {
|
||
const detectInitialView = () => {
|
||
const view = new URLSearchParams(location.search).get('view');
|
||
if (view) return view;
|
||
if (location.pathname === '/admin/ic-card' || location.pathname === '/ic-card-admin') return 'iccards';
|
||
return 'dashboard';
|
||
};
|
||
const currentView = ref(detectInitialView());
|
||
const sidebarOpen = ref(false);
|
||
const viewTitle = computed(() => {
|
||
const map = {
|
||
dashboard: '仪表盘',
|
||
management: '线路与票价管理',
|
||
faremap: '票价地图',
|
||
tickets: '车票记录',
|
||
vouchers: '凭证管理',
|
||
iccards: 'IC 卡管理',
|
||
assets: '线路图',
|
||
settings: '系统设置',
|
||
logs: '日志'
|
||
};
|
||
return map[currentView.value] || '票价图';
|
||
});
|
||
|
||
const connected = ref(false);
|
||
// Prefer polling first so admin remains connected even when the proxy
|
||
// does not support WebSocket upgrades reliably.
|
||
const socket = io({ transports: ['polling', 'websocket'] });
|
||
|
||
// Data State
|
||
const stations = ref([]);
|
||
const lines = ref([]);
|
||
const fares = ref([]);
|
||
const tickets = ref([]);
|
||
const stats = reactive({ sold_tickets: 0, revenue: 0 });
|
||
const config = reactive({ api_base: '', promotion: { name: '', discount: 1 } });
|
||
const logs = ref([]);
|
||
const logCategory = ref('');
|
||
const logTypeFilter = ref('');
|
||
const logQuery = ref('');
|
||
const logMax = ref(200);
|
||
const logLoading = ref(false);
|
||
const orders = ref([]);
|
||
const assetsManifest = reactive({ routeMap: null, fareTable: null, updatedAt: null });
|
||
const assetsFarePreview = reactive({ headers: [], rows: [] });
|
||
const assetsRouteMapUrl = ref('');
|
||
const assetsFareTableUrl = ref('');
|
||
const icCards = ref([]);
|
||
const icCardSearch = ref('');
|
||
const icSelectedId = ref('');
|
||
const icSelectedCard = ref(null);
|
||
const icSelectedEvents = ref([]);
|
||
const icCreateForm = reactive({ holder_name: '', balance: 50 });
|
||
const icDetailForm = reactive({ holder_name: '', status: 'active' });
|
||
let icCardSyncTimer = null;
|
||
let icCardSyncBusy = false;
|
||
let icListRequestSeq = 0;
|
||
let icDetailRequestSeq = 0;
|
||
let appMouseupHandler = null;
|
||
let coreLoaded = false;
|
||
let ticketDataLoaded = false;
|
||
let orderDataLoaded = false;
|
||
let logDataLoaded = false;
|
||
let assetsLoaded = false;
|
||
let fareMapLoaded = false;
|
||
const loadingState = reactive({
|
||
core: false,
|
||
tickets: false,
|
||
orders: false,
|
||
logs: false,
|
||
iccards: false
|
||
});
|
||
const lastSyncAt = ref(0);
|
||
|
||
// UI State
|
||
const showAddLine = ref(false);
|
||
const showAddStation = ref(false);
|
||
const newLine = reactive({ id: '', name: '', en_name: '', color: '#3366cc' });
|
||
const newStation = reactive({ code: '', name: '', en_name: '' });
|
||
|
||
// Ticket View State
|
||
const showTicketModal = ref(false);
|
||
const selectedTicket = ref(null);
|
||
|
||
// Management View State
|
||
const selectedLine = ref(null);
|
||
const fareMode = ref(false);
|
||
const stationEditMode = ref(false);
|
||
const fareSelection = ref([]);
|
||
const showFareModal = ref(false);
|
||
const currentFare = reactive({ exists: false, cost_regular: 0, cost_express: 0 });
|
||
const draggingStationIndex = ref(null);
|
||
const visualLineViewport = ref(null);
|
||
const lineViewportPan = reactive({
|
||
active: false,
|
||
startX: 0,
|
||
startY: 0,
|
||
scrollLeft: 0,
|
||
scrollTop: 0,
|
||
moved: false
|
||
});
|
||
const showStationModal = ref(false);
|
||
const stationForm = reactive({ code: '', name: '', en_name: '', transfer_enabled: false, transfer_to: [] });
|
||
const stationFormOriginalCode = ref('');
|
||
const showLineModal = ref(false);
|
||
const lineFormOriginalId = ref('');
|
||
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
|
||
|
||
// Legacy/Other State
|
||
const fareMapSvg = ref('');
|
||
const fareMapScale = ref(1);
|
||
const fareMapLoading = ref(false);
|
||
const fareMapError = ref('');
|
||
const ticketSearch = ref('');
|
||
const lastActionError = ref('');
|
||
const lastActionOkTs = ref(0);
|
||
const mutationBusy = ref(false);
|
||
const appDialog = window.appDialog || {
|
||
alert: (message) => Promise.resolve(window.alert(message)),
|
||
confirm: (message) => Promise.resolve(window.confirm(message)),
|
||
prompt: (message, defaultValue) => Promise.resolve(window.prompt(message, defaultValue))
|
||
};
|
||
const markSynced = () => {
|
||
lastSyncAt.value = Date.now();
|
||
};
|
||
|
||
const buildAssetUrl = (name) => {
|
||
if (!name) return '';
|
||
const v = assetsManifest.updatedAt ? String(assetsManifest.updatedAt) : String(Date.now());
|
||
return `/assets/${encodeURIComponent(name)}?v=${encodeURIComponent(v)}`;
|
||
};
|
||
|
||
const parseJsonSafe = (text) => {
|
||
if (text == null) return null;
|
||
const t = String(text);
|
||
if (!t) return null;
|
||
try { return JSON.parse(t); } catch (e) { return null; }
|
||
};
|
||
|
||
const parseCsvSimple = (text) => {
|
||
const out = { headers: [], rows: [] };
|
||
const raw = String(text || '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||
const lines = raw.split('\n').map(x => x.trim()).filter(Boolean);
|
||
if (!lines.length) return out;
|
||
const splitLine = (line) => {
|
||
const parts = [];
|
||
let cur = '';
|
||
let inQ = false;
|
||
for (let i = 0; i < line.length; i++) {
|
||
const ch = line[i];
|
||
if (ch === '"') {
|
||
if (inQ && line[i + 1] === '"') { cur += '"'; i++; continue; }
|
||
inQ = !inQ;
|
||
continue;
|
||
}
|
||
if (!inQ && ch === ',') { parts.push(cur); cur = ''; continue; }
|
||
cur += ch;
|
||
}
|
||
parts.push(cur);
|
||
return parts.map(x => x.trim());
|
||
};
|
||
const headers = splitLine(lines[0]).filter(Boolean);
|
||
if (!headers.length) return out;
|
||
out.headers = headers;
|
||
for (let i = 1; i < lines.length; i++) {
|
||
const cols = splitLine(lines[i]);
|
||
const row = {};
|
||
for (let j = 0; j < headers.length; j++) row[headers[j]] = (cols[j] == null ? '' : cols[j]);
|
||
out.rows.push(row);
|
||
}
|
||
return out;
|
||
};
|
||
|
||
const requestJson = async (url, opts = {}, { expectOk = false, timeoutMs = 15000 } = {}) => {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const r = await fetch(url, { ...opts, signal: controller.signal });
|
||
const text = await r.text();
|
||
const data = parseJsonSafe(text) ?? (text ? { raw: text } : null);
|
||
if (!r.ok) {
|
||
const msg = (data && (data.error || data.错误)) || r.statusText || '请求失败';
|
||
throw new Error(`${r.status} ${msg}`);
|
||
}
|
||
if (expectOk) {
|
||
if (data && data.ok === false) throw new Error(data.error || data.错误 || '操作失败');
|
||
if (data && data.ok == null && (data.error || data.错误)) throw new Error(data.error || data.错误);
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
const msg = (e && e.name === 'AbortError') ? '请求超时' : (e?.message || String(e));
|
||
throw new Error(msg);
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
};
|
||
|
||
const runMutation = async (action, { successMessage } = {}) => {
|
||
if (mutationBusy.value) return;
|
||
mutationBusy.value = true;
|
||
lastActionError.value = '';
|
||
try {
|
||
await action();
|
||
lastActionOkTs.value = Date.now();
|
||
if (successMessage) alert(successMessage);
|
||
await fetchData();
|
||
} catch (e) {
|
||
lastActionError.value = e?.message || String(e);
|
||
alert(`操作失败:${lastActionError.value}`);
|
||
await fetchData();
|
||
} finally {
|
||
mutationBusy.value = false;
|
||
}
|
||
};
|
||
|
||
// Methods
|
||
const formatTime = (ts) => {
|
||
if (ts == null || ts === '') return '---';
|
||
let value = Number(ts);
|
||
if (Number.isFinite(value)) {
|
||
if (value > 0 && value < 1000000000000) value *= 1000;
|
||
const d = new Date(value);
|
||
if (!Number.isNaN(d.getTime())) {
|
||
return d.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
}
|
||
const d = new Date(ts);
|
||
if (!Number.isNaN(d.getTime())) {
|
||
return d.toLocaleString('zh-CN', { hour12: false });
|
||
}
|
||
return String(ts);
|
||
};
|
||
|
||
const formatLogType = (type) => {
|
||
const map = {
|
||
'update_config_generic': { icon: 'fa-cog', text: '配置更新', class: 'text-primary' },
|
||
'update_station': { icon: 'fa-map-marker-alt', text: '站点更新', class: 'text-info' },
|
||
'add_line': { icon: 'fa-plus-circle', text: '新增线路', class: 'text-success' },
|
||
'update_line': { icon: 'fa-edit', text: '线路更新', class: 'text-warning' },
|
||
'update_fare': { icon: 'fa-coins', text: '票价更新', class: 'text-warning' },
|
||
'delete_fare': { icon: 'fa-trash', text: '票价删除', class: 'text-danger' },
|
||
'ticket_sold': { icon: 'fa-ticket-alt', text: '售票成功', class: 'text-success' },
|
||
'gate_entry': { icon: 'fa-sign-in-alt', text: '进站', class: 'text-info' },
|
||
'gate_exit': { icon: 'fa-sign-out-alt', text: '出站', class: 'text-info' }
|
||
};
|
||
return map[type] || { icon: 'fa-info-circle', text: type, class: 'text-muted' };
|
||
};
|
||
|
||
const formatTicketStatus = (status) => {
|
||
const map = {
|
||
'valid': { text: '有效', class: 'badge-success' },
|
||
'used': { text: '已使用', class: 'badge-secondary' },
|
||
'expired': { text: '已过期', class: 'badge-danger' },
|
||
'refunded': { text: '已退票', class: 'badge-warning' }
|
||
};
|
||
return map[status] || { text: status || '未知', class: 'badge-secondary' };
|
||
};
|
||
|
||
const formatTrainType = (type) => {
|
||
if (!type) return '普通';
|
||
const t = type.toLowerCase();
|
||
if (t === 'local') return '普通';
|
||
if (t === 'ltd.exp' || t === 'express') return '特急';
|
||
return type;
|
||
};
|
||
|
||
const getTicketEventType = (event) => String((event && (event["类型"] || event.type)) || '').toLowerCase();
|
||
|
||
const getTicketEventAction = (event) => String((event && (event["动作"] || event.action)) || '').toLowerCase();
|
||
|
||
const formatTicketEvent = (eventOrType) => {
|
||
const event = eventOrType && typeof eventOrType === 'object' ? eventOrType : { type: eventOrType };
|
||
const type = getTicketEventType(event);
|
||
const action = getTicketEventAction(event);
|
||
if (type === 'sale' || type === '售票') return '售票成功';
|
||
if (type === 'entry' || action === 'entry') return '进站成功';
|
||
if (type === 'exit' || action === 'exit') return '出站成功';
|
||
if (type === 'status' || type === '状态') {
|
||
return { entry: '进站成功', exit: '出站成功' }[action] || '状态变更';
|
||
}
|
||
const map = {
|
||
'entry': '进站',
|
||
'exit': '出站',
|
||
'check': '验票',
|
||
'status': '状态变更',
|
||
'refund': '退票'
|
||
};
|
||
return map[type] || event["类型"] || event.type || '状态变更';
|
||
};
|
||
|
||
const formatTicketEventLocation = (event) => {
|
||
const type = getTicketEventType(event);
|
||
const stationName = event?.["售票站"] || event?.["发生站"] || event?.station_name || '';
|
||
const stationCode = event?.["站点编号"] || event?.station_code || '';
|
||
if (type === 'sale' || type === '售票') {
|
||
return stationName || '线上售票';
|
||
}
|
||
if (!stationName && !stationCode) return '---';
|
||
return [stationName, stationName && stationCode ? stationCode : ''].filter(Boolean).join(' ');
|
||
};
|
||
|
||
const formatTicketEventAttachment = (value) => {
|
||
if (value == null || value === '') return '';
|
||
if (typeof value === 'string') return value;
|
||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||
if (Array.isArray(value)) {
|
||
return value.map(formatTicketEventAttachment).filter(Boolean).join(' | ');
|
||
}
|
||
if (typeof value === 'object') {
|
||
return Object.entries(value)
|
||
.filter(([, item]) => item != null && item !== '')
|
||
.map(([key, item]) => `${key}: ${formatTicketEventAttachment(item)}`)
|
||
.filter(Boolean)
|
||
.join(' | ');
|
||
}
|
||
return String(value);
|
||
};
|
||
|
||
const formatTicketEventExtra = (event) => {
|
||
const type = getTicketEventType(event);
|
||
const amount = event?.["售票额"] ?? event?.amount ?? event?.price ?? event?.cost;
|
||
const stationEn = event?.["站点英文"] || event?.station_en || '';
|
||
const deviceId = event?.["设备编号"] || event?.device_id || event?.device || '';
|
||
const attachment = formatTicketEventAttachment(
|
||
event?.["附加信息"] ?? event?.extra ?? event?.info ?? event?.meta ?? event?.detail
|
||
);
|
||
const parts = [];
|
||
if ((type === 'sale' || type === '售票') && amount != null && amount !== '') {
|
||
parts.push(`票价:¥ ${amount}`);
|
||
}
|
||
if (stationEn && deviceId) parts.push(`${stationEn} (${deviceId})`);
|
||
else if (deviceId) parts.push(`设备:${deviceId}`);
|
||
else if (stationEn) parts.push(stationEn);
|
||
if (attachment) parts.push(attachment);
|
||
return parts.join(' | ');
|
||
};
|
||
|
||
const formatLogDetail = (l) => {
|
||
if (!l.detail) return '';
|
||
if (typeof l.detail === 'string') return l.detail;
|
||
try {
|
||
return JSON.stringify(l.detail, null, 2);
|
||
} catch (e) {
|
||
return String(l.detail);
|
||
}
|
||
};
|
||
|
||
const formatMoney = (value) => {
|
||
const n = Number(value || 0);
|
||
return Number.isFinite(n) ? n.toFixed(0) : '0';
|
||
};
|
||
|
||
const HOLDER_NAME_PATTERN = /^[A-Za-z][A-Za-z .,'()&@/_\-+]*$/;
|
||
|
||
const validateIcHolderName = (value) => {
|
||
const holderName = String(value || '').trim();
|
||
if (!holderName) return '请输入持卡人姓名';
|
||
if (holderName.length > 24) return '持卡人姓名不能超过 24 个字符';
|
||
if (!HOLDER_NAME_PATTERN.test(holderName)) return '持卡人姓名仅支持英文与常用符号';
|
||
return '';
|
||
};
|
||
|
||
const icStatusInfo = (status) => {
|
||
const map = {
|
||
pending_pickup: { text: '待领卡', className: 'badge-warning' },
|
||
active: { text: '正常', className: 'badge-success' },
|
||
disabled: { text: '停用', className: 'badge-danger' },
|
||
lost: { text: '挂失', className: 'badge-danger' },
|
||
refunded: { text: '已退卡', className: 'badge-secondary' }
|
||
};
|
||
return map[String(status || '').toLowerCase()] || { text: status || '未知', className: 'badge-secondary' };
|
||
};
|
||
|
||
const icStatusColor = (status) => {
|
||
const s = String(status || '').toLowerCase();
|
||
if (s === 'active') return 'var(--success)';
|
||
if (s === 'pending_pickup') return 'var(--warning)';
|
||
return 'var(--danger)';
|
||
};
|
||
|
||
const cardOrderCode = (card) => {
|
||
const resolved = String(
|
||
card?.order_code ||
|
||
card?.voucher_code ||
|
||
card?.code ||
|
||
card?.orderCode ||
|
||
card?.card_server_data?.order_code ||
|
||
card?.card_server_data?.voucher_code ||
|
||
''
|
||
).trim();
|
||
if (resolved) return resolved;
|
||
return String(card?.source || '').trim() ? '现场办卡' : '---';
|
||
};
|
||
|
||
const displayIcCardId = (card) => {
|
||
return String(card?.display_card_id || card?.card_id || '---').trim() || '---';
|
||
};
|
||
|
||
const icEventTitle = (event) => {
|
||
const map = {
|
||
create: '后台建卡',
|
||
update: '信息更新',
|
||
topup: '余额充值',
|
||
open: '现场办卡',
|
||
redeem: '线上兑卡',
|
||
order_created: '线上购卡',
|
||
check: '进出站检查',
|
||
activated: '正式启用',
|
||
delete: '删除卡片'
|
||
};
|
||
return map[String(event?.type || '').toLowerCase()] || (event?.type || '事件');
|
||
};
|
||
|
||
const formatIcEventDetail = (event) => {
|
||
const detail = event?.detail != null ? event.detail : event;
|
||
if (typeof detail === 'string') return detail;
|
||
try {
|
||
return JSON.stringify(detail || {}, null, 2);
|
||
} catch (_) {
|
||
return String(detail || '');
|
||
}
|
||
};
|
||
|
||
const getStationName = (code) => {
|
||
const s = stations.value.find(x => x.code === code);
|
||
return s ? (s.name || s.cn_name) : code;
|
||
};
|
||
|
||
const getStationInfo = (code) => {
|
||
return stations.value.find(x => x.code === code) || { code, name: code, en_name: '' };
|
||
};
|
||
|
||
const fetchAssetsManifest = async () => {
|
||
const data = await requestJson('/api/assets/manifest');
|
||
assetsManifest.routeMap = data ? (data.routeMap || null) : null;
|
||
assetsManifest.fareTable = data ? (data.fareTable || null) : null;
|
||
assetsManifest.updatedAt = data ? (data.updatedAt || null) : null;
|
||
assetsRouteMapUrl.value = buildAssetUrl(assetsManifest.routeMap);
|
||
assetsFareTableUrl.value = buildAssetUrl(assetsManifest.fareTable);
|
||
assetsLoaded = true;
|
||
|
||
assetsFarePreview.headers = [];
|
||
assetsFarePreview.rows = [];
|
||
|
||
if (assetsManifest.fareTable) {
|
||
try {
|
||
const r = await fetch(assetsFareTableUrl.value);
|
||
const text = await r.text();
|
||
const name = String(assetsManifest.fareTable || '').toLowerCase();
|
||
if (name.endsWith('.json')) {
|
||
const obj = parseJsonSafe(text);
|
||
if (Array.isArray(obj)) {
|
||
const headers = [];
|
||
for (const it of obj) {
|
||
if (it && typeof it === 'object' && !Array.isArray(it)) {
|
||
for (const k of Object.keys(it)) if (!headers.includes(k)) headers.push(k);
|
||
}
|
||
}
|
||
assetsFarePreview.headers = headers;
|
||
assetsFarePreview.rows = obj.filter(x => x && typeof x === 'object' && !Array.isArray(x));
|
||
} else if (obj && typeof obj === 'object' && Array.isArray(obj.rows) && Array.isArray(obj.headers)) {
|
||
assetsFarePreview.headers = obj.headers;
|
||
const rows = [];
|
||
for (const row of obj.rows) {
|
||
const r2 = {};
|
||
for (let i = 0; i < obj.headers.length; i++) r2[obj.headers[i]] = row[i];
|
||
rows.push(r2);
|
||
}
|
||
assetsFarePreview.rows = rows;
|
||
}
|
||
} else if (name.endsWith('.csv')) {
|
||
const parsed = parseCsvSimple(text);
|
||
assetsFarePreview.headers = parsed.headers;
|
||
assetsFarePreview.rows = parsed.rows;
|
||
}
|
||
} catch (e) {}
|
||
}
|
||
markSynced();
|
||
};
|
||
|
||
const uploadAssetFile = async (url, file) => {
|
||
const fd = new FormData();
|
||
fd.append('file', file);
|
||
await requestJson(url, { method: 'POST', body: fd }, { expectOk: true });
|
||
await fetchAssetsManifest();
|
||
};
|
||
|
||
const uploadRouteMap = async (ev) => {
|
||
const f = ev && ev.target && ev.target.files && ev.target.files[0];
|
||
if (!f) return;
|
||
ev.target.value = '';
|
||
await uploadAssetFile('/api/assets/route-map', f);
|
||
};
|
||
|
||
const uploadFareTable = async (ev) => {
|
||
const f = ev && ev.target && ev.target.files && ev.target.files[0];
|
||
if (!f) return;
|
||
ev.target.value = '';
|
||
await uploadAssetFile('/api/assets/fare-table', f);
|
||
};
|
||
|
||
const deleteRouteMap = async () => {
|
||
await requestJson('/api/assets/route-map', { method: 'DELETE' }, { expectOk: true });
|
||
await fetchAssetsManifest();
|
||
};
|
||
|
||
const deleteFareTable = async () => {
|
||
await requestJson('/api/assets/fare-table', { method: 'DELETE' }, { expectOk: true });
|
||
await fetchAssetsManifest();
|
||
};
|
||
|
||
const transferIndex = computed(() => {
|
||
const out = new Map();
|
||
const inn = new Map();
|
||
|
||
for (const s of (stations.value || [])) {
|
||
const from = String(s?.code || '').trim();
|
||
if (!from) continue;
|
||
if (!s?.transfer_enabled) continue;
|
||
const list = Array.isArray(s.transfer_to) ? s.transfer_to : [];
|
||
for (const t of list) {
|
||
const to = String((t && typeof t === 'object') ? (t.code || t.station || t.id || '') : t).trim();
|
||
if (!to || to === from) continue;
|
||
const o = out.get(from) || [];
|
||
o.push(to);
|
||
out.set(from, o);
|
||
const i = inn.get(to) || [];
|
||
i.push(from);
|
||
inn.set(to, i);
|
||
}
|
||
}
|
||
|
||
const uniq = (arr) => Array.from(new Set((arr || []).filter(Boolean)));
|
||
const getOut = (code) => uniq(out.get(code));
|
||
const getIn = (code) => uniq(inn.get(code));
|
||
|
||
return { getOut, getIn };
|
||
});
|
||
|
||
const stationLinesIndex = computed(() => {
|
||
const map = new Map();
|
||
for (const li of (lines.value || [])) {
|
||
const id = String(li?.id || '').trim();
|
||
const color = li?.color || '#93a2b7';
|
||
if (!id) continue;
|
||
const arr = Array.isArray(li.stations) ? li.stations : (Array.isArray(li.stops) ? li.stops : []);
|
||
for (const sc of arr) {
|
||
const code = String(sc || '').trim();
|
||
if (!code) continue;
|
||
const cur = map.get(code) || [];
|
||
cur.push({ id, color });
|
||
map.set(code, cur);
|
||
}
|
||
}
|
||
const uniqById = (arr) => {
|
||
const seen = new Set();
|
||
const out = [];
|
||
for (const x of (arr || [])) {
|
||
if (!x?.id || seen.has(x.id)) continue;
|
||
seen.add(x.id);
|
||
out.push({ id: x.id, color: x.color || '#93a2b7' });
|
||
}
|
||
return out;
|
||
};
|
||
return {
|
||
getLines: (code) => uniqById(map.get(String(code || '').trim()) || [])
|
||
};
|
||
});
|
||
|
||
const isTransferStation = (code) => {
|
||
const c = String(code || '').trim();
|
||
if (!c) return false;
|
||
const { getOut, getIn } = transferIndex.value;
|
||
return (getOut(c).length + getIn(c).length) > 0;
|
||
};
|
||
|
||
const getTransferLineBadges = (code) => {
|
||
const c = String(code || '').trim();
|
||
if (!c) return [];
|
||
const { getOut, getIn } = transferIndex.value;
|
||
const partners = [...getOut(c), ...getIn(c)];
|
||
const lineId = selectedLine.value?.id;
|
||
const badges = [];
|
||
const seen = new Set();
|
||
for (const p of partners) {
|
||
for (const li of stationLinesIndex.value.getLines(p)) {
|
||
if (lineId && li.id === lineId) continue;
|
||
if (seen.has(li.id)) continue;
|
||
seen.add(li.id);
|
||
badges.push(li);
|
||
}
|
||
}
|
||
return badges.slice(0, 6);
|
||
};
|
||
|
||
const getTransferTitleSuffix = (code) => {
|
||
const c = String(code || '').trim();
|
||
if (!c) return '';
|
||
const { getOut, getIn } = transferIndex.value;
|
||
const out = getOut(c);
|
||
const inn = getIn(c);
|
||
if (out.length === 0 && inn.length === 0) return '';
|
||
const fmt = (arr) => arr.map(x => `${getStationName(x)} (${x})`).join(', ');
|
||
let s = '\nXFER';
|
||
if (out.length > 0) s += `\nTo: ${fmt(out)}`;
|
||
if (inn.length > 0) s += `\nFrom: ${fmt(inn)}`;
|
||
return s;
|
||
};
|
||
|
||
const viewTicketDetails = async (ticket) => {
|
||
selectedTicket.value = null;
|
||
showTicketModal.value = true;
|
||
try {
|
||
const res = await requestJson(`/api/tickets/${encodeURIComponent(ticket.ticket_id)}`);
|
||
if (res && res.ok) selectedTicket.value = res;
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
};
|
||
|
||
const closeTicketModal = () => {
|
||
showTicketModal.value = false;
|
||
selectedTicket.value = null;
|
||
};
|
||
|
||
// --- Drag & Drop Logic ---
|
||
const onStationDragStart = (index) => {
|
||
if (fareMode.value) return; // Disable drag in fare mode
|
||
draggingStationIndex.value = index;
|
||
};
|
||
|
||
const onStationDragOver = (index) => {
|
||
// Only swap if we are actually dragging
|
||
if (draggingStationIndex.value === null) return;
|
||
if (draggingStationIndex.value === index) return;
|
||
|
||
// Swap in local array for visual feedback
|
||
const list = selectedLine.value.stations;
|
||
const temp = list[draggingStationIndex.value];
|
||
list.splice(draggingStationIndex.value, 1);
|
||
list.splice(index, 0, temp);
|
||
draggingStationIndex.value = index;
|
||
};
|
||
|
||
const onStationDrop = async () => {
|
||
if (draggingStationIndex.value === null) return;
|
||
try {
|
||
await updateLineStations(selectedLine.value.id, selectedLine.value.stations);
|
||
} catch (e) {
|
||
alert(`保存站序失败:${e?.message || String(e)}`);
|
||
await fetchData();
|
||
}
|
||
draggingStationIndex.value = null;
|
||
};
|
||
|
||
const startLineViewportPan = (event) => {
|
||
const viewport = visualLineViewport.value;
|
||
if (!viewport) return;
|
||
if (event.button !== 0) return;
|
||
if (event.target && event.target.closest('.station-node')) return;
|
||
lineViewportPan.active = true;
|
||
lineViewportPan.moved = false;
|
||
lineViewportPan.startX = event.clientX;
|
||
lineViewportPan.startY = event.clientY;
|
||
lineViewportPan.scrollLeft = viewport.scrollLeft;
|
||
lineViewportPan.scrollTop = viewport.scrollTop;
|
||
};
|
||
|
||
const moveLineViewportPan = (event) => {
|
||
if (!lineViewportPan.active) return;
|
||
const viewport = visualLineViewport.value;
|
||
if (!viewport) return;
|
||
const deltaX = event.clientX - lineViewportPan.startX;
|
||
const deltaY = event.clientY - lineViewportPan.startY;
|
||
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
|
||
lineViewportPan.moved = true;
|
||
}
|
||
viewport.scrollLeft = lineViewportPan.scrollLeft - deltaX;
|
||
viewport.scrollTop = lineViewportPan.scrollTop - deltaY;
|
||
};
|
||
|
||
const endLineViewportPan = () => {
|
||
lineViewportPan.active = false;
|
||
};
|
||
|
||
// --- Order Management ---
|
||
const fetchOrders = async () => {
|
||
if (loadingState.orders) return;
|
||
loadingState.orders = true;
|
||
try {
|
||
const res = await requestJson('/api/orders');
|
||
if (res && res.ok) {
|
||
orders.value = res.orders || [];
|
||
orderDataLoaded = true;
|
||
markSynced();
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
loadingState.orders = false;
|
||
}
|
||
};
|
||
|
||
const fetchIcCards = async (keepSelection = true) => {
|
||
if (loadingState.iccards) return;
|
||
loadingState.iccards = true;
|
||
const requestSeq = ++icListRequestSeq;
|
||
const sp = new URLSearchParams();
|
||
if (icCardSearch.value.trim()) sp.set('q', icCardSearch.value.trim());
|
||
try {
|
||
const res = await requestJson(`/api/ic-cards?${sp.toString()}`);
|
||
if (requestSeq !== icListRequestSeq) return;
|
||
icCards.value = res?.cards || [];
|
||
if (keepSelection && icSelectedId.value && icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||
await loadIcCard(icSelectedId.value);
|
||
} else if (icSelectedId.value && !icCards.value.some((card) => card.card_id === icSelectedId.value)) {
|
||
icSelectedId.value = '';
|
||
icSelectedCard.value = null;
|
||
icSelectedEvents.value = [];
|
||
}
|
||
markSynced();
|
||
} finally {
|
||
if (requestSeq === icListRequestSeq) {
|
||
loadingState.iccards = false;
|
||
}
|
||
}
|
||
};
|
||
|
||
const loadIcCard = async (id) => {
|
||
const requestSeq = ++icDetailRequestSeq;
|
||
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(id)}`);
|
||
if (requestSeq !== icDetailRequestSeq) return;
|
||
const card = res?.card || null;
|
||
icSelectedId.value = id;
|
||
icSelectedCard.value = card;
|
||
icSelectedEvents.value = res?.events || [];
|
||
icDetailForm.holder_name = card?.holder_name || '';
|
||
icDetailForm.status = card?.status || 'active';
|
||
markSynced();
|
||
};
|
||
|
||
const syncSelectedIcCard = async () => {
|
||
if (!icSelectedId.value) return;
|
||
const res = await requestJson(`/api/ic-cards/${encodeURIComponent(icSelectedId.value)}`);
|
||
const card = res?.card || null;
|
||
if (!card) return;
|
||
icSelectedCard.value = card;
|
||
icSelectedEvents.value = res?.events || [];
|
||
};
|
||
|
||
const stopIcCardSync = () => {
|
||
if (icCardSyncTimer) {
|
||
clearInterval(icCardSyncTimer);
|
||
icCardSyncTimer = null;
|
||
}
|
||
};
|
||
|
||
const startIcCardSync = () => {
|
||
stopIcCardSync();
|
||
if (currentView.value !== 'iccards') return;
|
||
icCardSyncTimer = setInterval(() => {
|
||
if (document.hidden || icCardSyncBusy) return;
|
||
icCardSyncBusy = true;
|
||
Promise.all([
|
||
fetchIcCards(false).catch(console.error),
|
||
icSelectedId.value ? syncSelectedIcCard().catch(console.error) : Promise.resolve()
|
||
]).finally(() => {
|
||
icCardSyncBusy = false;
|
||
});
|
||
}, 5000);
|
||
};
|
||
|
||
const createIcCard = async () => {
|
||
const holder_name = String(icCreateForm.holder_name || '').trim();
|
||
const balance = Number(icCreateForm.balance || 0) || 0;
|
||
const holderError = validateIcHolderName(holder_name);
|
||
if (holderError) return alert(holderError);
|
||
await runMutation(async () => {
|
||
const res = await requestJson('/api/ic-cards', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ holder_name, balance })
|
||
}, { expectOk: true });
|
||
icCreateForm.holder_name = '';
|
||
icCreateForm.balance = 50;
|
||
await fetchIcCards(false);
|
||
if (res?.card_id) await loadIcCard(res.card_id);
|
||
}, { successMessage: 'IC 卡已创建' });
|
||
};
|
||
|
||
const saveIcCard = async () => {
|
||
if (!icSelectedId.value) return alert('请先选择一张 IC 卡');
|
||
const payload = {
|
||
holder_name: String(icDetailForm.holder_name || '').trim(),
|
||
status: icDetailForm.status || 'active'
|
||
};
|
||
const holderError = validateIcHolderName(payload.holder_name);
|
||
if (holderError) return alert(holderError);
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/ic-cards/${encodeURIComponent(icSelectedId.value)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
}, { expectOk: true });
|
||
await fetchIcCards(false);
|
||
await loadIcCard(icSelectedId.value);
|
||
}, { successMessage: '已保存 IC 卡信息' });
|
||
};
|
||
|
||
const topupIcCard = async () => {
|
||
if (!icSelectedId.value) return alert('请先选择一张 IC 卡');
|
||
const raw = await appDialog.prompt({
|
||
title: 'IC 卡充值',
|
||
message: `请输入给 ${icSelectedId.value} 充值的金额`,
|
||
defaultValue: '50',
|
||
placeholder: '请输入充值金额',
|
||
confirmText: '确认充值'
|
||
});
|
||
if (raw == null) return;
|
||
const amount = Number(raw);
|
||
if (!(amount > 0)) return alert('充值金额必须大于 0');
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/ic-cards/${encodeURIComponent(icSelectedId.value)}/topup`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ amount })
|
||
}, { expectOk: true });
|
||
await fetchIcCards(false);
|
||
await loadIcCard(icSelectedId.value);
|
||
}, { successMessage: '充值成功' });
|
||
};
|
||
|
||
const deleteIcCard = async () => {
|
||
if (!icSelectedId.value || !icSelectedCard.value) return alert('请先选择一张 IC 卡');
|
||
if (!await appDialog.confirm({
|
||
title: '删除 IC 卡',
|
||
message: `确定删除 IC 卡 ${icSelectedId.value} 吗?此操作不可撤销。`,
|
||
confirmText: '确认删除'
|
||
})) return;
|
||
const removingId = icSelectedId.value;
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/ic-cards/${encodeURIComponent(removingId)}`, { method: 'DELETE' }, { expectOk: true });
|
||
icSelectedId.value = '';
|
||
icSelectedCard.value = null;
|
||
icSelectedEvents.value = [];
|
||
await fetchIcCards(false);
|
||
}, { successMessage: 'IC 卡已删除' });
|
||
};
|
||
|
||
const buildLogsUrl = () => {
|
||
const sp = new URLSearchParams();
|
||
sp.set('max', String(logMax.value || 200));
|
||
if (logCategory.value) sp.set('category', logCategory.value);
|
||
if (logTypeFilter.value) sp.set('type', logTypeFilter.value);
|
||
if (logQuery.value) sp.set('q', logQuery.value);
|
||
return `/api/logs?${sp.toString()}`;
|
||
};
|
||
|
||
const fetchLogs = async () => {
|
||
if (logLoading.value) return;
|
||
logLoading.value = true;
|
||
try {
|
||
const res = await requestJson(buildLogsUrl());
|
||
if (res && res.ok) {
|
||
logs.value = res.logs || [];
|
||
logDataLoaded = true;
|
||
markSynced();
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
logLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const deleteOrder = async (code) => {
|
||
if (!await appDialog.confirm({
|
||
title: '删除凭证',
|
||
message: `确定删除凭证 ${code} 吗?`,
|
||
confirmText: '确认删除'
|
||
})) return;
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/orders/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
|
||
await fetchOrders();
|
||
});
|
||
};
|
||
|
||
const updateLineInfo = async () => {
|
||
if (!selectedLine.value) return;
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/lines/${encodeURIComponent(selectedLine.value.id)}`, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(selectedLine.value)
|
||
}, { expectOk: true });
|
||
});
|
||
};
|
||
|
||
const getFareText = (idx) => {
|
||
if (!selectedLine.value || !selectedLine.value.stations) return '';
|
||
const s1 = selectedLine.value.stations[idx];
|
||
const s2 = selectedLine.value.stations[idx + 1];
|
||
if (!s1 || !s2) return '';
|
||
|
||
const f = fares.value.find(x => (x.from === s1 && x.to === s2) || (x.from === s2 && x.to === s1));
|
||
if (!f) return '';
|
||
|
||
const reg = f.cost_regular ?? f.cost ?? 0;
|
||
const exp = f.cost_express ?? f.cost ?? 0;
|
||
return `¥${reg} / ¥${exp}`;
|
||
};
|
||
|
||
const fetchCoreData = async ({ force = false } = {}) => {
|
||
if (loadingState.core) return;
|
||
if (coreLoaded && !force) return;
|
||
loadingState.core = true;
|
||
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 [s, l, f, c, st] = await Promise.all([
|
||
safeFetch('/api/stations', []),
|
||
safeFetch('/api/lines', []),
|
||
safeFetch('/api/fares', []),
|
||
safeFetch('/api/config', {}),
|
||
safeFetch('/api/stats/ticket/total', {}).then((d) => d.total || {})
|
||
]);
|
||
stations.value = s;
|
||
lines.value = l;
|
||
fares.value = f;
|
||
Object.assign(config, c);
|
||
Object.assign(stats, st);
|
||
if (selectedLine.value) {
|
||
const found = lines.value.find((line) => line.id === selectedLine.value.id);
|
||
selectedLine.value = found || null;
|
||
}
|
||
coreLoaded = true;
|
||
markSynced();
|
||
} catch (e) {
|
||
console.error('Failed to fetch core data', e);
|
||
} finally {
|
||
loadingState.core = false;
|
||
}
|
||
};
|
||
|
||
const fetchTicketData = async () => {
|
||
if (loadingState.tickets) return;
|
||
loadingState.tickets = true;
|
||
try {
|
||
const res = await requestJson('/api/tickets');
|
||
tickets.value = res?.tickets || [];
|
||
ticketDataLoaded = true;
|
||
markSynced();
|
||
} catch (e) {
|
||
console.error('Failed to fetch tickets', e);
|
||
} finally {
|
||
loadingState.tickets = false;
|
||
}
|
||
};
|
||
|
||
const loadFareMap = async ({ force = false } = {}) => {
|
||
if (fareMapLoading.value) return;
|
||
if (fareMapLoaded && !force) return;
|
||
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;
|
||
fareMapLoaded = true;
|
||
markSynced();
|
||
} catch (e) {
|
||
console.error("Failed to load fare map", e);
|
||
fareMapError.value = '加载失败';
|
||
} finally {
|
||
fareMapLoading.value = false;
|
||
}
|
||
};
|
||
|
||
const ensureViewData = async (view = currentView.value, { force = false } = {}) => {
|
||
await fetchCoreData({ force });
|
||
if (view === 'tickets' && (force || !ticketDataLoaded)) await fetchTicketData();
|
||
if (view === 'vouchers' && (force || !orderDataLoaded)) await fetchOrders();
|
||
if (view === 'logs' && (force || !logDataLoaded)) await fetchLogs();
|
||
if (view === 'iccards') {
|
||
await fetchIcCards(true);
|
||
if (icSelectedId.value) {
|
||
await syncSelectedIcCard().catch(console.error);
|
||
}
|
||
}
|
||
if (view === 'faremap' && (force || !fareMapLoaded)) await loadFareMap({ force });
|
||
if (view === 'assets' && (!assetsLoaded || force)) await fetchAssetsManifest();
|
||
};
|
||
|
||
const fetchData = async () => {
|
||
await ensureViewData(currentView.value, { force: true });
|
||
};
|
||
|
||
const zoomFareMapIn = () => { fareMapScale.value = Math.min(3, Math.round((fareMapScale.value + 0.1) * 100) / 100); };
|
||
const zoomFareMapOut = () => { fareMapScale.value = Math.max(0.3, Math.round((fareMapScale.value - 0.1) * 100) / 100); };
|
||
const zoomFareMapReset = () => { fareMapScale.value = 1; };
|
||
|
||
// --- Management Actions ---
|
||
|
||
const createStation = async () => {
|
||
if (!newStation.code || !newStation.name) return alert('请填写完整');
|
||
await runMutation(async () => {
|
||
await requestJson('/api/stations', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(newStation) }, { expectOk: true });
|
||
showAddStation.value = false;
|
||
Object.assign(newStation, { code: '', name: '', en_name: '' });
|
||
});
|
||
};
|
||
|
||
const deleteStation = async (code) => {
|
||
if (!await appDialog.confirm({
|
||
title: '删除站点',
|
||
message: '确定从库中删除该站点?这不会影响已存在于线路中的引用,但建议先从线路移除。',
|
||
confirmText: '确认删除'
|
||
})) return;
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/stations/${encodeURIComponent(code)}`, { method: 'DELETE' }, { expectOk: true });
|
||
});
|
||
};
|
||
|
||
const createLine = async () => {
|
||
if (!newLine.id) return alert('请填写ID');
|
||
await runMutation(async () => {
|
||
await requestJson('/api/lines', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(newLine) }, { expectOk: true });
|
||
showAddLine.value = false;
|
||
Object.assign(newLine, { id: '', name: '', en_name: '', color: '#3366cc' });
|
||
});
|
||
};
|
||
|
||
const deleteLine = async (id) => {
|
||
if (!await appDialog.confirm({
|
||
title: '删除线路',
|
||
message: '确定删除此线路?',
|
||
confirmText: '确认删除'
|
||
})) return;
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' }, { expectOk: true });
|
||
if (selectedLine.value && selectedLine.value.id === id) selectedLine.value = null;
|
||
});
|
||
};
|
||
|
||
// --- Visual Editor Logic ---
|
||
|
||
const selectLine = (l) => {
|
||
selectedLine.value = l;
|
||
fareMode.value = false;
|
||
fareSelection.value = [];
|
||
};
|
||
|
||
const availableStations = computed(() => {
|
||
return stations.value;
|
||
});
|
||
|
||
const isStationInLine = (code) => {
|
||
return selectedLine.value && (selectedLine.value.stations || []).includes(code);
|
||
};
|
||
|
||
const addStationToLine = async () => {
|
||
if (!selectedLine.value) return;
|
||
const { code, name, en_name } = newStation;
|
||
|
||
if (!code || !name) return alert('请填写编号和名称');
|
||
|
||
await runMutation(async () => {
|
||
const existing = stations.value.find(s => s.code === code);
|
||
if (!existing) {
|
||
await requestJson('/api/stations', {
|
||
method: 'POST',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify({ code, name, en_name })
|
||
}, { expectOk: true });
|
||
}
|
||
|
||
if (isStationInLine(code)) throw new Error('该站点已在此线路中');
|
||
|
||
const newStations = [...(selectedLine.value.stations || [])];
|
||
newStations.push(code);
|
||
await updateLineStations(selectedLine.value.id, newStations, { skipFetchData: true });
|
||
Object.assign(newStation, { code: '', name: '', en_name: '' });
|
||
});
|
||
};
|
||
|
||
const removeStationFromLine = async (code) => {
|
||
if (!selectedLine.value) return;
|
||
const newStations = selectedLine.value.stations.filter(s => s !== code);
|
||
await updateLineStations(selectedLine.value.id, newStations);
|
||
};
|
||
|
||
const updateLineStations = async (lineId, stationsList, { skipFetchData } = {}) => {
|
||
// Need to update the whole line object usually, or a specific endpoint
|
||
// Assuming PUT /api/lines/:id updates fields provided
|
||
const line = lines.value.find(l => l.id === lineId);
|
||
if (!line) return;
|
||
|
||
const updated = { ...line, stations: stationsList };
|
||
|
||
const r = await requestJson(`/api/lines/${encodeURIComponent(lineId)}`, {
|
||
method: 'PUT',
|
||
headers: {'Content-Type': 'application/json'},
|
||
body: JSON.stringify(updated)
|
||
}, { expectOk: true });
|
||
if (!skipFetchData) await fetchData();
|
||
return r;
|
||
};
|
||
|
||
const openStationModal = (code) => {
|
||
const s = stations.value.find(x => x.code === code) || { code, name: code, en_name: '' };
|
||
stationFormOriginalCode.value = s.code || code;
|
||
stationForm.code = s.code || code;
|
||
stationForm.name = s.name || s.cn_name || '';
|
||
stationForm.en_name = s.en_name || s.enName || '';
|
||
stationForm.transfer_enabled = !!s.transfer_enabled;
|
||
stationForm.transfer_to = Array.isArray(s.transfer_to) ? [...s.transfer_to] : [];
|
||
showStationModal.value = true;
|
||
};
|
||
|
||
const closeStationModal = () => {
|
||
showStationModal.value = false;
|
||
};
|
||
|
||
const saveStationSettings = async () => {
|
||
if (!stationFormOriginalCode.value) return;
|
||
if (!stationForm.code) return alert('请填写站点编号');
|
||
const oldCode = String(stationFormOriginalCode.value || '').trim();
|
||
const newCode = String(stationForm.code || '').trim();
|
||
if (!newCode) return alert('请填写站点编号');
|
||
if (newCode !== oldCode) {
|
||
if (!await appDialog.confirm({
|
||
title: '修改站点编号',
|
||
message: `确定将站点编号从 ${oldCode} 修改为 ${newCode} 吗?这会同步更新线路、票价、凭证等引用。`,
|
||
confirmText: '确认修改'
|
||
})) return;
|
||
}
|
||
const payload = {
|
||
code: newCode,
|
||
name: stationForm.name,
|
||
en_name: stationForm.en_name,
|
||
transfer_enabled: !!stationForm.transfer_enabled,
|
||
transfer_to: stationForm.transfer_enabled ? (Array.isArray(stationForm.transfer_to) ? stationForm.transfer_to : []) : []
|
||
};
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/stations/${encodeURIComponent(oldCode)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
|
||
showStationModal.value = false;
|
||
});
|
||
};
|
||
|
||
const transferTargets = computed(() => {
|
||
const fromCode = stationForm.code;
|
||
return stations.value
|
||
.filter(s => s && s.code && s.code !== fromCode)
|
||
.map(s => ({ code: s.code, name: s.name || s.cn_name || s.code, en_name: s.en_name || s.enName || '' }));
|
||
});
|
||
|
||
const openLineModal = () => {
|
||
if (!selectedLine.value) return;
|
||
lineFormOriginalId.value = selectedLine.value.id;
|
||
lineForm.id = selectedLine.value.id || '';
|
||
lineForm.name = selectedLine.value.name || '';
|
||
lineForm.en_name = selectedLine.value.en_name || '';
|
||
lineForm.color = selectedLine.value.color || '#3366cc';
|
||
lineForm.stations = Array.isArray(selectedLine.value.stations) ? [...selectedLine.value.stations] : [];
|
||
showLineModal.value = true;
|
||
};
|
||
|
||
const closeLineModal = () => {
|
||
showLineModal.value = false;
|
||
};
|
||
|
||
const saveLineSettings = async () => {
|
||
if (!lineFormOriginalId.value) return;
|
||
const oldId = String(lineFormOriginalId.value || '').trim();
|
||
const newId = String(lineForm.id || '').trim();
|
||
if (!newId) return alert('请填写线路编号');
|
||
if (newId !== oldId) {
|
||
if (!await appDialog.confirm({
|
||
title: '修改线路编号',
|
||
message: `确定将线路编号从 ${oldId} 修改为 ${newId} 吗?`,
|
||
confirmText: '确认修改'
|
||
})) return;
|
||
}
|
||
const payload = {
|
||
id: newId,
|
||
name: lineForm.name,
|
||
en_name: lineForm.en_name,
|
||
color: lineForm.color,
|
||
stations: Array.isArray(lineForm.stations) ? [...lineForm.stations] : []
|
||
};
|
||
await runMutation(async () => {
|
||
await requestJson(`/api/lines/${encodeURIComponent(oldId)}`, { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
|
||
showLineModal.value = false;
|
||
const next = lines.value.find(l => l.id === newId);
|
||
if (next) selectLine(next);
|
||
});
|
||
};
|
||
|
||
const handleStationClick = async (code) => {
|
||
if (lineViewportPan.moved) {
|
||
lineViewportPan.moved = false;
|
||
return;
|
||
}
|
||
if (stationEditMode.value) {
|
||
openStationModal(code);
|
||
return;
|
||
}
|
||
if (fareMode.value) {
|
||
// Toggle selection
|
||
const idx = fareSelection.value.indexOf(code);
|
||
if (idx >= 0) {
|
||
fareSelection.value.splice(idx, 1);
|
||
} else {
|
||
if (fareSelection.value.length < 2) {
|
||
fareSelection.value.push(code);
|
||
} else {
|
||
// Replace the second one or reset? Let's shift
|
||
fareSelection.value.shift();
|
||
fareSelection.value.push(code);
|
||
}
|
||
}
|
||
|
||
// If we have 2, check fare
|
||
if (fareSelection.value.length === 2) {
|
||
checkAndOpenFareModal();
|
||
}
|
||
} else {
|
||
// Normal mode: Ask to delete?
|
||
if (await appDialog.confirm({
|
||
title: '移除站点',
|
||
message: `从线路 ${selectedLine.value.id} 中移除站点 ${getStationName(code)}?`,
|
||
confirmText: '确认移除'
|
||
})) {
|
||
await removeStationFromLine(code);
|
||
}
|
||
}
|
||
};
|
||
|
||
watch(fareMode, (v) => {
|
||
if (v) stationEditMode.value = false;
|
||
});
|
||
watch(stationEditMode, (v) => {
|
||
if (v) {
|
||
fareMode.value = false;
|
||
fareSelection.value = [];
|
||
}
|
||
});
|
||
watch(icCardSearch, () => {
|
||
window.clearTimeout(icCardSearch._timer);
|
||
icCardSearch._timer = window.setTimeout(() => {
|
||
fetchIcCards(false).catch(console.error);
|
||
}, 240);
|
||
});
|
||
|
||
const isStationSelected = (code) => {
|
||
return fareSelection.value.includes(code);
|
||
};
|
||
|
||
const checkAndOpenFareModal = () => {
|
||
const [from, to] = fareSelection.value;
|
||
// Find existing fare
|
||
// Fare direction is usually bidirectional or defined one way. Let's check both.
|
||
let f = fares.value.find(x => (x.from === from && x.to === to) || (x.from === to && x.to === from));
|
||
|
||
if (f) {
|
||
currentFare.exists = true;
|
||
currentFare.cost_regular = f.cost_regular || f.cost || 0;
|
||
currentFare.cost_express = f.cost_express || f.cost || 0;
|
||
} else {
|
||
currentFare.exists = false;
|
||
currentFare.cost_regular = 0;
|
||
currentFare.cost_express = 0;
|
||
}
|
||
showFareModal.value = true;
|
||
};
|
||
|
||
const closeFareModal = () => {
|
||
showFareModal.value = false;
|
||
fareSelection.value = [];
|
||
};
|
||
|
||
const saveCurrentFare = async () => {
|
||
const [from, to] = fareSelection.value;
|
||
await runMutation(async () => {
|
||
if (selectedLine.value) {
|
||
const stations = selectedLine.value.stations || [];
|
||
const idx1 = stations.indexOf(from);
|
||
const idx2 = stations.indexOf(to);
|
||
if (idx1 !== -1 && idx2 !== -1) {
|
||
const start = Math.min(idx1, idx2);
|
||
const end = Math.max(idx1, idx2);
|
||
if (end - start > 1) {
|
||
if (!await appDialog.confirm({
|
||
title: '区间票价应用',
|
||
message: `检测到所选站点间有 ${end - start - 1} 个中间站,是否将此票价应用到该区间内的每一段?`,
|
||
confirmText: '应用到整段',
|
||
cancelText: '仅保存当前区间'
|
||
})) {
|
||
await submitFare(from, to);
|
||
} else {
|
||
for (let k = start; k < end; k++) {
|
||
await submitFare(stations[k], stations[k+1]);
|
||
}
|
||
}
|
||
} else {
|
||
await submitFare(from, to);
|
||
}
|
||
}
|
||
}
|
||
closeFareModal();
|
||
});
|
||
};
|
||
|
||
const submitFare = async (from, to) => {
|
||
const payload = {
|
||
from, to,
|
||
cost_regular: currentFare.cost_regular,
|
||
cost_express: currentFare.cost_express
|
||
};
|
||
await requestJson('/api/fares', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload) }, { expectOk: true });
|
||
};
|
||
|
||
const deleteCurrentFare = async () => {
|
||
const [from, to] = fareSelection.value;
|
||
await runMutation(async () => {
|
||
await requestJson('/api/fares', { method: 'DELETE', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({ from, to }) }, { expectOk: true });
|
||
closeFareModal();
|
||
});
|
||
};
|
||
|
||
const saveConfig = async () => {
|
||
await runMutation(async () => {
|
||
await requestJson('/api/config', { method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(config) }, { expectOk: true });
|
||
}, { successMessage: '保存成功' });
|
||
};
|
||
|
||
const exportData = () => {
|
||
window.open('/api/export', '_blank');
|
||
};
|
||
|
||
// Socket Listeners
|
||
socket.on('connect', () => { connected.value = true; });
|
||
socket.on('disconnect', () => { connected.value = false; });
|
||
|
||
socket.on('stations:updated', (data) => {
|
||
stations.value = data;
|
||
// Refresh map when stations change
|
||
fareMapLoaded = false;
|
||
if (currentView.value === 'faremap') {
|
||
loadFareMap({ force: true });
|
||
}
|
||
});
|
||
|
||
socket.on('lines:updated', (data) => {
|
||
lines.value = data;
|
||
coreLoaded = true;
|
||
// Update selectedLine reference if it exists
|
||
if (selectedLine.value) {
|
||
const updated = data.find(l => l.id === selectedLine.value.id);
|
||
if (updated) {
|
||
selectedLine.value = updated;
|
||
} else {
|
||
selectedLine.value = null; // Line was deleted
|
||
}
|
||
}
|
||
fareMapLoaded = false;
|
||
if (currentView.value === 'faremap') {
|
||
loadFareMap({ force: true });
|
||
}
|
||
});
|
||
|
||
socket.on('fares:updated', (data) => {
|
||
fares.value = data;
|
||
coreLoaded = true;
|
||
fareMapLoaded = false;
|
||
if (currentView.value === 'faremap') {
|
||
loadFareMap({ force: true });
|
||
}
|
||
});
|
||
socket.on('config:updated', (data) => {
|
||
Object.assign(config, data);
|
||
coreLoaded = true;
|
||
fareMapLoaded = false;
|
||
if (currentView.value === 'faremap') {
|
||
loadFareMap({ force: true });
|
||
}
|
||
});
|
||
|
||
socket.on('stats:ticket:updated', (item) => {
|
||
stats.sold_tickets += item.sold_tickets;
|
||
stats.revenue += item.revenue;
|
||
});
|
||
socket.on('ic-card:created', () => { fetchIcCards(false).catch(console.error); });
|
||
socket.on('ic-card:opened', () => { fetchIcCards(false).catch(console.error); });
|
||
socket.on('ic-card:check', () => {
|
||
fetchIcCards(false).catch(console.error);
|
||
syncSelectedIcCard().catch(console.error);
|
||
});
|
||
socket.on('ic-card:topup', () => {
|
||
fetchIcCards(false).catch(console.error);
|
||
syncSelectedIcCard().catch(console.error);
|
||
});
|
||
socket.on('ic-card:sync', () => {
|
||
fetchIcCards(false).catch(console.error);
|
||
syncSelectedIcCard().catch(console.error);
|
||
});
|
||
|
||
watch(currentView, (v) => {
|
||
sidebarOpen.value = false;
|
||
if (v === 'iccards') {
|
||
startIcCardSync();
|
||
} else {
|
||
stopIcCardSync();
|
||
}
|
||
ensureViewData(v).catch(console.error);
|
||
const sp = new URLSearchParams(location.search);
|
||
if (v === 'dashboard') sp.delete('view');
|
||
else sp.set('view', v);
|
||
const q = sp.toString();
|
||
const target = `${location.pathname}${q ? `?${q}` : ''}`;
|
||
history.replaceState(null, '', target);
|
||
});
|
||
|
||
// Initial Load
|
||
onMounted(() => {
|
||
ensureViewData(currentView.value, { force: true }).catch(console.error);
|
||
if (currentView.value === 'iccards') {
|
||
startIcCardSync();
|
||
}
|
||
appMouseupHandler = async () => {
|
||
endLineViewportPan();
|
||
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;
|
||
}
|
||
};
|
||
window.addEventListener('mouseup', appMouseupHandler);
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
stopIcCardSync();
|
||
if (appMouseupHandler) {
|
||
window.removeEventListener('mouseup', appMouseupHandler);
|
||
}
|
||
});
|
||
|
||
// Computed
|
||
const recentLogs = computed(() => logs.value);
|
||
const orderList = computed(() => orders.value);
|
||
const lineEditorSvgWidth = computed(() => {
|
||
const count = Array.isArray(selectedLine.value?.stations) ? selectedLine.value.stations.length : 0;
|
||
return Math.max(960, 100 + Math.max(0, count - 1) * 120 + 120);
|
||
});
|
||
const lastSyncText = computed(() => lastSyncAt.value ? formatTime(lastSyncAt.value) : '尚未同步');
|
||
const isViewBusy = computed(() => {
|
||
if (loadingState.core) return true;
|
||
if (currentView.value === 'tickets') return loadingState.tickets;
|
||
if (currentView.value === 'vouchers') return loadingState.orders;
|
||
if (currentView.value === 'logs') return logLoading.value;
|
||
if (currentView.value === 'iccards') return loadingState.iccards;
|
||
if (currentView.value === 'faremap') return fareMapLoading.value;
|
||
return false;
|
||
});
|
||
const currentViewSummary = computed(() => {
|
||
const map = {
|
||
dashboard: `已同步 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
|
||
management: `当前可编辑 ${lines.value.length} 条线路与 ${stations.value.length} 个站点`,
|
||
faremap: fareMapLoading.value ? '票价图正在生成中' : '可导出当前铁路票价图',
|
||
tickets: `已加载 ${ticketList.value.length} 条车票记录`,
|
||
vouchers: `已加载 ${orders.value.length} 条凭证记录`,
|
||
iccards: `当前检索到 ${icCards.value.length} 张 IC 卡`,
|
||
assets: assetsManifest.routeMap ? `已上传线路图 ${assetsManifest.routeMap}` : '尚未上传线路图',
|
||
settings: '可维护优惠活动与导出数据',
|
||
logs: `当前筛选结果 ${logs.value.length} 条日志`
|
||
};
|
||
return map[currentView.value] || '后台模块已就绪';
|
||
});
|
||
const icCardStats = computed(() => ({
|
||
total: icCards.value.length,
|
||
pending: icCards.value.filter((card) => card.status === 'pending_pickup').length,
|
||
active: icCards.value.filter((card) => card.status === 'active').length,
|
||
balance: icCards.value.reduce((sum, card) => sum + (Number(card.balance || 0) || 0), 0)
|
||
}));
|
||
const ticketList = computed(() => {
|
||
if (!ticketSearch.value) return tickets.value.slice(0, 50);
|
||
const q = ticketSearch.value.toLowerCase();
|
||
return tickets.value.filter(t =>
|
||
t.ticket_id.toLowerCase().includes(q) ||
|
||
(t.start && t.start.toLowerCase().includes(q)) ||
|
||
(t.terminal && t.terminal.toLowerCase().includes(q))
|
||
).slice(0, 50);
|
||
});
|
||
|
||
const exportFareMap = () => {
|
||
const svgData = fareMapSvg.value;
|
||
if (!svgData) return alert('地图尚未加载');
|
||
|
||
const canvas = document.createElement('canvas');
|
||
const ctx = canvas.getContext('2d');
|
||
const img = new Image();
|
||
|
||
// Extract width/height from SVG string or default
|
||
const matchW = svgData.match(/width="([\d.]+)"/);
|
||
const matchH = svgData.match(/height="([\d.]+)"/);
|
||
const w = matchW ? Number(matchW[1]) : 1000;
|
||
const h = matchH ? Number(matchH[1]) : 1000;
|
||
const scale = 3;
|
||
canvas.width = Math.max(1, Math.round(w * scale));
|
||
canvas.height = Math.max(1, Math.round(h * scale));
|
||
|
||
const blob = new Blob([svgData], {type: 'image/svg+xml;charset=utf-8'});
|
||
const url = URL.createObjectURL(blob);
|
||
|
||
img.onload = () => {
|
||
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(0, 0, w, h);
|
||
ctx.drawImage(img, 0, 0);
|
||
URL.revokeObjectURL(url);
|
||
|
||
const pngUrl = canvas.toDataURL('image/png');
|
||
const a = document.createElement('a');
|
||
a.href = pngUrl;
|
||
a.download = 'fare-map.png';
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
};
|
||
img.src = url;
|
||
};
|
||
|
||
return {
|
||
currentView, viewTitle, connected, sidebarOpen,
|
||
loadingState, isViewBusy, lastSyncText, currentViewSummary,
|
||
stations, lines, fares, stats, config, recentLogs, ticketList,
|
||
logs, logCategory, logTypeFilter, logQuery, logMax, logLoading, fetchLogs,
|
||
orders, orderList, fetchOrders, deleteOrder,
|
||
icCards, icCardSearch, icSelectedId, icSelectedCard, icSelectedEvents, icCreateForm, icDetailForm, icCardStats,
|
||
createIcCard, loadIcCard, saveIcCard, topupIcCard, deleteIcCard, icStatusInfo, icStatusColor, icEventTitle, formatIcEventDetail, cardOrderCode, displayIcCardId, formatMoney,
|
||
showAddLine, showAddStation, newLine, newStation, fareMapSvg, ticketSearch,
|
||
assetsManifest, assetsFarePreview, assetsRouteMapUrl, assetsFareTableUrl,
|
||
uploadRouteMap, uploadFareTable, deleteRouteMap, deleteFareTable,
|
||
|
||
// Management
|
||
selectedLine, fareMode, stationEditMode, fareSelection, showFareModal, currentFare, availableStations,
|
||
visualLineViewport, lineViewportPan, lineEditorSvgWidth, startLineViewportPan, moveLineViewportPan,
|
||
selectLine, createLine, deleteLine, deleteStation, updateLineInfo, getFareText,
|
||
isStationInLine, addStationToLine, removeStationFromLine,
|
||
handleStationClick, isStationSelected,
|
||
onStationDragStart, onStationDragOver, onStationDrop, draggingStationIndex,
|
||
showStationModal, stationForm, stationFormOriginalCode, transferTargets, saveStationSettings, closeStationModal,
|
||
showLineModal, lineForm, openLineModal, saveLineSettings, closeLineModal,
|
||
|
||
// Tickets
|
||
showTicketModal, selectedTicket, viewTicketDetails, closeTicketModal, formatTicketStatus, formatTicketEvent, formatTicketEventLocation, formatTicketEventExtra, formatLogType, formatTrainType,
|
||
|
||
saveCurrentFare, deleteCurrentFare, closeFareModal,
|
||
|
||
saveConfig, exportData, exportFareMap,
|
||
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
|
||
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,
|
||
isTransferStation, getTransferTitleSuffix, getTransferLineBadges
|
||
};
|
||
}
|
||
}).mount('#app');
|