feat(web): 新增后台线路拓扑图布局编辑器,允许手动拖拽调整所有站点坐标

This commit is contained in:
2026-07-13 21:07:24 +08:00
parent 6a80db6d1d
commit ae11f9e34b
5 changed files with 340 additions and 30 deletions
+239
View File
@@ -121,6 +121,13 @@ createApp({
const lineFormOriginalId = ref('');
const lineForm = reactive({ id: '', name: '', en_name: '', color: '#3366cc', stations: [] });
// Topology Editor State
const topologyPan = reactive({ x: 0, y: 0, scale: 1, active: false, startX: 0, startY: 0, initialX: 0, initialY: 0 });
const topologyNodes = ref([]);
const topologyLines = ref([]);
const topologyDraggingNode = ref(null);
let topologyNodeDragStart = { x: 0, y: 0, nodeX: 0, nodeY: 0 };
// Legacy/Other State
const fareMapSvg = ref('');
const fareMapScale = ref(1);
@@ -1073,6 +1080,9 @@ createApp({
const found = lines.value.find((line) => line.id === selectedLine.value.id);
selectedLine.value = found || null;
}
if (currentView.value === 'assets') {
initTopologyLayout();
}
coreLoaded = true;
markSynced();
} catch (e) {
@@ -1605,6 +1615,227 @@ createApp({
syncSelectedIcCard().catch(console.error);
});
// Topology Editor Logic
const initTopologyLayout = () => {
const nodesMap = new Map();
const linesArr = lines.value || [];
const stationsArr = stations.value || [];
// Helper to get transfer canonicals
const canonicals = {};
const isTransfer = {};
stationsArr.forEach(s => {
const code = String(s.code || '').trim();
canonicals[code] = code;
isTransfer[code] = false;
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
const canonical = group[0];
group.forEach(g => canonicals[g] = canonical);
}
});
// Count lines per canonical to identify transfer hubs
const lineCountByCanonical = {};
linesArr.forEach(line => {
const stops = Array.isArray(line.stations) ? line.stations : [];
stops.forEach(stop => {
if (stop === '-') return;
const canonical = canonicals[stop] || stop;
if (!lineCountByCanonical[canonical]) lineCountByCanonical[canonical] = new Set();
lineCountByCanonical[canonical].add(line.id);
});
});
Object.keys(lineCountByCanonical).forEach(c => {
if (lineCountByCanonical[c].size > 1) {
isTransfer[c] = true;
}
});
// Initialize Nodes
stationsArr.forEach((s, idx) => {
const code = String(s.code || '').trim();
const canonical = canonicals[code] || code;
if (nodesMap.has(canonical)) return;
nodesMap.set(canonical, {
canonical,
code: code,
name: s.name || code,
color: null,
isTransfer: isTransfer[canonical],
x: Number(s.svg_x) || (100 + (idx % 10) * 80),
y: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80),
originalX: Number(s.svg_x) || (100 + (idx % 10) * 80),
originalY: Number(s.svg_y) || (100 + Math.floor(idx / 10) * 80)
});
});
// Assign colors to nodes based on lines
linesArr.forEach(line => {
const stops = Array.isArray(line.stations) ? line.stations : [];
stops.forEach(stop => {
if (stop === '-') return;
const canonical = canonicals[stop] || stop;
const node = nodesMap.get(canonical);
if (node && !node.color) {
node.color = line.color || '#93a2b7';
}
});
});
topologyNodes.value = Array.from(nodesMap.values());
updateTopologyLines();
};
const updateTopologyLines = () => {
const canonicals = {};
stations.value.forEach(s => {
const code = String(s.code || '').trim();
canonicals[code] = code;
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
const canonical = group[0];
group.forEach(g => canonicals[g] = canonical);
}
});
const newLines = [];
(lines.value || []).forEach(line => {
const stops = Array.isArray(line.stations) ? line.stations : [];
let d = '';
let isFirst = true;
for (let i = 0; i < stops.length; i++) {
const stop = stops[i];
if (stop === '-') {
isFirst = true;
continue;
}
const canonical = canonicals[stop] || stop;
const node = topologyNodes.value.find(n => n.canonical === canonical);
if (!node) continue;
if (isFirst) {
d += `M ${node.x} ${node.y} `;
isFirst = false;
} else {
d += `L ${node.x} ${node.y} `;
}
}
if (d) {
newLines.push({ id: line.id, color: line.color || '#93a2b7', d });
}
});
topologyLines.value = newLines;
};
const onTopologyCanvasMouseDown = (e) => {
topologyPan.active = true;
topologyPan.startX = e.clientX;
topologyPan.startY = e.clientY;
topologyPan.initialX = topologyPan.x;
topologyPan.initialY = topologyPan.y;
};
const onTopologyCanvasMouseMove = (e) => {
if (topologyPan.active) {
topologyPan.x = topologyPan.initialX + (e.clientX - topologyPan.startX);
topologyPan.y = topologyPan.initialY + (e.clientY - topologyPan.startY);
} else if (topologyDraggingNode.value) {
const node = topologyNodes.value.find(n => n.canonical === topologyDraggingNode.value);
if (node) {
const dx = (e.clientX - topologyNodeDragStart.x) / topologyPan.scale;
const dy = (e.clientY - topologyNodeDragStart.y) / topologyPan.scale;
// Snap to grid of 20
node.x = Math.round((topologyNodeDragStart.nodeX + dx) / 20) * 20;
node.y = Math.round((topologyNodeDragStart.nodeY + dy) / 20) * 20;
updateTopologyLines();
}
}
};
const onTopologyCanvasMouseUp = () => {
topologyPan.active = false;
topologyDraggingNode.value = null;
};
const onTopologyCanvasWheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.9 : 1.1;
topologyPan.scale = Math.max(0.1, Math.min(3, topologyPan.scale * delta));
};
const onTopologyNodeMouseDown = (e, node) => {
topologyDraggingNode.value = node.canonical;
topologyNodeDragStart.x = e.clientX;
topologyNodeDragStart.y = e.clientY;
topologyNodeDragStart.nodeX = node.x;
topologyNodeDragStart.nodeY = node.y;
};
const resetTopologyLayout = () => {
if (!confirm('确定要放弃当前修改并重置吗?')) return;
topologyNodes.value.forEach(node => {
node.x = node.originalX;
node.y = node.originalY;
});
topologyPan.x = 0;
topologyPan.y = 0;
topologyPan.scale = 1;
updateTopologyLines();
};
const saveTopologyLayout = async () => {
if (!confirm('确定要保存当前站点坐标吗?这会覆盖所有站点的 svg_x 和 svg_y。')) return;
mutationBusy.value = true;
try {
// We need to update svg_x and svg_y for all stations that belong to each canonical group
const canonicals = {};
stations.value.forEach(s => {
const code = String(s.code || '').trim();
canonicals[code] = code;
if (Array.isArray(s.transfer_to) && s.transfer_to.length > 0) {
const group = [code, ...s.transfer_to].map(x => String(x).trim()).sort();
const canonical = group[0];
group.forEach(g => canonicals[g] = canonical);
}
});
const updates = [];
for (const s of stations.value) {
const code = String(s.code || '').trim();
const canonical = canonicals[code] || code;
const node = topologyNodes.value.find(n => n.canonical === canonical);
if (node) {
updates.push({
...s,
svg_x: node.x,
svg_y: node.y
});
}
}
// Batch update one by one
for (const update of updates) {
await fetch(`/api/admin/stations/${encodeURIComponent(update.code)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(update)
});
}
alert('坐标保存成功!');
await fetchData();
initTopologyLayout(); // Re-initialize to update originalX/Y
} catch (e) {
alert('保存失败: ' + e.message);
} finally {
mutationBusy.value = false;
}
};
watch(currentView, (v) => {
sidebarOpen.value = false;
if (v === 'iccards') {
@@ -1612,6 +1843,9 @@ createApp({
} else {
stopIcCardSync();
}
if (v === 'assets') {
setTimeout(initTopologyLayout, 300);
}
ensureViewData(v).catch(console.error);
const sp = new URLSearchParams(location.search);
if (v === 'dashboard') sp.delete('view');
@@ -1787,6 +2021,11 @@ createApp({
saveCurrentFare, deleteCurrentFare, closeFareModal,
// Topology Editor
topologyPan, topologyNodes, topologyLines, topologyDraggingNode,
onTopologyCanvasMouseDown, onTopologyCanvasMouseMove, onTopologyCanvasMouseUp, onTopologyCanvasWheel, onTopologyNodeMouseDown,
resetTopologyLayout, saveTopologyLayout,
saveConfig, isPushingUpdate, pushLuaUpdate, exportData, exportFareMap,
formatTime, formatLogDetail, getStationName, getStationInfo, loadFareMap, fetchData, refreshData: fetchData,
fareMapScale, fareMapLoading, fareMapError, zoomFareMapIn, zoomFareMapOut, zoomFareMapReset,