app.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623
  1. const parseResponse = async (r) => {
  2. const contentType = r.headers.get('content-type') || '';
  3. if (contentType.includes('application/json')) {
  4. return r.json();
  5. }
  6. const text = await r.text();
  7. return text ? { text } : {};
  8. };
  9. const API = {
  10. get: async (url) => {
  11. const r = await fetch(url);
  12. if (!r.ok) {
  13. const text = await r.text();
  14. throw new Error(`${url}: ${r.status} ${text}`);
  15. }
  16. return parseResponse(r);
  17. },
  18. post: async (url, body = {}) => {
  19. const r = await fetch(url, {
  20. method: 'POST',
  21. headers: { 'Content-Type': 'application/json' },
  22. body: JSON.stringify(body)
  23. });
  24. if (!r.ok) {
  25. const text = await r.text();
  26. throw new Error(`${url}: ${r.status} ${text}`);
  27. }
  28. return parseResponse(r);
  29. },
  30. del: async (url) => {
  31. const r = await fetch(url, { method: 'DELETE' });
  32. if (!r.ok) {
  33. const text = await r.text();
  34. throw new Error(`${url}: ${r.status} ${text}`);
  35. }
  36. return parseResponse(r);
  37. }
  38. };
  39. let currentGroup = null;
  40. let scanPollInterval = null;
  41. let controlsGloballyDisabled = false;
  42. let tempPreview = null;
  43. let selectedSampleEl = null;
  44. function log(msg) {
  45. const panel = document.getElementById('log-panel');
  46. const line = document.createElement('div');
  47. line.textContent = `${new Date().toLocaleTimeString()} ${msg}`;
  48. panel.appendChild(line);
  49. while (panel.children.length > 200) {
  50. panel.removeChild(panel.firstChild);
  51. }
  52. panel.scrollTop = panel.scrollHeight;
  53. }
  54. function setStatus(msg) {
  55. document.getElementById('status').textContent = `状态:${msg}`;
  56. }
  57. function setControlsDisabled(disabled) {
  58. controlsGloballyDisabled = disabled;
  59. ['btn-scan', 'btn-poll-start', 'btn-poll-stop', 'btn-preview', 'btn-add'].forEach(id => {
  60. const el = document.getElementById(id);
  61. if (el) el.disabled = disabled;
  62. });
  63. ['inp-pan', 'inp-tilt', 'inp-zoom', 'inp-dwell'].forEach(id => {
  64. const el = document.getElementById(id);
  65. if (el) el.disabled = disabled;
  66. });
  67. }
  68. async function withDisabled(id, fn) {
  69. const el = document.getElementById(id);
  70. el.disabled = true;
  71. try {
  72. return await fn();
  73. } finally {
  74. if (!controlsGloballyDisabled) {
  75. el.disabled = false;
  76. }
  77. }
  78. }
  79. function parseStrictFloat(value, name) {
  80. const num = Number(value);
  81. if (!Number.isFinite(num)) {
  82. throw new Error(`${name} 必须是有效数字`);
  83. }
  84. return num;
  85. }
  86. function escapeHtml(text) {
  87. const div = document.createElement('div');
  88. div.textContent = text;
  89. return div.innerHTML;
  90. }
  91. function setSelectedPreview(url) {
  92. const img = document.getElementById('selected-preview');
  93. if (url) {
  94. img.src = url;
  95. img.style.display = 'block';
  96. } else {
  97. img.src = '';
  98. img.style.display = 'none';
  99. }
  100. }
  101. function selectSample(sample) {
  102. document.getElementById('inp-pan').value = sample.pan.toFixed(2);
  103. document.getElementById('inp-tilt').value = sample.tilt.toFixed(2);
  104. document.getElementById('inp-zoom').value = sample.zoom;
  105. tempPreview = { path: sample.thumbnail };
  106. setSelectedPreview(`/api/sample-image?path=${encodeURIComponent(sample.thumbnail)}`);
  107. if (sampleCanvas) sampleCanvas.setSelected(sample.pan, sample.tilt);
  108. }
  109. class SampleCanvas {
  110. constructor(containerId, wrapperId) {
  111. this.container = document.getElementById(containerId);
  112. this.wrapper = document.getElementById(wrapperId);
  113. this.samples = [];
  114. this.pans = [];
  115. this.tilts = [];
  116. this.sampleMap = new Map();
  117. this.cellW = 160;
  118. this.cellH = 120;
  119. this.captionH = 20;
  120. this.scale = 1;
  121. this.selectedPan = null;
  122. this.selectedTilt = null;
  123. this.selectedNodeId = null;
  124. this.graph = null;
  125. this.initGraph();
  126. window.addEventListener('resize', () => this.resize());
  127. }
  128. initGraph() {
  129. const G6 = window.G6;
  130. if (!G6) {
  131. log('G6 库未加载,扫描矩阵不可用');
  132. return;
  133. }
  134. const rect = this.wrapper.getBoundingClientRect();
  135. const width = rect.width || this.wrapper.clientWidth || 800;
  136. const height = rect.height || this.wrapper.clientHeight || 600;
  137. this.graph = new G6.Graph({
  138. container: this.container,
  139. width,
  140. height,
  141. animation: false,
  142. zoomRange: [0.1, 5],
  143. data: { nodes: [], edges: [] },
  144. node: {
  145. type: 'image',
  146. style: {
  147. size: [this.cellW, this.cellH],
  148. cursor: 'pointer',
  149. labelFill: '#cbd5e1',
  150. labelFontSize: 11,
  151. labelPlacement: 'bottom',
  152. labelOffsetY: 4,
  153. radius: 4,
  154. },
  155. state: {
  156. selected: {
  157. halo: true,
  158. haloStroke: '#4ade80',
  159. haloLineWidth: 6,
  160. haloOpacity: 1,
  161. },
  162. },
  163. },
  164. layout: {
  165. type: 'grid',
  166. cols: 1,
  167. nodeSize: [this.cellW, this.cellH + this.captionH],
  168. preventOverlap: true,
  169. },
  170. behaviors: ['drag-canvas', 'zoom-canvas'],
  171. });
  172. this.graph.on('node:click', (e) => {
  173. const target = e.item || e.target;
  174. const id = target?.id;
  175. if (!id) return;
  176. const nodeData = this.graph.getNodeData(id);
  177. if (nodeData?.data?.sample) {
  178. selectSample(nodeData.data.sample);
  179. }
  180. });
  181. this.graph.on('aftertransform', () => {
  182. if (!this.graph) return;
  183. this.scale = this.graph.getZoom();
  184. this.updateZoomLabel();
  185. });
  186. }
  187. async resize() {
  188. if (!this.graph) return;
  189. const rect = this.wrapper.getBoundingClientRect();
  190. this.graph.setSize(rect.width, rect.height);
  191. await this.graph.fitView();
  192. this.scale = this.graph.getZoom();
  193. this.updateZoomLabel();
  194. }
  195. async setSamples(samples) {
  196. this.samples = samples || [];
  197. this.pans = Array.from(new Set(this.samples.map(s => s.pan))).sort((a, b) => a - b);
  198. this.tilts = Array.from(new Set(this.samples.map(s => s.tilt))).sort((a, b) => b - a);
  199. this.sampleMap = new Map();
  200. this.samples.forEach(s => this.sampleMap.set(`${s.pan},${s.tilt}`, s));
  201. if (!this.graph) return;
  202. const nodes = [];
  203. this.tilts.forEach(tilt => {
  204. this.pans.forEach(pan => {
  205. const s = this.sampleMap.get(`${pan},${tilt}`);
  206. if (!s) return;
  207. const id = `p${pan.toFixed(1)}_t${tilt.toFixed(1)}`;
  208. nodes.push({
  209. id,
  210. data: { sample: s },
  211. style: {
  212. src: `/api/sample-image?path=${encodeURIComponent(s.thumbnail)}`,
  213. labelText: `P:${pan.toFixed(0)} T:${tilt.toFixed(0)}`,
  214. },
  215. });
  216. });
  217. });
  218. this.graph.setLayout({
  219. type: 'grid',
  220. cols: this.pans.length || 1,
  221. nodeSize: [this.cellW, this.cellH + this.captionH],
  222. preventOverlap: true,
  223. });
  224. this.graph.setData({ nodes, edges: [] });
  225. await this.graph.render();
  226. if (nodes.length > 0) await this.graph.fitView();
  227. this.selectedNodeId = null;
  228. this.selectedPan = null;
  229. this.selectedTilt = null;
  230. this.scale = this.graph.getZoom();
  231. this.updateZoomLabel();
  232. }
  233. setSelected(pan, tilt) {
  234. if (!this.graph) return;
  235. const prevId = this.selectedNodeId;
  236. this.selectedPan = pan;
  237. this.selectedTilt = tilt;
  238. this.selectedNodeId = pan !== null && tilt !== null
  239. ? `p${pan.toFixed(1)}_t${tilt.toFixed(1)}`
  240. : null;
  241. const states = {};
  242. if (prevId && prevId !== this.selectedNodeId) states[prevId] = [];
  243. if (this.selectedNodeId) states[this.selectedNodeId] = ['selected'];
  244. if (Object.keys(states).length > 0) {
  245. this.graph.setElementState(states, false);
  246. }
  247. }
  248. async resetView() {
  249. if (!this.graph) return;
  250. await this.graph.fitView();
  251. this.scale = this.graph.getZoom();
  252. this.updateZoomLabel();
  253. }
  254. updateZoomLabel() {
  255. const label = document.getElementById('zoom-level');
  256. if (label) label.textContent = `${Math.round(this.scale * 100)}%`;
  257. }
  258. draw() {
  259. if (!this.graph) return;
  260. this.graph.zoomTo(this.scale);
  261. }
  262. }
  263. let sampleCanvas = null;
  264. async function loadSamples(groupId) {
  265. try {
  266. const data = await API.get(`/api/samples/${groupId}`);
  267. if (!sampleCanvas) {
  268. sampleCanvas = new SampleCanvas('sample-canvas', 'sample-grid-wrapper');
  269. }
  270. await sampleCanvas.setSamples(data.samples || []);
  271. } catch (e) {
  272. log(`加载扫描样本失败: ${e.message}`);
  273. }
  274. }
  275. async function loadGroups() {
  276. try {
  277. const status = await API.get('/api/status');
  278. const select = document.getElementById('group-select');
  279. select.innerHTML = '';
  280. const gids = Object.keys(status.groups || {});
  281. gids.forEach(gid => {
  282. const opt = document.createElement('option');
  283. opt.value = gid;
  284. opt.textContent = gid;
  285. select.appendChild(opt);
  286. });
  287. if (gids.length === 0) {
  288. currentGroup = null;
  289. setStatus('未配置摄像头组');
  290. setControlsDisabled(true);
  291. return;
  292. }
  293. setControlsDisabled(false);
  294. if (gids.includes(currentGroup)) {
  295. select.value = currentGroup;
  296. } else {
  297. currentGroup = select.options[0].value;
  298. onGroupChange();
  299. }
  300. } catch (e) {
  301. log(`获取状态失败: ${e.message}`);
  302. }
  303. }
  304. function resetSampleZoom() {
  305. if (sampleCanvas) sampleCanvas.resetView();
  306. }
  307. function onGroupChange() {
  308. currentGroup = document.getElementById('group-select').value;
  309. selectedSampleEl = null;
  310. tempPreview = null;
  311. setSelectedPreview(null);
  312. resetSampleZoom();
  313. loadSamples(currentGroup);
  314. renderVideos(currentGroup);
  315. loadPoints(currentGroup);
  316. }
  317. function renderVideos(groupId) {
  318. const grid = document.getElementById('video-grid');
  319. grid.innerHTML = '';
  320. ['panorama', 'ptz'].forEach(cam => {
  321. const box = document.createElement('div');
  322. box.className = 'video-box';
  323. const title = document.createElement('div');
  324. title.className = 'title';
  325. title.textContent = `${cam} - ${groupId}`;
  326. const img = document.createElement('img');
  327. img.src = `/api/live/${cam}/${groupId}?marked=1&t=${Date.now()}`;
  328. img.alt = cam;
  329. box.appendChild(title);
  330. box.appendChild(img);
  331. grid.appendChild(box);
  332. });
  333. }
  334. async function loadPoints(groupId) {
  335. try {
  336. const data = await API.get(`/api/points/${groupId}`);
  337. const ul = document.getElementById('points');
  338. ul.innerHTML = '';
  339. data.points.forEach(p => {
  340. const li = document.createElement('li');
  341. const span = document.createElement('span');
  342. span.textContent = `P:${p.pan.toFixed(0)} T:${p.tilt.toFixed(0)}`;
  343. const previewBtn = document.createElement('button');
  344. previewBtn.textContent = '预览';
  345. previewBtn.onclick = async () => {
  346. document.getElementById('inp-pan').value = p.pan.toFixed(2);
  347. document.getElementById('inp-tilt').value = p.tilt.toFixed(2);
  348. document.getElementById('inp-zoom').value = p.zoom;
  349. await runPreview(groupId, p.pan, p.tilt, p.zoom, p.id);
  350. };
  351. const delBtn = document.createElement('button');
  352. delBtn.dataset.id = String(p.id);
  353. delBtn.textContent = '删除';
  354. delBtn.onclick = async () => {
  355. if (!currentGroup) {
  356. log('未选择摄像头组');
  357. return;
  358. }
  359. if (!confirm('确定删除该扫描点?')) return;
  360. delBtn.disabled = true;
  361. try {
  362. await API.del(`/api/points/${groupId}/${p.id}`);
  363. loadPoints(groupId);
  364. } catch (e) {
  365. log(`删除失败: ${e.message}`);
  366. delBtn.disabled = false;
  367. }
  368. };
  369. li.appendChild(span);
  370. li.appendChild(previewBtn);
  371. li.appendChild(delBtn);
  372. ul.appendChild(li);
  373. });
  374. } catch (e) {
  375. log(`加载扫描点失败: ${e.message}`);
  376. }
  377. }
  378. async function updateStatus() {
  379. if (!currentGroup) return;
  380. try {
  381. const status = await API.get('/api/status');
  382. const g = status.groups[currentGroup];
  383. if (g) {
  384. setStatus(g.polling_state);
  385. }
  386. } catch (e) {
  387. // ignore
  388. }
  389. }
  390. document.getElementById('group-select').addEventListener('change', onGroupChange);
  391. document.getElementById('btn-scan').addEventListener('click', async () => {
  392. if (!currentGroup) {
  393. log('未选择摄像头组');
  394. return;
  395. }
  396. if (scanPollInterval) return;
  397. const scannedGroup = currentGroup;
  398. const scanBtn = document.getElementById('btn-scan');
  399. scanBtn.disabled = true;
  400. setStatus('扫描中...');
  401. try {
  402. await API.post(`/api/scan/${scannedGroup}`);
  403. log(`开始扫描: ${scannedGroup}`);
  404. scanPollInterval = setInterval(async () => {
  405. try {
  406. const prog = await API.get(`/api/scan/${scannedGroup}/progress`);
  407. const progress = prog.total > 0 ? (prog.current / prog.total) * 100 : 0;
  408. if (prog.state === 'done' || prog.state === 'failed' || progress >= 100) {
  409. clearInterval(scanPollInterval);
  410. scanPollInterval = null;
  411. if (!controlsGloballyDisabled) scanBtn.disabled = false;
  412. if (prog.state === 'done') {
  413. log('扫描完成');
  414. resetSampleZoom();
  415. loadSamples(scannedGroup);
  416. loadPoints(scannedGroup);
  417. } else if (prog.state === 'failed') {
  418. log(`扫描失败: ${prog.error || 'unknown'}`);
  419. }
  420. } else {
  421. setStatus(`扫描中... ${progress.toFixed(0)}%`);
  422. }
  423. } catch (e) {
  424. clearInterval(scanPollInterval);
  425. scanPollInterval = null;
  426. if (!controlsGloballyDisabled) scanBtn.disabled = false;
  427. setStatus('扫描失败');
  428. log(`扫描进度获取失败: ${e.message}`);
  429. }
  430. }, 1000);
  431. } catch (e) {
  432. log(`扫描失败: ${e.message}`);
  433. if (!controlsGloballyDisabled) scanBtn.disabled = false;
  434. setStatus('扫描失败');
  435. }
  436. });
  437. document.getElementById('btn-poll-start').addEventListener('click', async () => {
  438. if (!currentGroup) {
  439. log('未选择摄像头组');
  440. return;
  441. }
  442. await withDisabled('btn-poll-start', async () => {
  443. try {
  444. await API.post(`/api/poll/${currentGroup}/start`);
  445. log(`开始轮询: ${currentGroup}`);
  446. } catch (e) {
  447. log(`轮询启动失败: ${e.message}`);
  448. }
  449. });
  450. });
  451. document.getElementById('btn-poll-stop').addEventListener('click', async () => {
  452. if (!currentGroup) {
  453. log('未选择摄像头组');
  454. return;
  455. }
  456. await withDisabled('btn-poll-stop', async () => {
  457. try {
  458. await API.post(`/api/poll/${currentGroup}/stop`);
  459. log(`停止轮询: ${currentGroup}`);
  460. } catch (e) {
  461. log(`停止失败: ${e.message}`);
  462. }
  463. });
  464. });
  465. async function runPreview(groupId, pan, tilt, zoom, pointId = null) {
  466. const payload = { pan, tilt, zoom };
  467. const result = await API.post(`/api/preview/${groupId}`, payload);
  468. log(`预览位置: P=${pan.toFixed(1)} T=${tilt.toFixed(1)} Z=${zoom}`);
  469. if (result.snapshot_url) {
  470. log(`预览抓拍已保存: ${result.snapshot_path}`);
  471. // 预览抓拍显示在右侧,但不更新保存点图片
  472. setSelectedPreview(result.snapshot_url);
  473. tempPreview = { path: result.snapshot_path };
  474. }
  475. return result;
  476. }
  477. document.getElementById('btn-preview').addEventListener('click', async () => {
  478. if (!currentGroup) {
  479. log('未选择摄像头组');
  480. return;
  481. }
  482. let pan, tilt, zoom;
  483. try {
  484. pan = parseStrictFloat(document.getElementById('inp-pan').value, 'pan');
  485. tilt = parseStrictFloat(document.getElementById('inp-tilt').value, 'tilt');
  486. zoom = Number(document.getElementById('inp-zoom').value);
  487. } catch (e) {
  488. log(`错误:${e.message}`);
  489. return;
  490. }
  491. if (!Number.isInteger(zoom) || zoom < 1) {
  492. log('错误:zoom 必须是大于等于 1 的整数');
  493. return;
  494. }
  495. await withDisabled('btn-preview', async () => {
  496. try {
  497. await runPreview(currentGroup, pan, tilt, zoom);
  498. } catch (e) {
  499. log(`预览失败: ${e.message}`);
  500. }
  501. });
  502. });
  503. document.getElementById('btn-add').addEventListener('click', async () => {
  504. if (!currentGroup) {
  505. log('未选择摄像头组');
  506. return;
  507. }
  508. await withDisabled('btn-add', async () => {
  509. let pan, tilt, zoom, dwellTime;
  510. try {
  511. pan = parseStrictFloat(document.getElementById('inp-pan').value, 'pan');
  512. tilt = parseStrictFloat(document.getElementById('inp-tilt').value, 'tilt');
  513. zoom = Number(document.getElementById('inp-zoom').value);
  514. dwellTime = parseStrictFloat(document.getElementById('inp-dwell').value, '停留时间');
  515. } catch (e) {
  516. log(`错误:${e.message}`);
  517. return;
  518. }
  519. if (pan < 0 || pan > 360) {
  520. log('错误:pan 必须是 0-360 之间的有限数值');
  521. return;
  522. }
  523. if (tilt < -90 || tilt > 90) {
  524. log('错误:tilt 必须是 -90-90 之间的有限数值');
  525. return;
  526. }
  527. if (!Number.isInteger(zoom) || zoom < 1) {
  528. log('错误:zoom 必须是大于等于 1 的整数');
  529. return;
  530. }
  531. if (dwellTime <= 0) {
  532. log('错误:停留时间必须是大于 0 的有限数值');
  533. return;
  534. }
  535. const payload = { pan, tilt, zoom, dwell_time: dwellTime };
  536. if (tempPreview) {
  537. payload.preview_image = tempPreview.path;
  538. }
  539. try {
  540. await API.post(`/api/points/${currentGroup}`, payload);
  541. log('扫描点已保存');
  542. selectedSampleEl = null;
  543. tempPreview = null;
  544. setSelectedPreview(null);
  545. loadPoints(currentGroup);
  546. } catch (e) {
  547. log(`保存失败: ${e.message}`);
  548. }
  549. });
  550. });
  551. document.getElementById('btn-zoom-in').addEventListener('click', () => {
  552. if (sampleCanvas) {
  553. sampleCanvas.scale = Math.min(5.0, sampleCanvas.scale * 1.2);
  554. sampleCanvas.updateZoomLabel();
  555. sampleCanvas.draw();
  556. }
  557. });
  558. document.getElementById('btn-zoom-out').addEventListener('click', () => {
  559. if (sampleCanvas) {
  560. sampleCanvas.scale = Math.max(0.1, sampleCanvas.scale / 1.2);
  561. sampleCanvas.updateZoomLabel();
  562. sampleCanvas.draw();
  563. }
  564. });
  565. document.getElementById('btn-zoom-reset').addEventListener('click', () => {
  566. resetSampleZoom();
  567. });
  568. setControlsDisabled(true);
  569. loadGroups();
  570. setInterval(updateStatus, 2000);