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
+4 -4
View File
@@ -16,8 +16,8 @@
"mtimeMs": 1783602619602.658
},
"ticket-order.js": {
"size": 37721,
"mtimeMs": 1783947073044.0798
"size": 38975,
"mtimeMs": 1783947975169.5613
},
"ticket-search.js": {
"size": 17269,
@@ -28,8 +28,8 @@
"mtimeMs": 1783666241687.1482
},
"index.js": {
"size": 82754,
"mtimeMs": 1783779774480.386
"size": 93210,
"mtimeMs": 1783947920227.9448
},
"ic-card-admin.js": {
"size": 19095,
+2 -2
View File
@@ -3,10 +3,10 @@
"blog.css": 2,
"custom-dialog.js": 11,
"public-status.js": 14,
"ticket-order.js": 30,
"ticket-order.js": 31,
"ticket-search.js": 12,
"ticket-route.js": 3,
"index.js": 5,
"index.js": 6,
"ic-card-admin.js": 3,
"ic-card-order.js": 3,
"ic-card-detail.js": 2,
+63 -18
View File
@@ -74,7 +74,7 @@
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
</div>
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
<span class="nav-icon"><i class="fas fa-route"></i></span> 线路图
<span class="nav-icon"><i class="fas fa-route"></i></span> 拓扑图布局
</div>
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'">
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
@@ -794,31 +794,76 @@
</div>
</div>
<div v-if="currentView === 'assets'">
<div class="card mb-4">
<div v-if="currentView === 'assets'" class="assets-editor-container" style="display: flex; flex-direction: column; height: 80vh;">
<div class="card mb-4" style="flex: 1; display: flex; flex-direction: column;">
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
<div class="flex" style="align-items: center; gap: 10px;">
<h4>线路图</h4>
<span class="badge" v-if="assetsManifest.routeMap" style="font-family: monospace;">{{ assetsManifest.routeMap }}</span>
<h4>线路拓扑图布局编辑器</h4>
<span class="badge" style="background: #2563eb; color: #fff;">拖拽站点以自定义位置</span>
</div>
<div class="flex" style="gap: 8px;">
<label class="btn" style="cursor: pointer;">
<i class="fas fa-upload"></i> 上传线路图
<input type="file" hidden accept=".png,.jpg,.jpeg,.webp,.svg" @change="uploadRouteMap">
</label>
<a v-if="assetsRouteMapUrl" :href="assetsRouteMapUrl" target="_blank" class="btn">
<i class="fas fa-external-link-alt"></i> 打开
</a>
<button v-if="assetsManifest.routeMap" class="danger" @click="deleteRouteMap">
<i class="fas fa-trash"></i> 删除
<button class="btn" @click="resetTopologyLayout">
<i class="fas fa-undo"></i> 重置布局
</button>
<button class="btn primary" @click="saveTopologyLayout">
<i class="fas fa-save"></i> 保存坐标
</button>
</div>
</div>
<div v-if="assetsRouteMapUrl" style="border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0b0b0f; padding: 16px;">
<img :src="assetsRouteMapUrl" alt="线路图" style="display:block; width: 100%; height: auto;" ref="mapImageRef">
<div style="flex: 1; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0f172a; position: relative; user-select: none;"
ref="topologyCanvas"
@mousedown="onTopologyCanvasMouseDown"
@mousemove="onTopologyCanvasMouseMove"
@mouseup="onTopologyCanvasMouseUp"
@mouseleave="onTopologyCanvasMouseUp"
@wheel="onTopologyCanvasWheel">
<div :style="{
position: 'absolute',
left: '0', top: '0',
width: '100%', height: '100%',
transformOrigin: '0 0',
transform: `translate(${topologyPan.x}px, ${topologyPan.y}px) scale(${topologyPan.scale})`
}">
<!-- Grid Background (Optional) -->
<svg width="10000" height="10000" style="position: absolute; left: -5000px; top: -5000px; pointer-events: none;">
<defs>
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
<rect width="40" height="40" fill="none" stroke="rgba(255,255,255,0.05)" />
</pattern>
</defs>
<rect width="10000" height="10000" fill="url(#grid)" />
<!-- Lines -->
<path v-for="l in topologyLines" :key="'path-'+l.id" :d="l.d" fill="none" :stroke="l.color" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" opacity="0.6" />
</svg>
<!-- Stations -->
<div v-for="node in topologyNodes" :key="node.canonical"
:style="{
position: 'absolute',
left: node.x + 'px',
top: node.y + 'px',
transform: 'translate(-50%, -50%)',
cursor: 'grab',
zIndex: node.canonical === topologyDraggingNode ? 100 : 10
}"
@mousedown.stop="onTopologyNodeMouseDown($event, node)">
<div v-if="node.isTransfer" style="width: 24px; height: 24px; border-radius: 50%; background: #fff; border: 3px solid #1f2937; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 6px rgba(0,0,0,0.3);">
<div style="width: 10px; height: 10px; border-radius: 50%; background: #cbd5e1;"></div>
</div>
<div v-else :style="{
width: '20px', height: '20px', borderRadius: '50%', background: '#fff',
border: `4px solid ${node.color || '#94a3b8'}`,
boxShadow: '0 2px 4px rgba(0,0,0,0.2)'
}"></div>
<div style="position: absolute; top: 26px; left: 50%; transform: translateX(-50%); white-space: nowrap; font-size: 12px; font-weight: 600; color: #f8fafc; text-shadow: 0 1px 3px rgba(0,0,0,0.8); pointer-events: none;">
{{ node.name }}
</div>
</div>
</div>
</div>
<div v-else class="loading">未上传线路图</div>
</div>
</div>
@@ -920,7 +965,7 @@
</main>
</div>
<script src="/custom-dialog.js?v=11"></script>
<script src="index.js?v=5"></script>
<script src="index.js?v=6"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const isDomain = location.hostname.includes('fse-media.group');
+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,
+32 -6
View File
@@ -209,10 +209,28 @@
}
stationXByCanonical = {};
stationYByCanonical = {};
const customXByCanonical = {};
const customYByCanonical = {};
for (const s of stationsData) {
const code = String(s.code || s.编号 || '').trim();
if (!code) continue;
const canonical = stationCanonicalByCode[code] || code;
if (s.svg_x != null && s.svg_y != null) {
customXByCanonical[canonical] = Number(s.svg_x);
customYByCanonical[canonical] = Number(s.svg_y);
}
}
for (const canonical of Object.keys(occurrences)) {
const arr = occurrences[canonical];
const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length);
stationXByCanonical[canonical] = startX + Math.round(avg);
if (customXByCanonical[canonical] != null) {
stationXByCanonical[canonical] = customXByCanonical[canonical];
} else {
const arr = occurrences[canonical];
const avg = arr.reduce((a,b)=>a+b,0) / Math.max(1, arr.length);
stationXByCanonical[canonical] = startX + Math.round(avg);
}
}
for (let pass = 0; pass < 3; pass++) {
@@ -221,6 +239,10 @@
for (const code of li.stops) {
if (code === '-') continue;
const canonical = stationCanonicalByCode[code] || code;
if (customXByCanonical[canonical] != null) {
prevX = customXByCanonical[canonical];
continue;
}
const x = stationXByCanonical[canonical] ?? (prevX + minGapX);
const nx = Math.max(x, prevX + minGapX);
if (stationXByCanonical[canonical] == null || nx > stationXByCanonical[canonical]) {
@@ -234,18 +256,21 @@
// 3. Assign Grid Y to canonical stations (Average of their lines)
const hubYByCanonical = {};
for (const canonical of Object.keys(lineIdxsByCanonical)) {
if (customYByCanonical[canonical] != null) {
hubYByCanonical[canonical] = customYByCanonical[canonical];
continue;
}
const idxs = Array.from(lineIdxsByCanonical[canonical] || []).sort((a, b) => a - b);
if (!idxs.length) continue;
const ys = idxs.map(idx => baseY + idx * lineGapY);
hubYByCanonical[canonical] = ys.reduce((sum, y) => sum + y, 0) / ys.length;
}
stationYByCanonical = {};
for (const li of lineStops) {
const yNode = baseY + li.idx * lineGapY;
for (const c of li.stops) {
const canonical = stationCanonicalByCode[c] || c;
stationYByCanonical[c] = hubYByCanonical[canonical] ?? yNode;
stationYByCanonical[c] = customYByCanonical[canonical] ?? hubYByCanonical[canonical] ?? yNode;
}
}
@@ -396,8 +421,9 @@
const allCanonicals = Object.keys(stationXByCanonical);
const minX = Math.min(...allCanonicals.map(c => stationXByCanonical[c] || 0));
const maxX = Math.max(...allCanonicals.map(c => stationXByCanonical[c] || 0));
const maxY = Math.max(...allCanonicals.map(c => stationYByCanonical[c] || 0));
const width = Math.max(560, maxX + 200);
const height = Math.max(260, baseY + currentLineIdx * lineGapY + 120);
const height = Math.max(260, maxY + 120, baseY + lineStops.length * lineGapY + 120);
mapContainer.innerHTML = `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${svgContent}</svg>`;
} catch(e) {