duqing2026's picture
fix: localize static assets and fix JS reference error
03cf3a8
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[[ project.title_cn ]] - [[ project.name ]]</title>
<script src="/static/js/tailwindcss.js"></script>
<script src="/static/js/vue.global.js"></script>
<script src="/static/js/html2canvas.min.js"></script>
<link rel="stylesheet" href="https://cdn.staticfile.org/font-awesome/6.4.0/css/all.min.css">
<style>
/* Custom Scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #f1f1f1; }
::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #94a3b8; }
.marker-pulse {
animation: pulse-animation 2s infinite;
}
@keyframes pulse-animation {
0% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7); }
70% { box-shadow: 0 0 0 10px rgba(59, 130, 246, 0); }
100% { box-shadow: 0 0 0 0 rgba(59, 130, 246, 0); }
}
.map-container {
position: relative;
overflow: hidden;
user-select: none;
cursor: crosshair;
}
.map-container.preview-mode {
cursor: default;
}
.marker {
position: absolute;
transform: translate(-50%, -50%);
transition: all 0.2s ease;
z-index: 10;
}
.marker:hover {
z-index: 20;
transform: translate(-50%, -50%) scale(1.2);
}
.tooltip-card {
position: absolute;
z-index: 30;
background: white;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
padding: 1rem;
width: 250px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
transform: translate(-50%, -110%); /* Default above */
}
.tooltip-card.active {
opacity: 1;
pointer-events: auto;
}
/* Arrow */
.tooltip-card::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -8px;
border-width: 8px;
border-style: solid;
border-color: white transparent transparent transparent;
}
[v-cloak] { display: none; }
</style>
</head>
<body class="bg-slate-50 text-slate-800 h-screen flex flex-col overflow-hidden">
<div id="app" v-cloak class="flex flex-col h-full">
<!-- Header -->
<header class="bg-white border-b border-slate-200 px-6 py-3 flex justify-between items-center shadow-sm z-50">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-blue-600 rounded-lg flex items-center justify-center text-white text-xl">
<i class="fa-solid fa-map-location-dot"></i>
</div>
<div>
<h1 class="font-bold text-lg text-slate-800">[[ project.title_cn ]]</h1>
<p class="text-xs text-slate-500">v[[ project.version ]]</p>
</div>
</div>
<div class="flex items-center gap-3">
<button @click="isPreview = !isPreview"
:class="isPreview ? 'bg-indigo-100 text-indigo-700 border-indigo-200' : 'bg-white border-slate-300 hover:bg-slate-50'"
class="px-4 py-2 rounded-lg border text-sm font-medium flex items-center gap-2 transition-colors">
<i :class="isPreview ? 'fa-solid fa-eye' : 'fa-regular fa-eye'"></i>
{{ isPreview ? '退出预览' : '预览交互' }}
</button>
<button @click="exportProject" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 shadow-sm transition-colors">
<i class="fa-solid fa-download"></i>
导出 HTML
</button>
</div>
</header>
<!-- Main Content -->
<div class="flex-1 flex overflow-hidden">
<!-- Left Sidebar (Settings) -->
<aside class="w-80 bg-white border-r border-slate-200 flex flex-col z-40">
<div class="p-4 border-b border-slate-100">
<h2 class="font-semibold text-slate-800 mb-4">地图设置</h2>
<!-- Image Upload -->
<div class="mb-6">
<label class="block text-sm font-medium text-slate-700 mb-2">底图上传</label>
<div class="relative border-2 border-dashed border-slate-300 rounded-lg p-6 text-center hover:border-blue-500 transition-colors bg-slate-50 cursor-pointer"
@dragover.prevent @drop.prevent="handleDrop">
<input type="file" ref="fileInput" @change="handleFileChange" accept="image/*" class="absolute inset-0 w-full h-full opacity-0 cursor-pointer">
<div v-if="!bgImage">
<i class="fa-regular fa-image text-2xl text-slate-400 mb-2"></i>
<p class="text-xs text-slate-500">点击或拖拽上传图片</p>
<button @click.stop="loadDemoImage" class="mt-2 text-xs text-blue-600 hover:underline">使用演示地图</button>
</div>
<div v-else class="relative h-20 w-full">
<img :src="bgImage" class="h-full w-full object-cover rounded">
<button @click.stop="bgImage = null; markers = []" class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-5 h-5 flex items-center justify-center text-xs hover:bg-red-600">×</button>
</div>
</div>
</div>
<!-- Marker Editor (Only visible when a marker is selected) -->
<div v-if="selectedMarkerIndex !== -1" class="animate-fade-in">
<div class="flex justify-between items-center mb-3">
<h3 class="font-medium text-slate-700 text-sm">编辑热点 #{{ selectedMarkerIndex + 1 }}</h3>
<button @click="deleteMarker(selectedMarkerIndex)" class="text-red-500 text-xs hover:text-red-700">
<i class="fa-solid fa-trash"></i> 删除
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-xs font-medium text-slate-500 mb-1">标题</label>
<input v-model="markers[selectedMarkerIndex].title" type="text" class="w-full px-3 py-2 border border-slate-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" placeholder="输入标题...">
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1">描述内容</label>
<textarea v-model="markers[selectedMarkerIndex].desc" rows="3" class="w-full px-3 py-2 border border-slate-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 outline-none" placeholder="输入描述..."></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-slate-500 mb-1">图标</label>
<select v-model="markers[selectedMarkerIndex].icon" class="w-full px-2 py-2 border border-slate-300 rounded text-sm bg-white">
<option value="fa-location-dot">📍 定位</option>
<option value="fa-circle-info">ℹ️ 信息</option>
<option value="fa-star">⭐ 星标</option>
<option value="fa-camera">📷 相机</option>
<option value="fa-shop">🏪 商店</option>
<option value="fa-restroom">🚻 洗手间</option>
<option value="fa-utensils">🍴 餐饮</option>
<option value="fa-plus">➕ 加号</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-slate-500 mb-1">颜色</label>
<input type="color" v-model="markers[selectedMarkerIndex].color" class="w-full h-9 border border-slate-300 rounded cursor-pointer">
</div>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-slate-400 bg-slate-50 rounded-lg border border-dashed border-slate-200">
<p class="text-sm">点击地图任意位置<br>添加新热点</p>
</div>
</div>
<!-- Markers List -->
<div class="flex-1 overflow-y-auto p-4">
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider mb-3">热点列表 ({{ markers.length }})</h3>
<div class="space-y-2">
<div v-for="(marker, index) in markers" :key="index"
@click="selectMarker(index)"
:class="{'border-blue-500 ring-1 ring-blue-500 bg-blue-50': selectedMarkerIndex === index, 'border-slate-200 hover:border-blue-300': selectedMarkerIndex !== index}"
class="p-3 border rounded-lg cursor-pointer transition-all flex items-start gap-3 bg-white">
<div class="w-6 h-6 rounded-full flex items-center justify-center text-white text-xs shrink-0" :style="{backgroundColor: marker.color}">
<i class="fa-solid" :class="marker.icon"></i>
</div>
<div class="overflow-hidden">
<div class="font-medium text-sm truncate text-slate-700">{{ marker.title || '未命名热点' }}</div>
<div class="text-xs text-slate-500 truncate">{{ marker.desc || '无描述' }}</div>
</div>
</div>
</div>
</div>
</aside>
<!-- Canvas Area -->
<main class="flex-1 bg-slate-100 relative overflow-auto flex items-center justify-center p-8">
<div v-if="!bgImage" class="text-center text-slate-400">
<i class="fa-regular fa-image text-6xl mb-4 opacity-30"></i>
<p>请先上传底图</p>
</div>
<div v-else
ref="mapContainer"
class="map-container shadow-2xl rounded-lg bg-white relative inline-block"
:class="{'preview-mode': isPreview}"
@click="handleMapClick">
<img :src="bgImage" class="max-w-none" style="max-height: 80vh; display: block;">
<!-- Markers -->
<div v-for="(marker, index) in markers" :key="index"
class="marker w-8 h-8 rounded-full flex items-center justify-center text-white shadow-lg cursor-pointer"
:class="{'marker-pulse': selectedMarkerIndex === index && !isPreview}"
:style="{
left: marker.x + '%',
top: marker.y + '%',
backgroundColor: marker.color,
fontSize: '14px'
}"
@click.stop="handleMarkerClick(index)">
<i class="fa-solid" :class="marker.icon"></i>
<!-- Tooltip (Only visible in preview or if active) -->
<div v-if="isPreview && activeTooltipIndex === index"
class="tooltip-card active"
@click.stop>
<h4 class="font-bold text-slate-800 mb-1">{{ marker.title }}</h4>
<p class="text-sm text-slate-600 leading-relaxed">{{ marker.desc }}</p>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
// Pass Jinja2 variables to JavaScript
const PROJECT_INFO = {
title_cn: "[[ project.title_cn ]]",
version: "[[ project.version ]]",
name: "[[ project.name ]]"
};
const { createApp, ref, reactive, onMounted } = Vue;
createApp({
setup() {
const bgImage = ref(null);
const markers = ref([]);
const selectedMarkerIndex = ref(-1);
const isPreview = ref(false);
const activeTooltipIndex = ref(-1);
const fileInput = ref(null);
const mapContainer = ref(null);
// Demo Image - Local SVG for offline capability
const DEMO_IMAGE = "/static/images/demo_map.svg";
const loadDemoImage = () => {
// Convert URL to Base64 to ensure offline/export works cleanly without CORS issues if possible,
// but for demo simple URL is fine. However, html2canvas prefers base64.
// Let's just use the URL for now, but in a real app we might proxy it.
bgImage.value = DEMO_IMAGE;
markers.value = [
{ x: 25, y: 40, color: '#ef4444', icon: 'fa-bed', title: '主卧室', desc: '宽敞的主卧室,配有独立卫浴和步入式衣帽间。' },
{ x: 55, y: 60, color: '#3b82f6', icon: 'fa-couch', title: '客厅', desc: '开放式客厅,连接餐厅,采光极佳。' },
{ x: 75, y: 30, color: '#10b981', icon: 'fa-utensils', title: '厨房', desc: '现代化厨房,配备高端电器和中岛台。' }
];
};
onMounted(() => {
// Auto-load demo to提供默认数据,确保开箱即用
loadDemoImage();
});
const handleFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
readFile(file);
};
const handleDrop = (e) => {
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith('image/')) {
readFile(file);
}
};
const readFile = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
bgImage.value = e.target.result;
markers.value = [];
selectedMarkerIndex.value = -1;
};
reader.readAsDataURL(file);
};
const handleMapClick = (e) => {
if (isPreview.value) {
activeTooltipIndex.value = -1; // Close tooltips
return;
}
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
markers.value.push({
x: x.toFixed(2),
y: y.toFixed(2),
title: '新热点',
desc: '这里是描述内容...',
color: '#3b82f6',
icon: 'fa-location-dot'
});
selectedMarkerIndex.value = markers.value.length - 1;
};
const handleMarkerClick = (index) => {
if (isPreview.value) {
activeTooltipIndex.value = activeTooltipIndex.value === index ? -1 : index;
} else {
selectedMarkerIndex.value = index;
}
};
const selectMarker = (index) => {
selectedMarkerIndex.value = index;
// Exit preview if selecting from list
isPreview.value = false;
};
const deleteMarker = (index) => {
markers.value.splice(index, 1);
selectedMarkerIndex.value = -1;
};
const exportProject = () => {
if (!bgImage.value) return alert('请先上传图片');
const htmlContent = generateExportHTML(bgImage.value, markers.value, PROJECT_INFO);
const blob = new Blob([htmlContent], { type: 'text/html' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'interactive-map.html';
a.click();
URL.revokeObjectURL(url);
};
// Helper to generate the standalone HTML
const generateExportHTML = (img, markers, info) => {
return `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${info.title_cn} Export</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
body { margin: 0; padding: 0; background: #f8fafc; font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
.map-wrapper { position: relative; display: inline-block; max-width: 100%; box-shadow: 0 20px 25px -5px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; }
.map-img { display: block; max-width: 100%; height: auto; }
.marker {
position: absolute; width: 32px; height: 32px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
color: white; transform: translate(-50%, -50%); cursor: pointer;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
transition: transform 0.2s;
font-size: 14px;
}
.marker:hover { transform: translate(-50%, -50%) scale(1.1); z-index: 100; }
.tooltip {
position: absolute; bottom: 120%; left: 50%; transform: translateX(-50%);
background: white; color: #334155; padding: 12px; border-radius: 6px;
width: 220px; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.1);
opacity: 0; visibility: hidden; transition: all 0.2s; pointer-events: none;
text-align: left;
}
.tooltip h4 { margin: 0 0 4px 0; color: #1e293b; font-size: 14px; }
.tooltip p { margin: 0; font-size: 12px; line-height: 1.4; color: #64748b; }
.tooltip::after {
content: ""; position: absolute; top: 100%; left: 50%; margin-left: -6px;
border-width: 6px; border-style: solid; border-color: white transparent transparent transparent;
}
.marker.active .tooltip { opacity: 1; visibility: visible; pointer-events: auto; }
/* Mobile responsive */
@media (max-width: 640px) {
.tooltip { width: 180px; }
}
</style>
</head>
<body>
<div class="map-wrapper" id="map">
<img src="${img}" class="map-img" alt="Map">
<!-- Markers generated by JS -->
</div>
<script>
const markers = ${JSON.stringify(markers)};
const mapEl = document.getElementById('map');
let activeMarker = null;
markers.forEach((m, i) => {
const el = document.createElement('div');
el.className = 'marker';
el.style.left = m.x + '%';
el.style.top = m.y + '%';
el.style.backgroundColor = m.color;
el.innerHTML = '<i class="fa-solid ' + m.icon + '"></i>' +
'<div class="tooltip">' +
'<h4>' + (m.title || '') + '</h4>' +
'<p>' + (m.desc || '') + '</p>' +
'</div>';
el.addEventListener('click', (e) => {
e.stopPropagation();
// Toggle active class
if (activeMarker && activeMarker !== el) {
activeMarker.classList.remove('active');
}
if (el.classList.contains('active')) {
el.classList.remove('active');
activeMarker = null;
} else {
el.classList.add('active');
activeMarker = el;
}
});
mapEl.appendChild(el);
});
// Close when clicking elsewhere
document.addEventListener('click', () => {
if (activeMarker) {
activeMarker.classList.remove('active');
activeMarker = null;
}
});
<\/script>
</body>
</html>`;
};
return {
bgImage,
markers,
selectedMarkerIndex,
isPreview,
activeTooltipIndex,
fileInput,
mapContainer,
handleFileChange,
handleDrop,
handleMapClick,
handleMarkerClick,
selectMarker,
deleteMarker,
loadDemoImage,
exportProject,
PROJECT_INFO: [[ project | tojson ]]
};
}
}).mount('#app');
</script>
</body>
</html>