inspection.ejs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. <!DOCTYPE html>
  2. <html lang="zh">
  3. <head>
  4. <meta charset="UTF-8">
  5. <title>巡检助手</title>
  6. <meta name="viewport" content="width=device-width, initial-scale=1">
  7. <link rel="stylesheet" href="/public/css/bootstrap/bootstrap.min.css">
  8. <link rel="stylesheet" href="/public/css/wap/main.css">
  9. <link rel="stylesheet" href="/public/css/toast.css">
  10. <link rel="stylesheet" href="/public/css/font-awesome/font-awesome.min.css">
  11. <link rel="stylesheet" href="/public/css/toastr.css">
  12. <link rel="apple-touch-icon-precomposed" href="/public/images/logo.png">
  13. <link rel="shortcut icon" href="/public/images/logo.png">
  14. <style>
  15. body,
  16. html {
  17. height: 100%;
  18. overflow: hidden;
  19. margin: 0; /* 去掉默认 margin */
  20. padding: 0;
  21. -webkit-overflow-scrolling: touch;
  22. }
  23. body {
  24. background: #f1f3f5;
  25. font-size: 16px;
  26. padding-bottom: 100px;
  27. }
  28. .chat-box {
  29. height: calc(100vh - 100px); /* 减去头部和底部高度,150px 是示例,需根据实际调整 */
  30. overflow-y: auto;
  31. /*padding: 10px;*/
  32. box-sizing: border-box;
  33. }
  34. .message {
  35. display: flex;
  36. margin-bottom: 12px;
  37. padding: 0 15px;
  38. }
  39. .message.user {
  40. justify-content: flex-end;
  41. }
  42. .message.ai {
  43. justify-content: flex-start;
  44. }
  45. .bubble {
  46. max-width: 95%;
  47. padding: 10px 14px;
  48. border-radius: 18px;
  49. line-height: 1.4;
  50. word-wrap: break-word;
  51. }
  52. .message.user .bubble {
  53. background: #007bff;
  54. color: white;
  55. border-bottom-right-radius: 0;
  56. }
  57. .message.ai .bubble {
  58. background: #dee2e6;
  59. color: #333;
  60. border-bottom-left-radius: 0;
  61. }
  62. .chat-footer {
  63. position: fixed;
  64. bottom: 0;
  65. left: 0;
  66. right: 0;
  67. width: 100%;
  68. background: #fff;
  69. padding: 10px env(safe-area-inset-right) calc(10px + env(safe-area-inset-bottom)) env(safe-area-inset-left);
  70. box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.1);
  71. z-index: 999;
  72. }
  73. /*.chat-footer.wx {*/
  74. /* padding-top: 22px; !* 微信才有的上间距 *!*/
  75. /*}*/
  76. .chat-footer.wx .input-group {
  77. margin-bottom: 22px;
  78. }
  79. .chat-footer input.form-control {
  80. border: none;
  81. padding: 10px 12px;
  82. height: 44px;
  83. }
  84. .chat-footer .btn {
  85. border-radius: 0;
  86. }
  87. .form-preview {
  88. background: #fff;
  89. border: 1px solid #dee2e6;
  90. border-radius: 12px;
  91. padding: 15px 20px;
  92. margin: 20px 15px 0 15px;
  93. box-shadow: 0 2px 6px rgba(0,0,0,0.05);
  94. }
  95. .form-preview h6 {
  96. font-weight: bold;
  97. margin-bottom: 16px;
  98. border-bottom: 1px solid #dee2e6;
  99. padding-bottom: 8px;
  100. font-size: 17px;
  101. }
  102. .form-item {
  103. margin-bottom: 12px;
  104. font-size: 15px;
  105. line-height: 1.5;
  106. }
  107. .form-item label {
  108. font-weight: 500;
  109. color: #555;
  110. }
  111. .form-item span {
  112. display: inline-block;
  113. margin-left: 5px;
  114. color: #333;
  115. }
  116. .form-actions {
  117. border-top: 1px solid #dee2e6;
  118. padding-top: 12px;
  119. margin-top: 16px;
  120. display: flex;
  121. justify-content: space-between;
  122. }
  123. /* 1. WebKit 浏览器(Chrome, Safari, Android) */
  124. #chat-box::-webkit-scrollbar {
  125. width: 4px;
  126. }
  127. #chat-box::-webkit-scrollbar-track {
  128. background: transparent;
  129. }
  130. #chat-box::-webkit-scrollbar-thumb {
  131. background-color: rgba(0, 0, 0, 0.3);
  132. border-radius: 4px;
  133. }
  134. /* 2. Firefox */
  135. #chat-box {
  136. scrollbar-width: thin;
  137. scrollbar-color: rgba(0, 0, 0, 0.3) transparent;
  138. }
  139. /* 3. 选项:在小屏上彻底隐藏滚动条(可滑动但看不见) */
  140. @media (max-width: 768px) {
  141. #chat-box::-webkit-scrollbar {
  142. display: none;
  143. }
  144. #chat-box {
  145. -ms-overflow-style: none; /* IE 10+ */
  146. scrollbar-width: none; /* Firefox */
  147. }
  148. }
  149. .input-group {
  150. position: relative;
  151. }
  152. .input-icon {
  153. position: absolute;
  154. left: 10px; /* ---- 改成左边 ---- */
  155. top: 50%;
  156. transform: translateY(-50%);
  157. cursor: pointer;
  158. color: #888;
  159. z-index: 10;
  160. font-size: 18px;
  161. }
  162. /* 激活时高亮 */
  163. #voice-icon.active {
  164. color: #007bff;
  165. }
  166. /* 输入框左边留位置给语音按钮 */
  167. #user-input {
  168. margin-left: 40px !important;
  169. padding-left: 0px;
  170. }
  171. .chat-footer.wx #user-input {
  172. margin-left: 12px !important; /* 或 0px */
  173. padding-left: 0px;
  174. }
  175. .xj-title {
  176. position: sticky;
  177. top: 0;
  178. background: #fff;
  179. }
  180. #recording-toast {
  181. position: fixed;
  182. bottom: 80px;
  183. left: 50%;
  184. transform: translateX(-50%);
  185. background-color: rgba(0, 0, 0, 0.85);
  186. color: #fff;
  187. padding: 12px 20px;
  188. border-radius: 24px;
  189. font-size: 14px;
  190. display: flex;
  191. align-items: center;
  192. z-index: 9999;
  193. animation: fadeInUp 0.3s ease-out;
  194. }
  195. #recording-toast .fa {
  196. margin-right: 8px;
  197. }
  198. @keyframes fadeInUp {
  199. from {
  200. opacity: 0;
  201. transform: translate(-50%, 100%);
  202. }
  203. to {
  204. opacity: 1;
  205. transform: translate(-50%, 0);
  206. }
  207. }
  208. </style>
  209. </head>
  210. <body>
  211. <div class="container mb-3 px-0">
  212. <h5 class="text-center py-2 xj-title mb-0">巡检助手</h5>
  213. <div id="chat-box" class="chat-box mb-3">
  214. <div id="messages"></div>
  215. <!-- 表单预览卡片 -->
  216. <div id="form-preview" class="form-preview d-none">
  217. <h6><i class="fa fa-clipboard-check mr-2"></i>巡检单信息确认</h6>
  218. <div class="form-item">
  219. <label>检查项目:</label>
  220. <span id="field-check_item">—</span>
  221. </div>
  222. <div class="form-item">
  223. <label>现场检查情况:</label>
  224. <span id="field-check_situation">—</span>
  225. </div>
  226. <div class="form-item">
  227. <label>处理要求及措施:</label>
  228. <span id="field-action">—</span>
  229. </div>
  230. <div class="form-item">
  231. <label>检查日期:</label>
  232. <span id="field-date">—</span>
  233. </div>
  234. <div class="form-item">
  235. <label>质检员:</label>
  236. <span id="field-inspector">—</span>
  237. </div>
  238. <div class="form-actions">
  239. <button id="modify-btn" class="btn btn-outline-secondary btn-sm">
  240. <i class="fa fa-edit"></i> 修改
  241. </button>
  242. <button id="confirm-btn" class="btn btn-success btn-sm">
  243. <i class="fa fa-check-circle"></i> 确认生成
  244. </button>
  245. </div>
  246. </div>
  247. </div>
  248. <div class="chat-footer">
  249. <!-- <div class="input-group">-->
  250. <!-- <input type="text" id="user-input" class="form-control" placeholder="请输入...">-->
  251. <!-- <div class="input-icon">-->
  252. <!-- <i id="voice-icon" class="fa fa-microphone"></i>-->
  253. <!-- </div>-->
  254. <!-- <div class="input-group-append">-->
  255. <!-- <button class="btn btn-primary btn-send" id="send-btn">发送</button>-->
  256. <!-- </div>-->
  257. <!-- </div>-->
  258. <div class="input-group">
  259. <!-- 左侧语音按钮 -->
  260. <div class="input-icon" id="voice-container">
  261. <i id="voice-icon" class="fa fa-microphone"></i>
  262. </div>
  263. <input type="text" id="user-input" class="form-control" placeholder="请输入...">
  264. <div class="input-group-append">
  265. <button class="btn btn-primary btn-send" id="send-btn">发送</button>
  266. </div>
  267. </div>
  268. </div>
  269. </div>
  270. <div id="recording-toast" style="display: none;">
  271. <div class="toast-inner">
  272. <i class="fa fa-microphone"></i>
  273. <span>正在录音,请讲话...</span>
  274. </div>
  275. </div>
  276. <script src="/public/js/jquery/jquery-3.2.1.min.js"></script>
  277. <script src="/public/js/popper/popper.min.js"></script>
  278. <script src="/public/js/bootstrap/bootstrap.min.js"></script>
  279. <script src="/public/js/toastr.min.js"></script>
  280. <script src="/public/js/cookies.js"></script>
  281. <script src="/public/js/wap/global.js"></script>
  282. <script>
  283. const csrf = '<%= ctx.csrf %>';
  284. </script>
  285. <script>
  286. const $chatBox = $('#chat-box');
  287. const $formPreview = $('#form-preview');
  288. let conversationId = '';
  289. $(function () {
  290. const appendMessage = (text, sender = 'user', cssClass = '') => {
  291. const formatted = text.replace(/\n/g, '<br>');
  292. const $message = $('<div class="message"></div>').addClass(sender);
  293. if (cssClass) {
  294. $message.addClass(cssClass);
  295. }
  296. const $bubble = $('<div class="bubble"></div>').html(formatted);
  297. $message.append($bubble);
  298. $('#messages').append($message);
  299. $chatBox.scrollTop($chatBox[0].scrollHeight);
  300. };
  301. appendMessage("👋 嗨,我是巡检助手,告诉我项目名称可以帮你快速生成巡检单", "ai", 'mt-3');
  302. const updatePreviewForm = (data) => {
  303. $('#field-check_item').text(data.check_item || '—');
  304. $('#field-check_situation').text(data.check_situation || '—');
  305. $('#field-action').text(data.action || '—');
  306. $('#field-date').text(data.date || '—');
  307. $('#field-inspector').text(data.inspector || '—');
  308. $formPreview.removeClass('d-none');
  309. };
  310. $('#send-btn').on('click', () => {
  311. const input = $('#user-input').val().trim();
  312. if (!input) return;
  313. appendMessage(input, 'user');
  314. $('#user-input').val('');
  315. $formPreview.addClass('d-none');
  316. postData('/wap/inspection/ask', { user_input: input, conversationId }, function (result) {
  317. if (result) {
  318. conversationId = result.conversation_id;
  319. if (result.answer_text) {
  320. appendMessage(result.answer_text, 'ai');
  321. }
  322. if (result.answer_json) {
  323. updatePreviewForm(result.answer_json);
  324. $formPreview.removeClass('d-none');
  325. } else {
  326. $formPreview.addClass('d-none');
  327. }
  328. } else {
  329. appendMessage('请求出错,请稍后重试。', 'ai');
  330. }
  331. }, function () {
  332. appendMessage('请求出错,请再输入一次。', 'ai');
  333. });
  334. });
  335. $('#user-input').on('keypress', function(e) {
  336. if (e.which === 13) $('#send-btn').click();
  337. });
  338. $('#confirm-btn').on('click', () => {
  339. alert('✅ 表单已提交!你可以跳转页面或保存到数据库');
  340. // 可跳转页面如 window.location.href = '/form/preview';
  341. });
  342. $('#modify-btn').on('click', () => {
  343. $formPreview.addClass('d-none');
  344. appendMessage('请继续修改你想调整的信息。', 'ai');
  345. });
  346. const originalHeight = window.innerHeight;
  347. $('#user-input').on('focus', function () {
  348. const $input = $(this);
  349. const checkKeyboardOpen = function () {
  350. const newHeight = window.innerHeight;
  351. if (newHeight < originalHeight) {
  352. // 键盘已弹出
  353. setTimeout(function () {
  354. $input[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
  355. }, 100);
  356. $(window).off('resize', checkKeyboardOpen);
  357. }
  358. };
  359. $(window).on('resize', checkKeyboardOpen);
  360. });
  361. // const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
  362. // recognition.continuous = false;
  363. // recognition.lang = 'zh-CN';
  364. // recognition.interimResults = false;
  365. //
  366. // let isRecording = false;
  367. // let pressTimer = null;
  368. //
  369. // $('#voice-icon').on('click', function () {
  370. //
  371. // if (!isRecording) {
  372. // // 👉 开始录音
  373. // try {
  374. // recognition.start();
  375. // isRecording = true;
  376. // $(this).addClass('active');
  377. // $('#recording-toast').fadeIn(); // 显示录音提示
  378. // } catch (e) {
  379. // console.error('开始录音失败:', e);
  380. // }
  381. // } else {
  382. // // 👉 停止录音
  383. // recognition.stop();
  384. // isRecording = false;
  385. // $(this).removeClass('active');
  386. // $('#recording-toast').fadeOut(); // 隐藏录音提示
  387. // }
  388. // });
  389. //
  390. // // 👉 识别结果
  391. // recognition.onresult = function (event) {
  392. // const result = event.results[0][0].transcript.trim();
  393. // $('#user-input').val(result);
  394. // };
  395. //
  396. // // 👉 错误处理
  397. // recognition.onerror = function (err) {
  398. // console.error('语音识别出错:', err);
  399. // $('#voice-icon').removeClass('active');
  400. // $('#recording-toast').fadeOut();
  401. // isRecording = false;
  402. // };
  403. //
  404. // // 👉 识别自然结束(可能用户不说话)
  405. // recognition.onend = function () {
  406. // $('#voice-icon').removeClass('active');
  407. // $('#recording-toast').fadeOut();
  408. // isRecording = false;
  409. // };
  410. if (isWeChat()) {
  411. $('#voice-icon').hide(); // 在微信内隐藏语音按钮
  412. // 输入框恢复正常 padding
  413. // $('#user-input').css('padding-left', '0px !important');
  414. $('.chat-footer').addClass('wx');
  415. // const style = document.createElement('style');
  416. // style.innerHTML = `
  417. // .chat-footer { padding-top: 22px !important; }
  418. // .chat-footer .input-group { bottom: 22px !important; }
  419. // `;
  420. // document.head.appendChild(style);
  421. }
  422. let isRecording = false;
  423. let mediaStream = null;
  424. let audioCtx = null;
  425. let processor = null;
  426. let buffers = [];
  427. const TARGET_SAMPLE_RATE = 16000;
  428. // start / collect audio
  429. async function startRecording() {
  430. mediaStream = await navigator.mediaDevices.getUserMedia({
  431. audio: { echoCancellation: true, noiseSuppression: true }
  432. });
  433. audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  434. const source = audioCtx.createMediaStreamSource(mediaStream);
  435. processor = audioCtx.createScriptProcessor(4096, 1, 1);
  436. buffers = [];
  437. processor.onaudioprocess = (e) => {
  438. const ch = e.inputBuffer.getChannelData(0);
  439. buffers.push(new Float32Array(ch));
  440. };
  441. source.connect(processor);
  442. processor.connect(audioCtx.destination);
  443. }
  444. function mergeBuffers(buffers) {
  445. let total = buffers.reduce((s, b) => s + b.length, 0);
  446. const out = new Float32Array(total);
  447. let offset = 0;
  448. for (const b of buffers) {
  449. out.set(b, offset);
  450. offset += b.length;
  451. }
  452. return out;
  453. }
  454. function downsampleBuffer(buffer, inSampleRate, outSampleRate) {
  455. if (outSampleRate === inSampleRate) return buffer;
  456. const ratio = inSampleRate / outSampleRate;
  457. const newLength = Math.round(buffer.length / ratio);
  458. const result = new Float32Array(newLength);
  459. let offsetResult = 0;
  460. let offsetBuffer = 0;
  461. while (offsetResult < newLength) {
  462. const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
  463. let accum = 0, count = 0;
  464. for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
  465. accum += buffer[i];
  466. count++;
  467. }
  468. result[offsetResult] = accum / Math.max(1, count);
  469. offsetResult++;
  470. offsetBuffer = nextOffsetBuffer;
  471. }
  472. return result;
  473. }
  474. function floatTo16BitPCM(float32Array) {
  475. const l = float32Array.length;
  476. const buf = new Int16Array(l);
  477. for (let i = 0; i < l; i++) {
  478. let s = Math.max(-1, Math.min(1, float32Array[i]));
  479. buf[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
  480. }
  481. return buf;
  482. }
  483. function makeWav(int16Arr, sampleRate) {
  484. const buffer = new ArrayBuffer(44 + int16Arr.length * 2);
  485. const view = new DataView(buffer);
  486. function writeString(view, offset, str) {
  487. for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
  488. }
  489. writeString(view, 0, 'RIFF');
  490. view.setUint32(4, 36 + int16Arr.length * 2, true);
  491. writeString(view, 8, 'WAVE');
  492. writeString(view, 12, 'fmt ');
  493. view.setUint32(16, 16, true);
  494. view.setUint16(20, 1, true);
  495. view.setUint16(22, 1, true);
  496. view.setUint32(24, sampleRate, true);
  497. view.setUint32(28, sampleRate * 2, true);
  498. view.setUint16(32, 2, true);
  499. view.setUint16(34, 16, true);
  500. writeString(view, 36, 'data');
  501. view.setUint32(40, int16Arr.length * 2, true);
  502. let p = 44;
  503. for (let i = 0; i < int16Arr.length; i++, p += 2) {
  504. view.setInt16(p, int16Arr[i], true);
  505. }
  506. return new Blob([view], { type: 'audio/wav' });
  507. }
  508. async function stopRecordingAndBuildWav() {
  509. try {
  510. processor.disconnect();
  511. audioCtx.close();
  512. mediaStream.getTracks().forEach(t => t.stop());
  513. } catch (e) {}
  514. const merged = mergeBuffers(buffers);
  515. const inSampleRate = (audioCtx && audioCtx.sampleRate) || 48000;
  516. const down = downsampleBuffer(merged, inSampleRate, TARGET_SAMPLE_RATE);
  517. const int16 = floatTo16BitPCM(down);
  518. const wavBlob = makeWav(int16, TARGET_SAMPLE_RATE);
  519. return wavBlob;
  520. }
  521. $('#voice-icon').on('click', async function () {
  522. if (!isRecording) {
  523. try {
  524. await startRecording();
  525. isRecording = true;
  526. $(this).addClass('active');
  527. $('#recording-toast').text('正在录音...').fadeIn();
  528. } catch (e) {
  529. console.error('start fail', e);
  530. toastr.error('无法获取麦克风权限');
  531. // toastr.error(e);
  532. }
  533. } else {
  534. isRecording = false;
  535. $(this).removeClass('active');
  536. $('#recording-toast').text('上传中...').fadeIn();
  537. const wav = await stopRecordingAndBuildWav();
  538. try {
  539. // 构造 FormData(file 字段名为 audio)
  540. const fd = new FormData();
  541. fd.append('audio', wav, 'rec.wav');
  542. const resp = await postDataWithFileAsync('/wap/voice/oneshot', fd);
  543. // 你的封装应返回解析后的 JSON
  544. if (resp) {
  545. // 当前 input 的原有内容
  546. const oldText = $('#user-input').val() || '';
  547. // 去掉中文标点
  548. const newText = resp ? resp.replace(/[,。!?;:]/g, '') : '';
  549. $('#user-input').val(oldText + (newText || ''));
  550. } else {
  551. toastr.warning('抱歉,未能识别成功');
  552. // toastr.error('未能识别成功:' + (resp && resp.msg ? resp.msg : '未知'));
  553. }
  554. } catch (err) {
  555. // alert(err);
  556. // console.error(err);
  557. toastr.error('上传或识别出错');
  558. } finally {
  559. $('#recording-toast').fadeOut();
  560. $('#recording-toast').text('正在录音...');
  561. }
  562. }
  563. });
  564. });
  565. function isWeChat() {
  566. const ua = navigator.userAgent.toLowerCase();
  567. return /micromessenger/.test(ua);
  568. }
  569. </script>
  570. </body>
  571. </html>