app.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. import * as THREE from 'three';
  2. import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
  3. const parseResponse = async (r) => {
  4. const contentType = r.headers.get('content-type') || '';
  5. if (contentType.includes('application/json')) {
  6. return r.json();
  7. }
  8. const text = await r.text();
  9. return text ? { text } : {};
  10. };
  11. const API = {
  12. get: async (url) => {
  13. const r = await fetch(url);
  14. if (!r.ok) {
  15. const text = await r.text();
  16. throw new Error(`${url}: ${r.status} ${text}`);
  17. }
  18. return parseResponse(r);
  19. },
  20. post: async (url, body = {}) => {
  21. const r = await fetch(url, {
  22. method: 'POST',
  23. headers: { 'Content-Type': 'application/json' },
  24. body: JSON.stringify(body)
  25. });
  26. if (!r.ok) {
  27. const text = await r.text();
  28. throw new Error(`${url}: ${r.status} ${text}`);
  29. }
  30. return parseResponse(r);
  31. },
  32. del: async (url) => {
  33. const r = await fetch(url, { method: 'DELETE' });
  34. if (!r.ok) {
  35. const text = await r.text();
  36. throw new Error(`${url}: ${r.status} ${text}`);
  37. }
  38. return parseResponse(r);
  39. }
  40. };
  41. let currentGroup = null;
  42. let scene, camera, renderer, sphere, controls;
  43. let raycaster = new THREE.Raycaster();
  44. let mouse = new THREE.Vector2();
  45. let markers = [];
  46. let scanPollInterval = null;
  47. let controlsGloballyDisabled = false;
  48. function log(msg) {
  49. const panel = document.getElementById('log-panel');
  50. const line = document.createElement('div');
  51. line.textContent = `${new Date().toLocaleTimeString()} ${msg}`;
  52. panel.appendChild(line);
  53. while (panel.children.length > 200) {
  54. panel.removeChild(panel.firstChild);
  55. }
  56. panel.scrollTop = panel.scrollHeight;
  57. }
  58. function setStatus(msg) {
  59. document.getElementById('status').textContent = `状态:${msg}`;
  60. }
  61. function setControlsDisabled(disabled) {
  62. controlsGloballyDisabled = disabled;
  63. ['btn-scan', 'btn-poll-start', 'btn-poll-stop', 'btn-preview', 'btn-add'].forEach(id => {
  64. const el = document.getElementById(id);
  65. if (el) el.disabled = disabled;
  66. });
  67. ['inp-pan', 'inp-tilt', 'inp-zoom', 'inp-dwell'].forEach(id => {
  68. const el = document.getElementById(id);
  69. if (el) el.disabled = disabled;
  70. });
  71. }
  72. async function withDisabled(id, fn) {
  73. const el = document.getElementById(id);
  74. el.disabled = true;
  75. try {
  76. return await fn();
  77. } finally {
  78. if (!controlsGloballyDisabled) {
  79. el.disabled = false;
  80. }
  81. }
  82. }
  83. function parseStrictFloat(value, name) {
  84. const num = Number(value);
  85. if (!Number.isFinite(num)) {
  86. throw new Error(`${name} 必须是有效数字`);
  87. }
  88. return num;
  89. }
  90. function escapeHtml(text) {
  91. const div = document.createElement('div');
  92. div.textContent = text;
  93. return div.innerHTML;
  94. }
  95. function initThree() {
  96. const container = document.getElementById('panorama-container');
  97. scene = new THREE.Scene();
  98. camera = new THREE.PerspectiveCamera(75, container.clientWidth / container.clientHeight, 0.1, 1000);
  99. camera.position.set(0, 0, 0.1);
  100. renderer = new THREE.WebGLRenderer({ antialias: true });
  101. renderer.setSize(container.clientWidth, container.clientHeight);
  102. container.appendChild(renderer.domElement);
  103. controls = new OrbitControls(camera, renderer.domElement);
  104. controls.enableZoom = true;
  105. controls.enablePan = false;
  106. controls.rotateSpeed = -0.3;
  107. controls.minPolarAngle = 0.1;
  108. controls.maxPolarAngle = Math.PI - 0.1;
  109. renderer.domElement.addEventListener('click', onPanoramaClick);
  110. window.addEventListener('resize', onResize);
  111. animate();
  112. }
  113. function onResize() {
  114. const container = document.getElementById('panorama-container');
  115. camera.aspect = container.clientWidth / container.clientHeight;
  116. camera.updateProjectionMatrix();
  117. renderer.setSize(container.clientWidth, container.clientHeight);
  118. }
  119. function loadPanorama(groupId) {
  120. clearMarkers();
  121. const loader = new THREE.TextureLoader();
  122. loader.load(`/api/panorama/${groupId}`,
  123. (texture) => {
  124. if (sphere) scene.remove(sphere);
  125. const geo = new THREE.SphereGeometry(50, 60, 40);
  126. geo.scale(-1, 1, 1);
  127. const mat = new THREE.MeshBasicMaterial({ map: texture });
  128. sphere = new THREE.Mesh(geo, mat);
  129. scene.add(sphere);
  130. log(`加载全景图: ${groupId}`);
  131. loadPoints(groupId);
  132. },
  133. undefined,
  134. () => {
  135. log('全景图未就绪,请先执行扫描');
  136. }
  137. );
  138. }
  139. function clearMarkers() {
  140. markers.forEach(m => scene.remove(m));
  141. markers = [];
  142. }
  143. function addMarker(pan, tilt, color = 0x22c55e) {
  144. const r = 49.5;
  145. const phi = (90 - tilt) * Math.PI / 180;
  146. const theta = (pan - 90) * Math.PI / 180;
  147. const x = r * Math.sin(phi) * Math.cos(theta);
  148. const y = r * Math.cos(phi);
  149. const z = r * Math.sin(phi) * Math.sin(theta);
  150. const geo = new THREE.SphereGeometry(0.4, 16, 16);
  151. const mat = new THREE.MeshBasicMaterial({ color });
  152. const mesh = new THREE.Mesh(geo, mat);
  153. mesh.position.set(x, y, z);
  154. scene.add(mesh);
  155. markers.push(mesh);
  156. return mesh;
  157. }
  158. function onPanoramaClick(event) {
  159. if (!sphere) return;
  160. const rect = renderer.domElement.getBoundingClientRect();
  161. mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  162. mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
  163. raycaster.setFromCamera(mouse, camera);
  164. const intersects = raycaster.intersectObject(sphere);
  165. if (intersects.length === 0) return;
  166. const p = intersects[0].point.clone().normalize();
  167. const pan = ((Math.atan2(p.x, -p.z) * 180 / Math.PI) + 360) % 360;
  168. const tilt = Math.asin(Math.max(-1, Math.min(1, p.y))) * 180 / Math.PI;
  169. document.getElementById('inp-pan').value = pan.toFixed(2);
  170. document.getElementById('inp-tilt').value = tilt.toFixed(2);
  171. addMarker(pan, tilt, 0x3b82f6);
  172. }
  173. function animate() {
  174. requestAnimationFrame(animate);
  175. if (controls) controls.update();
  176. renderer.render(scene, camera);
  177. }
  178. async function loadGroups() {
  179. try {
  180. const status = await API.get('/api/status');
  181. const select = document.getElementById('group-select');
  182. select.innerHTML = '';
  183. const gids = Object.keys(status.groups || {});
  184. gids.forEach(gid => {
  185. const opt = document.createElement('option');
  186. opt.value = gid;
  187. opt.textContent = gid;
  188. select.appendChild(opt);
  189. });
  190. if (gids.length === 0) {
  191. currentGroup = null;
  192. setStatus('未配置摄像头组');
  193. setControlsDisabled(true);
  194. return;
  195. }
  196. setControlsDisabled(false);
  197. if (gids.includes(currentGroup)) {
  198. select.value = currentGroup;
  199. } else {
  200. currentGroup = select.options[0].value;
  201. onGroupChange();
  202. }
  203. } catch (e) {
  204. log(`获取状态失败: ${e.message}`);
  205. }
  206. }
  207. function onGroupChange() {
  208. currentGroup = document.getElementById('group-select').value;
  209. loadPanorama(currentGroup);
  210. renderVideos(currentGroup);
  211. loadPoints(currentGroup);
  212. }
  213. function renderVideos(groupId) {
  214. const grid = document.getElementById('video-grid');
  215. grid.innerHTML = '';
  216. ['panorama', 'ptz'].forEach(cam => {
  217. const box = document.createElement('div');
  218. box.className = 'video-box';
  219. const title = document.createElement('div');
  220. title.className = 'title';
  221. title.textContent = `${cam} - ${groupId}`;
  222. const img = document.createElement('img');
  223. img.src = `/api/live/${cam}/${groupId}?marked=1&t=${Date.now()}`;
  224. img.alt = cam;
  225. box.appendChild(title);
  226. box.appendChild(img);
  227. grid.appendChild(box);
  228. });
  229. }
  230. async function loadPoints(groupId) {
  231. try {
  232. const data = await API.get(`/api/points/${groupId}`);
  233. clearMarkers();
  234. const ul = document.getElementById('points');
  235. ul.innerHTML = '';
  236. data.points.forEach(p => {
  237. addMarker(p.pan, p.tilt, 0x22c55e);
  238. const li = document.createElement('li');
  239. const span = document.createElement('span');
  240. span.textContent = `P:${p.pan.toFixed(0)} T:${p.tilt.toFixed(0)}`;
  241. const btn = document.createElement('button');
  242. btn.dataset.id = String(p.id);
  243. btn.textContent = '删除';
  244. btn.onclick = async () => {
  245. if (!currentGroup) {
  246. log('未选择摄像头组');
  247. return;
  248. }
  249. if (!confirm('确定删除该扫描点?')) return;
  250. btn.disabled = true;
  251. try {
  252. await API.del(`/api/points/${groupId}/${p.id}`);
  253. loadPoints(groupId);
  254. } catch (e) {
  255. log(`删除失败: ${e.message}`);
  256. btn.disabled = false;
  257. }
  258. };
  259. li.appendChild(span);
  260. li.appendChild(btn);
  261. ul.appendChild(li);
  262. });
  263. } catch (e) {
  264. log(`加载扫描点失败: ${e.message}`);
  265. }
  266. }
  267. async function updateStatus() {
  268. if (!currentGroup) return;
  269. try {
  270. const status = await API.get('/api/status');
  271. const g = status.groups[currentGroup];
  272. if (g) {
  273. setStatus(g.polling_state);
  274. }
  275. } catch (e) {
  276. // ignore
  277. }
  278. }
  279. document.getElementById('group-select').addEventListener('change', onGroupChange);
  280. document.getElementById('btn-scan').addEventListener('click', async () => {
  281. if (!currentGroup) {
  282. log('未选择摄像头组');
  283. return;
  284. }
  285. if (scanPollInterval) return;
  286. const scannedGroup = currentGroup;
  287. const scanBtn = document.getElementById('btn-scan');
  288. scanBtn.disabled = true;
  289. setStatus('扫描中...');
  290. try {
  291. await API.post(`/api/scan/${scannedGroup}`);
  292. log(`开始扫描: ${scannedGroup}`);
  293. scanPollInterval = setInterval(async () => {
  294. try {
  295. const prog = await API.get(`/api/scan/${scannedGroup}/progress`);
  296. const progress = prog.total > 0 ? (prog.current / prog.total) * 100 : 0;
  297. if (prog.state === 'done' || prog.state === 'failed' || progress >= 100) {
  298. clearInterval(scanPollInterval);
  299. scanPollInterval = null;
  300. if (!controlsGloballyDisabled) scanBtn.disabled = false;
  301. if (prog.state === 'done') {
  302. log('扫描完成');
  303. loadPanorama(scannedGroup);
  304. loadPoints(scannedGroup);
  305. } else if (prog.state === 'failed') {
  306. log(`扫描失败: ${prog.error || 'unknown'}`);
  307. }
  308. } else {
  309. setStatus(`扫描中... ${progress.toFixed(0)}%`);
  310. }
  311. } catch (e) {
  312. clearInterval(scanPollInterval);
  313. scanPollInterval = null;
  314. if (!controlsGloballyDisabled) scanBtn.disabled = false;
  315. setStatus('扫描失败');
  316. log(`扫描进度获取失败: ${e.message}`);
  317. }
  318. }, 1000);
  319. } catch (e) {
  320. log(`扫描失败: ${e.message}`);
  321. if (!controlsGloballyDisabled) scanBtn.disabled = false;
  322. setStatus('扫描失败');
  323. }
  324. });
  325. document.getElementById('btn-poll-start').addEventListener('click', async () => {
  326. if (!currentGroup) {
  327. log('未选择摄像头组');
  328. return;
  329. }
  330. await withDisabled('btn-poll-start', async () => {
  331. try {
  332. await API.post(`/api/poll/${currentGroup}/start`);
  333. log(`开始轮询: ${currentGroup}`);
  334. } catch (e) {
  335. log(`轮询启动失败: ${e.message}`);
  336. }
  337. });
  338. });
  339. document.getElementById('btn-poll-stop').addEventListener('click', async () => {
  340. if (!currentGroup) {
  341. log('未选择摄像头组');
  342. return;
  343. }
  344. await withDisabled('btn-poll-stop', async () => {
  345. try {
  346. await API.post(`/api/poll/${currentGroup}/stop`);
  347. log(`停止轮询: ${currentGroup}`);
  348. } catch (e) {
  349. log(`停止失败: ${e.message}`);
  350. }
  351. });
  352. });
  353. document.getElementById('btn-preview').addEventListener('click', async () => {
  354. if (!currentGroup) {
  355. log('未选择摄像头组');
  356. return;
  357. }
  358. let pan, tilt, zoom;
  359. try {
  360. pan = parseStrictFloat(document.getElementById('inp-pan').value, 'pan');
  361. tilt = parseStrictFloat(document.getElementById('inp-tilt').value, 'tilt');
  362. zoom = Number(document.getElementById('inp-zoom').value);
  363. } catch (e) {
  364. log(`错误:${e.message}`);
  365. return;
  366. }
  367. if (!Number.isInteger(zoom) || zoom < 1) {
  368. log('错误:zoom 必须是大于等于 1 的整数');
  369. return;
  370. }
  371. log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`);
  372. // Preview is implemented by the user visually confirming via the PTZ live stream window.
  373. // A future backend endpoint could move the PTZ to this exact point.
  374. });
  375. document.getElementById('btn-add').addEventListener('click', async () => {
  376. if (!currentGroup) {
  377. log('未选择摄像头组');
  378. return;
  379. }
  380. await withDisabled('btn-add', async () => {
  381. let pan, tilt, zoom, dwellTime;
  382. try {
  383. pan = parseStrictFloat(document.getElementById('inp-pan').value, 'pan');
  384. tilt = parseStrictFloat(document.getElementById('inp-tilt').value, 'tilt');
  385. zoom = Number(document.getElementById('inp-zoom').value);
  386. dwellTime = parseStrictFloat(document.getElementById('inp-dwell').value, '停留时间');
  387. } catch (e) {
  388. log(`错误:${e.message}`);
  389. return;
  390. }
  391. if (pan < 0 || pan > 360) {
  392. log('错误:pan 必须是 0-360 之间的有限数值');
  393. return;
  394. }
  395. if (tilt < -90 || tilt > 90) {
  396. log('错误:tilt 必须是 -90-90 之间的有限数值');
  397. return;
  398. }
  399. if (!Number.isInteger(zoom) || zoom < 1) {
  400. log('错误:zoom 必须是大于等于 1 的整数');
  401. return;
  402. }
  403. if (dwellTime <= 0) {
  404. log('错误:停留时间必须是大于 0 的有限数值');
  405. return;
  406. }
  407. const payload = { pan, tilt, zoom, dwell_time: dwellTime };
  408. try {
  409. await API.post(`/api/points/${currentGroup}`, payload);
  410. log('扫描点已保存');
  411. loadPoints(currentGroup);
  412. } catch (e) {
  413. log(`保存失败: ${e.message}`);
  414. }
  415. });
  416. });
  417. initThree();
  418. setControlsDisabled(true);
  419. loadGroups();
  420. setInterval(updateStatus, 2000);