feat: 改造线路图上传后台并接入购票页总览图
This commit is contained in:
+33
-64
@@ -74,7 +74,7 @@
|
|||||||
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
<span class="nav-icon"><i class="fas fa-credit-card"></i></span> IC卡管理
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" :class="{active: currentView === 'assets'}" @click="currentView = 'assets'">
|
<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>
|
||||||
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'">
|
<div class="nav-item" :class="{active: currentView === 'settings'}" @click="currentView = 'settings'">
|
||||||
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
|
<span class="nav-icon"><i class="fas fa-cog"></i></span> 系统设置
|
||||||
@@ -794,73 +794,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="currentView === 'assets'" class="assets-editor-container" style="display: flex; flex-direction: column; height: 80vh;">
|
<div v-if="currentView === 'assets'" class="assets-editor-container">
|
||||||
<div class="card mb-4" style="flex: 1; display: flex; flex-direction: column;">
|
<div class="card mb-4">
|
||||||
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
<div class="flex between mb-4" style="flex-wrap: wrap; gap: 10px;">
|
||||||
<div class="flex" style="align-items: center; gap: 10px;">
|
<div>
|
||||||
<h4>线路拓扑图布局编辑器</h4>
|
<h4 style="margin-bottom: 6px;">线路图上传</h4>
|
||||||
<span class="badge" style="background: #2563eb; color: #fff;">拖拽站点以自定义位置</span>
|
<div class="text-muted">上传后会用于首页展示,也会在购票页未选择线路时显示为总览图。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex" style="gap: 8px;">
|
<div class="flex" style="gap: 8px; flex-wrap: wrap;">
|
||||||
<button class="btn" @click="resetTopologyLayout">
|
<label class="btn primary" style="cursor: pointer;">
|
||||||
<i class="fas fa-undo"></i> 重置布局
|
<i class="fas fa-upload"></i> 上传线路图
|
||||||
</button>
|
<input type="file" accept=".png,.jpg,.jpeg,.webp,.svg" style="display: none;" @change="uploadRouteMap">
|
||||||
<button class="btn primary" @click="saveTopologyLayout">
|
</label>
|
||||||
<i class="fas fa-save"></i> 保存坐标
|
<button class="btn" @click="deleteRouteMap" :disabled="!assetsManifest.routeMap || mutationBusy">
|
||||||
|
<i class="fas fa-trash"></i> 删除当前线路图
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="flex: 1; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: #0f172a; position: relative; user-select: none;"
|
<div class="grid" style="display: grid; grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); gap: 16px; align-items: start;">
|
||||||
ref="topologyCanvas"
|
<div style="border: 1px solid var(--border); padding: 16px; background: var(--surface);">
|
||||||
@mousedown="onTopologyCanvasMouseDown"
|
<div style="font-weight: 700; margin-bottom: 12px;">上传说明</div>
|
||||||
@mousemove="onTopologyCanvasMouseMove"
|
<div class="text-muted" style="display: grid; gap: 8px; line-height: 1.7;">
|
||||||
@mouseup="onTopologyCanvasMouseUp"
|
<div>支持格式:PNG、JPG、WEBP、SVG</div>
|
||||||
@mouseleave="onTopologyCanvasMouseUp"
|
<div>建议上传横向长图,便于首页和购票页展示。</div>
|
||||||
@wheel="onTopologyCanvasWheel">
|
<div>再次上传会自动替换旧文件。</div>
|
||||||
<div :style="{
|
</div>
|
||||||
position: 'absolute',
|
<div style="margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); display: grid; gap: 8px;">
|
||||||
left: '0', top: '0',
|
<div><strong>当前文件:</strong> {{ assetsManifest.routeMap || '未上传' }}</div>
|
||||||
width: '100%', height: '100%',
|
<div><strong>更新时间:</strong> {{ assetsManifest.updatedAt ? formatTime(assetsManifest.updatedAt) : '暂无' }}</div>
|
||||||
transformOrigin: '0 0',
|
</div>
|
||||||
transform: `translate(${topologyPan.x}px, ${topologyPan.y}px) scale(${topologyPan.scale})`
|
</div>
|
||||||
}">
|
|
||||||
<!-- Grid Background (Optional) -->
|
<div style="border: 1px solid var(--border); background: #0f172a; min-height: 420px; display: flex; align-items: center; justify-content: center; overflow: auto;">
|
||||||
<svg width="10000" height="10000" style="position: absolute; left: -5000px; top: -5000px; pointer-events: none;">
|
<template v-if="assetsRouteMapUrl">
|
||||||
<defs>
|
<img :src="assetsRouteMapUrl" alt="线路图预览" style="display: block; max-width: 100%; height: auto; background: #ffffff;">
|
||||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
</template>
|
||||||
<rect width="40" height="40" fill="none" stroke="rgba(255,255,255,0.05)" />
|
<div v-else class="text-muted" style="padding: 24px; text-align: center;">
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
@@ -994,5 +965,3 @@
|
|||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1843,9 +1843,6 @@ createApp({
|
|||||||
} else {
|
} else {
|
||||||
stopIcCardSync();
|
stopIcCardSync();
|
||||||
}
|
}
|
||||||
if (v === 'assets') {
|
|
||||||
setTimeout(initTopologyLayout, 300);
|
|
||||||
}
|
|
||||||
ensureViewData(v).catch(console.error);
|
ensureViewData(v).catch(console.error);
|
||||||
const sp = new URLSearchParams(location.search);
|
const sp = new URLSearchParams(location.search);
|
||||||
if (v === 'dashboard') sp.delete('view');
|
if (v === 'dashboard') sp.delete('view');
|
||||||
|
|||||||
+23
-5
@@ -41,7 +41,8 @@
|
|||||||
let lineColors = [];
|
let lineColors = [];
|
||||||
let linesByStation = new Map();
|
let linesByStation = new Map();
|
||||||
let lineIdxsByCanonical = {};
|
let lineIdxsByCanonical = {};
|
||||||
let activeLineIdx = 0;
|
let activeLineIdx = -1;
|
||||||
|
let routeMapUrl = '';
|
||||||
|
|
||||||
const piePath = (cx, cy, r, a0, a1) => {
|
const piePath = (cx, cy, r, a0, a1) => {
|
||||||
const x0 = cx + r * Math.cos(a0);
|
const x0 = cx + r * Math.cos(a0);
|
||||||
@@ -77,6 +78,12 @@
|
|||||||
return (yiq >= 140) ? '#0f172a' : '#ffffff';
|
return (yiq >= 140) ? '#0f172a' : '#ffffff';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const buildAssetUrl = (name, updatedAt) => {
|
||||||
|
if (!name) return '';
|
||||||
|
const version = updatedAt ? String(updatedAt) : String(Date.now());
|
||||||
|
return `/assets/${encodeURIComponent(name)}?v=${encodeURIComponent(version)}`;
|
||||||
|
};
|
||||||
|
|
||||||
function renderStationNode(x, y, code, i, forceColor = null) {
|
function renderStationNode(x, y, code, i, forceColor = null) {
|
||||||
const canonical = stationCanonicalByCode[code] || code;
|
const canonical = stationCanonicalByCode[code] || code;
|
||||||
const codes = stationCodesByCanonical[canonical] || [canonical];
|
const codes = stationCodesByCanonical[canonical] || [canonical];
|
||||||
@@ -165,7 +172,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.selectTvmLine = (idx) => {
|
window.selectTvmLine = (idx) => {
|
||||||
activeLineIdx = idx;
|
activeLineIdx = (activeLineIdx === idx) ? -1 : idx;
|
||||||
renderTvmLines();
|
renderTvmLines();
|
||||||
renderTvmSingleLine();
|
renderTvmSingleLine();
|
||||||
};
|
};
|
||||||
@@ -175,7 +182,11 @@
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
const li = lineStops[activeLineIdx];
|
const li = lineStops[activeLineIdx];
|
||||||
if (!li) {
|
if (!li) {
|
||||||
container.innerHTML = '';
|
if (routeMapUrl) {
|
||||||
|
container.innerHTML = `<div class="tvm-overview tvm-animate"><img src="${routeMapUrl}" alt="线路图总览"></div>`;
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<div class="tvm-overview-empty">暂未选择线路,且暂无已上传线路图。</div>';
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,9 +324,10 @@
|
|||||||
async function renderSystemMap() {
|
async function renderSystemMap() {
|
||||||
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
mapContainer.innerHTML = '<div class="loading">加载线路数据...</div>';
|
||||||
try {
|
try {
|
||||||
const [linesData, stationsData] = await Promise.all([
|
const [linesData, stationsData, assetsData] = await Promise.all([
|
||||||
fetch('/api/public/lines').then(r=>r.json()),
|
fetch('/api/public/lines').then(r=>r.json()),
|
||||||
fetch('/api/public/stations').then(r=>r.json())
|
fetch('/api/public/stations').then(r=>r.json()),
|
||||||
|
fetch('/api/assets/manifest', { cache: 'no-store' }).then(r => r.json()).catch(() => null)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
window.cachedStationsData = stationsData;
|
window.cachedStationsData = stationsData;
|
||||||
@@ -374,6 +386,8 @@
|
|||||||
const uniq = Array.from(new Set(arr));
|
const uniq = Array.from(new Set(arr));
|
||||||
if (uniq.length >= 2) stationTransfer.add(c);
|
if (uniq.length >= 2) stationTransfer.add(c);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
routeMapUrl = (assetsData && assetsData.routeMap) ? buildAssetUrl(assetsData.routeMap, assetsData.updatedAt) : '';
|
||||||
|
|
||||||
const fadeStyle = `<style>
|
const fadeStyle = `<style>
|
||||||
@keyframes tvmFadeIn {
|
@keyframes tvmFadeIn {
|
||||||
@@ -385,6 +399,9 @@
|
|||||||
.tvm-lines { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -8px; margin-bottom: 2px; }
|
.tvm-lines { display: flex; flex-wrap: wrap; gap: 6px; margin-top: -8px; margin-bottom: 2px; }
|
||||||
.tvm-line-btn { flex: 0 0 auto; white-space: nowrap; }
|
.tvm-line-btn { flex: 0 0 auto; white-space: nowrap; }
|
||||||
.tvm-single-line, .tvm-route { overflow-x: auto; padding: 10px 0; min-height: 120px; display: flex; align-items: center; justify-content: flex-start; }
|
.tvm-single-line, .tvm-route { overflow-x: auto; padding: 10px 0; min-height: 120px; display: flex; align-items: center; justify-content: flex-start; }
|
||||||
|
.tvm-overview { width: 100%; min-height: 120px; display: flex; align-items: center; justify-content: center; background: #f8fbf8; border: 1px solid #d7e0d3; }
|
||||||
|
.tvm-overview img { display: block; max-width: 100%; height: auto; background: #ffffff; }
|
||||||
|
.tvm-overview-empty { width: 100%; min-height: 120px; display: flex; align-items: center; justify-content: center; color: #64748b; background: #f8fbf8; border: 1px solid #d7e0d3; padding: 16px; text-align: center; }
|
||||||
.tvm-route-title { font-weight: bold; margin-bottom: -15px; color: #475569; display: none; font-size: 14px; }
|
.tvm-route-title { font-weight: bold; margin-bottom: -15px; color: #475569; display: none; font-size: 14px; }
|
||||||
.tvm-route { margin-top: -4px; }
|
.tvm-route { margin-top: -4px; }
|
||||||
.tvm-scroll-container { scroll-behavior: smooth; -webkit-overflow-scrolling: touch; }
|
.tvm-scroll-container { scroll-behavior: smooth; -webkit-overflow-scrolling: touch; }
|
||||||
@@ -395,6 +412,7 @@
|
|||||||
.tvm-layout { gap: 12px; }
|
.tvm-layout { gap: 12px; }
|
||||||
.tvm-lines { flex-wrap: nowrap; overflow-x: auto; gap: 5px; margin-top: -14px; margin-bottom: -2px; padding-bottom: 2px; }
|
.tvm-lines { flex-wrap: nowrap; overflow-x: auto; gap: 5px; margin-top: -14px; margin-bottom: -2px; padding-bottom: 2px; }
|
||||||
.tvm-single-line, .tvm-route { min-height: 112px; padding: 6px 0 8px; }
|
.tvm-single-line, .tvm-route { min-height: 112px; padding: 6px 0 8px; }
|
||||||
|
.tvm-overview, .tvm-overview-empty { min-height: 112px; }
|
||||||
.tvm-route-title { margin-bottom: -12px; font-size: 13px; }
|
.tvm-route-title { margin-bottom: -12px; font-size: 13px; }
|
||||||
.tvm-route { margin-top: -6px; }
|
.tvm-route { margin-top: -6px; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user