import * as THREE from 'three'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; const parseResponse = async (r) => { const contentType = r.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return r.json(); } const text = await r.text(); return text ? { text } : {}; }; const API = { get: async (url) => { const r = await fetch(url); if (!r.ok) { const text = await r.text(); throw new Error(`${url}: ${r.status} ${text}`); } return parseResponse(r); }, post: async (url, body = {}) => { const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); if (!r.ok) { const text = await r.text(); throw new Error(`${url}: ${r.status} ${text}`); } return parseResponse(r); }, del: async (url) => { const r = await fetch(url, { method: 'DELETE' }); if (!r.ok) { const text = await r.text(); throw new Error(`${url}: ${r.status} ${text}`); } return parseResponse(r); } }; let currentGroup = null; let scene, camera, renderer, sphere, controls; let raycaster = new THREE.Raycaster(); let mouse = new THREE.Vector2(); let markers = []; let tempMarker = null; let previewSprites = []; let tempPreview = null; let scanPollInterval = null; let controlsGloballyDisabled = false; function log(msg) { const panel = document.getElementById('log-panel'); const line = document.createElement('div'); line.textContent = `${new Date().toLocaleTimeString()} ${msg}`; panel.appendChild(line); while (panel.children.length > 200) { panel.removeChild(panel.firstChild); } panel.scrollTop = panel.scrollHeight; } function setStatus(msg) { document.getElementById('status').textContent = `状态:${msg}`; } function setControlsDisabled(disabled) { controlsGloballyDisabled = disabled; ['btn-scan', 'btn-poll-start', 'btn-poll-stop', 'btn-preview', 'btn-add'].forEach(id => { const el = document.getElementById(id); if (el) el.disabled = disabled; }); ['inp-pan', 'inp-tilt', 'inp-zoom', 'inp-dwell'].forEach(id => { const el = document.getElementById(id); if (el) el.disabled = disabled; }); } async function withDisabled(id, fn) { const el = document.getElementById(id); el.disabled = true; try { return await fn(); } finally { if (!controlsGloballyDisabled) { el.disabled = false; } } } function parseStrictFloat(value, name) { const num = Number(value); if (!Number.isFinite(num)) { throw new Error(`${name} 必须是有效数字`); } return num; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function initThree() { const container = document.getElementById('panorama-container'); scene = new THREE.Scene(); camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000); camera.position.set(0, 0, 0.1); renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(container.clientWidth, container.clientHeight); container.appendChild(renderer.domElement); controls = new OrbitControls(camera, renderer.domElement); controls.enableZoom = true; controls.enablePan = false; controls.rotateSpeed = -0.3; controls.minPolarAngle = 0.1; controls.maxPolarAngle = Math.PI - 0.1; renderer.domElement.addEventListener('click', onPanoramaClick); window.addEventListener('resize', onResize); animate(); } function onResize() { const container = document.getElementById('panorama-container'); camera.aspect = container.clientWidth / container.clientHeight; camera.updateProjectionMatrix(); renderer.setSize(container.clientWidth, container.clientHeight); } function loadPanorama(groupId) { clearMarkers(); const loader = new THREE.TextureLoader(); loader.load(`/api/panorama/${groupId}`, (texture) => { if (sphere) scene.remove(sphere); const geo = new THREE.SphereGeometry(50, 60, 40); geo.scale(-1, 1, 1); const mat = new THREE.MeshBasicMaterial({ map: texture }); sphere = new THREE.Mesh(geo, mat); scene.add(sphere); log(`加载全景图: ${groupId}`); loadPoints(groupId); }, undefined, () => { log('全景图未就绪,请先执行扫描'); } ); } function clearMarkers() { markers.forEach(m => scene.remove(m)); markers = []; } function clearTempMarker() { if (tempMarker) { scene.remove(tempMarker); tempMarker = null; } } function setTempMarker(pan, tilt) { clearTempMarker(); tempMarker = addMarker(pan, tilt, 0x3b82f6); } function clearPreviewSprites() { previewSprites.forEach(s => scene.remove(s)); previewSprites = []; } function addPreviewPlane(pan, tilt, imageUrl) { const loader = new THREE.TextureLoader(); const mesh = new THREE.Mesh( new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true, opacity: 0.95 }) ); mesh.visible = false; scene.add(mesh); previewSprites.push(mesh); loader.load(imageUrl, (texture) => { const imageWidth = texture.image.width || 100; const imageHeight = texture.image.height || 100; const aspect = imageWidth / imageHeight; const baseSize = 5; mesh.geometry.dispose(); mesh.geometry = new THREE.PlaneGeometry(baseSize * aspect, baseSize); mesh.material.map = texture; mesh.material.needsUpdate = true; const r = 46; const phi = (90 - tilt) * Math.PI / 180; const theta = (pan - 90) * Math.PI / 180; const x = r * Math.sin(phi) * Math.cos(theta); const y = r * Math.cos(phi); const z = r * Math.sin(phi) * Math.sin(theta); mesh.position.set(x, y, z); // 让平面法线朝外(贴合球面) mesh.lookAt(x * 2, y * 2, z * 2); mesh.visible = true; mesh.userData = { type: 'preview', pan, tilt, imageUrl }; }, undefined, (err) => { log(`加载预览图失败: ${err.message || err}`); }); return mesh; } function addMarker(pan, tilt, color = 0x22c55e) { const r = 49.5; const phi = (90 - tilt) * Math.PI / 180; const theta = (pan - 90) * Math.PI / 180; const x = r * Math.sin(phi) * Math.cos(theta); const y = r * Math.cos(phi); const z = r * Math.sin(phi) * Math.sin(theta); const geo = new THREE.SphereGeometry(0.4, 16, 16); const mat = new THREE.MeshBasicMaterial({ color }); const mesh = new THREE.Mesh(geo, mat); mesh.position.set(x, y, z); scene.add(mesh); markers.push(mesh); return mesh; } function onPanoramaClick(event) { if (!sphere) return; const rect = renderer.domElement.getBoundingClientRect(); mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; raycaster.setFromCamera(mouse, camera); const intersects = raycaster.intersectObject(sphere); if (intersects.length === 0) return; const p = intersects[0].point.clone().normalize(); const pan = ((Math.atan2(p.x, -p.z) * 180 / Math.PI) + 360) % 360; const tilt = Math.asin(Math.max(-1, Math.min(1, p.y))) * 180 / Math.PI; document.getElementById('inp-pan').value = pan.toFixed(2); document.getElementById('inp-tilt').value = tilt.toFixed(2); tempPreview = null; setTempMarker(pan, tilt); } function animate() { requestAnimationFrame(animate); if (controls) controls.update(); renderer.render(scene, camera); } async function loadGroups() { try { const status = await API.get('/api/status'); const select = document.getElementById('group-select'); select.innerHTML = ''; const gids = Object.keys(status.groups || {}); gids.forEach(gid => { const opt = document.createElement('option'); opt.value = gid; opt.textContent = gid; select.appendChild(opt); }); if (gids.length === 0) { currentGroup = null; setStatus('未配置摄像头组'); setControlsDisabled(true); return; } setControlsDisabled(false); if (gids.includes(currentGroup)) { select.value = currentGroup; } else { currentGroup = select.options[0].value; onGroupChange(); } } catch (e) { log(`获取状态失败: ${e.message}`); } } function onGroupChange() { currentGroup = document.getElementById('group-select').value; clearTempMarker(); clearPreviewSprites(); tempPreview = null; loadPanorama(currentGroup); renderVideos(currentGroup); loadPoints(currentGroup); } function renderVideos(groupId) { const grid = document.getElementById('video-grid'); grid.innerHTML = ''; ['panorama', 'ptz'].forEach(cam => { const box = document.createElement('div'); box.className = 'video-box'; const title = document.createElement('div'); title.className = 'title'; title.textContent = `${cam} - ${groupId}`; const img = document.createElement('img'); img.src = `/api/live/${cam}/${groupId}?marked=1&t=${Date.now()}`; img.alt = cam; box.appendChild(title); box.appendChild(img); grid.appendChild(box); }); } async function loadPoints(groupId) { try { const data = await API.get(`/api/points/${groupId}`); clearMarkers(); clearPreviewSprites(); const ul = document.getElementById('points'); ul.innerHTML = ''; data.points.forEach(p => { addMarker(p.pan, p.tilt, 0x22c55e); if (p.preview_image) { addPreviewPlane(p.pan, p.tilt, `/api/preview-image?path=${encodeURIComponent(p.preview_image)}`); } const li = document.createElement('li'); const span = document.createElement('span'); span.textContent = `P:${p.pan.toFixed(0)} T:${p.tilt.toFixed(0)}`; const previewBtn = document.createElement('button'); previewBtn.textContent = '预览'; previewBtn.onclick = async () => { document.getElementById('inp-pan').value = p.pan.toFixed(2); document.getElementById('inp-tilt').value = p.tilt.toFixed(2); document.getElementById('inp-zoom').value = p.zoom; await runPreview(groupId, p.pan, p.tilt, p.zoom, p.id); }; const delBtn = document.createElement('button'); delBtn.dataset.id = String(p.id); delBtn.textContent = '删除'; delBtn.onclick = async () => { if (!currentGroup) { log('未选择摄像头组'); return; } if (!confirm('确定删除该扫描点?')) return; delBtn.disabled = true; try { await API.del(`/api/points/${groupId}/${p.id}`); loadPoints(groupId); } catch (e) { log(`删除失败: ${e.message}`); delBtn.disabled = false; } }; li.appendChild(span); li.appendChild(previewBtn); li.appendChild(delBtn); ul.appendChild(li); }); } catch (e) { log(`加载扫描点失败: ${e.message}`); } } async function updateStatus() { if (!currentGroup) return; try { const status = await API.get('/api/status'); const g = status.groups[currentGroup]; if (g) { setStatus(g.polling_state); } } catch (e) { // ignore } } document.getElementById('group-select').addEventListener('change', onGroupChange); document.getElementById('btn-scan').addEventListener('click', async () => { if (!currentGroup) { log('未选择摄像头组'); return; } if (scanPollInterval) return; const scannedGroup = currentGroup; const scanBtn = document.getElementById('btn-scan'); scanBtn.disabled = true; setStatus('扫描中...'); try { await API.post(`/api/scan/${scannedGroup}`); log(`开始扫描: ${scannedGroup}`); scanPollInterval = setInterval(async () => { try { const prog = await API.get(`/api/scan/${scannedGroup}/progress`); const progress = prog.total > 0 ? (prog.current / prog.total) * 100 : 0; if (prog.state === 'done' || prog.state === 'failed' || progress >= 100) { clearInterval(scanPollInterval); scanPollInterval = null; if (!controlsGloballyDisabled) scanBtn.disabled = false; if (prog.state === 'done') { log('扫描完成'); loadPanorama(scannedGroup); loadPoints(scannedGroup); } else if (prog.state === 'failed') { log(`扫描失败: ${prog.error || 'unknown'}`); } } else { setStatus(`扫描中... ${progress.toFixed(0)}%`); } } catch (e) { clearInterval(scanPollInterval); scanPollInterval = null; if (!controlsGloballyDisabled) scanBtn.disabled = false; setStatus('扫描失败'); log(`扫描进度获取失败: ${e.message}`); } }, 1000); } catch (e) { log(`扫描失败: ${e.message}`); if (!controlsGloballyDisabled) scanBtn.disabled = false; setStatus('扫描失败'); } }); document.getElementById('btn-poll-start').addEventListener('click', async () => { if (!currentGroup) { log('未选择摄像头组'); return; } await withDisabled('btn-poll-start', async () => { try { await API.post(`/api/poll/${currentGroup}/start`); log(`开始轮询: ${currentGroup}`); } catch (e) { log(`轮询启动失败: ${e.message}`); } }); }); document.getElementById('btn-poll-stop').addEventListener('click', async () => { if (!currentGroup) { log('未选择摄像头组'); return; } await withDisabled('btn-poll-stop', async () => { try { await API.post(`/api/poll/${currentGroup}/stop`); log(`停止轮询: ${currentGroup}`); } catch (e) { log(`停止失败: ${e.message}`); } }); }); async function runPreview(groupId, pan, tilt, zoom, pointId = null) { const payload = { pan, tilt, zoom }; if (pointId !== null) { payload.point_id = pointId; } const result = await API.post(`/api/preview/${groupId}`, payload); log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`); if (result.snapshot_url) { log(`预览抓拍已保存: ${result.snapshot_path}`); if (pointId === null) { // 未保存的临时点:记录本次预览图,保存时可关联 if (tempPreview && tempPreview.mesh) { scene.remove(tempPreview.mesh); previewSprites = previewSprites.filter(s => s !== tempPreview.mesh); } clearTempMarker(); const mesh = addPreviewPlane(pan, tilt, result.snapshot_url); tempPreview = { pan, tilt, url: result.snapshot_url, path: result.snapshot_path, mesh }; } else { // 已保存点:刷新列表以显示最新预览图 loadPoints(groupId); } } return result; } document.getElementById('btn-preview').addEventListener('click', async () => { if (!currentGroup) { log('未选择摄像头组'); return; } let pan, tilt, zoom; try { pan = parseStrictFloat(document.getElementById('inp-pan').value, 'pan'); tilt = parseStrictFloat(document.getElementById('inp-tilt').value, 'tilt'); zoom = Number(document.getElementById('inp-zoom').value); } catch (e) { log(`错误:${e.message}`); return; } if (!Number.isInteger(zoom) || zoom < 1) { log('错误:zoom 必须是大于等于 1 的整数'); return; } await withDisabled('btn-preview', async () => { try { await runPreview(currentGroup, pan, tilt, zoom); } catch (e) { log(`预览失败: ${e.message}`); } }); }); document.getElementById('btn-add').addEventListener('click', async () => { if (!currentGroup) { log('未选择摄像头组'); return; } await withDisabled('btn-add', async () => { let pan, tilt, zoom, dwellTime; try { pan = parseStrictFloat(document.getElementById('inp-pan').value, 'pan'); tilt = parseStrictFloat(document.getElementById('inp-tilt').value, 'tilt'); zoom = Number(document.getElementById('inp-zoom').value); dwellTime = parseStrictFloat(document.getElementById('inp-dwell').value, '停留时间'); } catch (e) { log(`错误:${e.message}`); return; } if (pan < 0 || pan > 360) { log('错误:pan 必须是 0-360 之间的有限数值'); return; } if (tilt < -90 || tilt > 90) { log('错误:tilt 必须是 -90-90 之间的有限数值'); return; } if (!Number.isInteger(zoom) || zoom < 1) { log('错误:zoom 必须是大于等于 1 的整数'); return; } if (dwellTime <= 0) { log('错误:停留时间必须是大于 0 的有限数值'); return; } const payload = { pan, tilt, zoom, dwell_time: dwellTime }; if (tempPreview) { payload.preview_image = tempPreview.path; } try { await API.post(`/api/points/${currentGroup}`, payload); log('扫描点已保存'); clearTempMarker(); tempPreview = null; loadPoints(currentGroup); } catch (e) { log(`保存失败: ${e.message}`); } }); }); initThree(); setControlsDisabled(true); loadGroups(); setInterval(updateStatus, 2000);