feat(web): 新增后台线路拓扑图布局编辑器,允许手动拖拽调整所有站点坐标
This commit is contained in:
+239
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user