| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666 |
- <!DOCTYPE html>
- <html lang="zh">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <meta http-equiv="x-ua-compatible" content="ie=edge">
- <title>巡检助手-计量支付</title>
- <link rel="stylesheet" href="/public/css/bootstrap/bootstrap.min.css">
- <link rel="stylesheet" href="/public/css/wap/main.css">
- <link rel="stylesheet" href="/public/css/toast.css">
- <link rel="stylesheet" href="/public/css/font-awesome/font-awesome.min.css">
- <link rel="stylesheet" href="/public/css/toastr.css">
- <link rel="apple-touch-icon-precomposed" href="/public/images/logo.png">
- <link rel="shortcut icon" href="/public/images/logo.png">
- <style>
- body,
- html {
- height: 100%;
- overflow: hidden;
- margin: 0; /* 去掉默认 margin */
- padding: 0;
- -webkit-overflow-scrolling: touch;
- }
- body {
- background: #f1f3f5;
- font-size: 16px;
- padding-bottom: 100px;
- }
- .pt-4 {
- padding-top: 3.5rem !important;
- }
- .chat-box {
- height: calc(100vh - 100px); /* 减去头部和底部高度,150px 是示例,需根据实际调整 */
- overflow-y: auto;
- /*padding: 10px;*/
- box-sizing: border-box;
- }
- .message {
- display: flex;
- margin-bottom: 12px;
- padding: 0 15px;
- }
- .message.user {
- justify-content: flex-end;
- }
- .message.ai {
- justify-content: flex-start;
- }
- .bubble {
- max-width: 95%;
- padding: 10px 14px;
- border-radius: 18px;
- line-height: 1.4;
- word-wrap: break-word;
- }
- .message.user .bubble {
- background: #007bff;
- color: white;
- border-bottom-right-radius: 0;
- }
- .message.ai .bubble {
- background: #dee2e6;
- color: #333;
- border-bottom-left-radius: 0;
- }
- .chat-footer {
- position: fixed;
- bottom: 0;
- left: 0;
- right: 0;
- width: 100%;
- background: #fff;
- padding: 10px env(safe-area-inset-right) calc(10px + env(safe-area-inset-bottom)) env(safe-area-inset-left);
- box-shadow: 0 -2px 6px rgba(0, 0, 0, 0.1);
- z-index: 999;
- }
- /*.chat-footer.wx {*/
- /* padding-top: 22px; !* 微信才有的上间距 *!*/
- /*}*/
- .chat-footer.wx .input-group {
- margin-bottom: 22px;
- }
- .chat-footer input.form-control {
- border: none;
- padding: 10px 12px;
- height: 44px;
- }
- .chat-footer .btn {
- border-radius: 0;
- }
- .form-preview {
- background: #fff;
- border: 1px solid #dee2e6;
- border-radius: 12px;
- padding: 15px 20px;
- margin: 20px 15px 0 15px;
- box-shadow: 0 2px 6px rgba(0,0,0,0.05);
- }
- .form-preview h6 {
- font-weight: bold;
- margin-bottom: 16px;
- border-bottom: 1px solid #dee2e6;
- padding-bottom: 8px;
- font-size: 17px;
- }
- .form-item {
- margin-bottom: 12px;
- font-size: 15px;
- line-height: 1.5;
- }
- .form-item label {
- font-weight: 500;
- color: #555;
- }
- .form-item span {
- display: inline-block;
- margin-left: 5px;
- color: #333;
- }
- .form-actions {
- border-top: 1px solid #dee2e6;
- padding-top: 12px;
- margin-top: 16px;
- display: flex;
- justify-content: space-between;
- }
- /* 1. WebKit 浏览器(Chrome, Safari, Android) */
- #chat-box::-webkit-scrollbar {
- width: 4px;
- }
- #chat-box::-webkit-scrollbar-track {
- background: transparent;
- }
- #chat-box::-webkit-scrollbar-thumb {
- background-color: rgba(0, 0, 0, 0.3);
- border-radius: 4px;
- }
- /* 2. Firefox */
- #chat-box {
- scrollbar-width: thin;
- scrollbar-color: rgba(0, 0, 0, 0.3) transparent;
- }
- /* 3. 选项:在小屏上彻底隐藏滚动条(可滑动但看不见) */
- @media (max-width: 768px) {
- #chat-box::-webkit-scrollbar {
- display: none;
- }
- #chat-box {
- -ms-overflow-style: none; /* IE 10+ */
- scrollbar-width: none; /* Firefox */
- }
- }
- .input-group {
- position: relative;
- }
- .input-icon {
- position: absolute;
- left: 10px; /* ---- 改成左边 ---- */
- top: 50%;
- transform: translateY(-50%);
- cursor: pointer;
- color: #888;
- z-index: 10;
- font-size: 18px;
- }
- /* 激活时高亮 */
- #voice-icon.active {
- color: #007bff;
- }
- /* 输入框左边留位置给语音按钮 */
- #user-input {
- margin-left: 40px !important;
- padding-left: 0px;
- }
- .chat-footer.wx #user-input {
- margin-left: 12px !important; /* 或 0px */
- padding-left: 0px;
- }
- .xj-title {
- position: sticky;
- top: 0;
- background: #fff;
- }
- #recording-toast {
- position: fixed;
- bottom: 80px;
- left: 50%;
- transform: translateX(-50%);
- background-color: rgba(0, 0, 0, 0.85);
- color: #fff;
- padding: 12px 20px;
- border-radius: 24px;
- font-size: 14px;
- display: flex;
- align-items: center;
- z-index: 9999;
- animation: fadeInUp 0.3s ease-out;
- }
- #recording-toast .fa {
- margin-right: 8px;
- }
- @keyframes fadeInUp {
- from {
- opacity: 0;
- transform: translate(-50%, 100%);
- }
- to {
- opacity: 1;
- transform: translate(-50%, 0);
- }
- }
- </style>
- </head>
- <body>
- <div class="container mb-3 px-0">
- <!--顶部-->
- <nav class="fixed-top bg-dark">
- <div class="my-2 d-flex justify-content-between">
- <span class="text-white ml-3"><a href="/wap/sp/<%- ctx.subProject.id %>/<%- type %>/tender/<%- tender.id %>/inspection" class="mr-2 text-white show-loading"><i class="fa fa-chevron-left"></i><%- (type === 'quality' ? '质量' : type === 'safe' ? '安全' : '') %>巡检</a></span>
- <a tabindex="0" href="javascript:void(0)" class="text-white text-truncate text-center"
- style="width:150px" data-toggle="popover" data-placement="top"
- data-content="<%- tender.name %>" data-trigger="focus"><%- tender.name %></a>
- <div class="mr-3">
- <div class="dropdown">
- <button class="btn btn-sm btn-light dropdown-toggle" type="button" data-toggle="dropdown">
- <%- ctx.session.sessionUser.name.substr(ctx.session.sessionUser.name.length > 2 ? ctx.session.sessionUser.name.length - 2 : 0) %>
- </button>
- <div class="dropdown-menu" aria-labelledby="dropdownMenuButton">
- <a class="dropdown-item" href="/wap/logout">退出登录</a>
- </div>
- </div>
- </div>
- </div>
- </nav>
- <!-- <h5 class="text-center py-2 xj-title mb-0">巡检助手</h5>-->
- <div id="chat-box" class="chat-box pt-4 mb-3">
- <div id="messages"></div>
- <!-- 表单预览卡片 -->
- <div id="form-preview" class="form-preview d-none">
- <h6><i class="fa fa-clipboard-check mr-2"></i>巡检单信息确认</h6>
- <div class="form-item">
- <label>巡检编号:</label>
- <span id="field-code"><%- newCode %></span>
- </div>
- <div class="form-item">
- <label>检查项目:</label>
- <span id="field-check_item">—</span>
- </div>
- <div class="form-item">
- <label>现场检查情况:</label>
- <span id="field-check_situation">—</span>
- </div>
- <div class="form-item">
- <label>处理要求及措施:</label>
- <span id="field-action">—</span>
- </div>
- <div class="form-item">
- <label>检查日期:</label>
- <span id="field-date">—</span>
- </div>
- <div class="form-item">
- <label>质检员:</label>
- <span id="field-inspector"><%- ctx.session.sessionUser.name %></span>
- </div>
- <div class="form-actions">
- <button id="modify-btn" class="btn btn-outline-secondary btn-sm">
- <i class="fa fa-edit"></i> 修改
- </button>
- <button id="confirm-btn" class="btn btn-success btn-sm">
- <i class="fa fa-check-circle"></i> 确认生成
- </button>
- </div>
- </div>
- </div>
- <div class="chat-footer">
- <!-- <div class="input-group">-->
- <!-- <input type="text" id="user-input" class="form-control" placeholder="请输入...">-->
- <!-- <div class="input-icon">-->
- <!-- <i id="voice-icon" class="fa fa-microphone"></i>-->
- <!-- </div>-->
- <!-- <div class="input-group-append">-->
- <!-- <button class="btn btn-primary btn-send" id="send-btn">发送</button>-->
- <!-- </div>-->
- <!-- </div>-->
- <div class="input-group">
- <!-- 左侧语音按钮 -->
- <div class="input-icon" id="voice-container">
- <i id="voice-icon" class="fa fa-microphone"></i>
- </div>
- <input type="text" id="user-input" class="form-control" placeholder="请输入...">
- <div class="input-group-append">
- <button class="btn btn-primary btn-send" id="send-btn">发送</button>
- </div>
- </div>
- </div>
- </div>
- <div id="recording-toast" style="display: none;">
- <div class="toast-inner">
- <i class="fa fa-microphone"></i>
- <span>正在录音,请讲话...</span>
- </div>
- </div>
- <script src="/public/js/jquery/jquery-3.2.1.min.js"></script>
- <script src="/public/js/popper/popper.min.js"></script>
- <script src="/public/js/bootstrap/bootstrap.min.js"></script>
- <script src="/public/js/toastr.min.js"></script>
- <script src="/public/js/cookies.js"></script>
- <script src="/public/js/wap/global.js"></script>
- <script>
- const csrf = '<%= ctx.csrf %>';
- const spid = '<%- ctx.subProject.id %>';
- const tender_id = parseInt('<%- ctx.tender.id %>');
- const type = '<%- type %>';
- const inspector = JSON.parse('<%- JSON.stringify(ctx.session.sessionUser.name) %>');
- const newCode = JSON.parse('<%- JSON.stringify(newCode) %>');
- </script>
- <script>
- const $chatBox = $('#chat-box');
- const $formPreview = $('#form-preview');
- let conversationId = '';
- $(function () {
- const appendMessage = (text, sender = 'user', cssClass = '') => {
- const formatted = text.replace(/\n/g, '<br>');
- const $message = $('<div class="message"></div>').addClass(sender);
- if (cssClass) {
- $message.addClass(cssClass);
- }
- const $bubble = $('<div class="bubble"></div>').html(formatted);
- $message.append($bubble);
- $('#messages').append($message);
- $chatBox.scrollTop($chatBox[0].scrollHeight);
- };
- appendMessage("👋 嗨,我是巡检助手,告诉我检查项目名称可以帮你快速生成巡检单", "ai", 'mt-3');
- const updatePreviewForm = (data) => {
- $('#field-code').text(data.code || newCode);
- $('#field-check_item').text(data.check_item || '—');
- $('#field-check_situation').text(data.check_situation || '—');
- $('#field-action').text(data.action || '—');
- $('#field-date').text(data.date || '—');
- $('#field-inspector').text(data.inspector || inspector);
- $formPreview.removeClass('d-none');
- };
- $('#send-btn').on('click', () => {
- const input = $('#user-input').val().trim();
- if (!input) return;
- appendMessage(input, 'user');
- $('#user-input').val('');
- $formPreview.addClass('d-none');
- postData('/wap/inspection/ask', { user_input: input, conversationId }, function (result) {
- if (result) {
- conversationId = result.conversation_id;
- if (result.answer_text) {
- appendMessage(result.answer_text, 'ai');
- }
- if (result.answer_json) {
- updatePreviewForm(result.answer_json);
- $formPreview.removeClass('d-none');
- } else {
- $formPreview.addClass('d-none');
- }
- } else {
- appendMessage('请求出错,请稍后重试。', 'ai');
- }
- }, function () {
- appendMessage('请求出错,请再输入一次。', 'ai');
- });
- });
- $('#user-input').on('keypress', function(e) {
- if (e.which === 13) $('#send-btn').click();
- });
- $('#confirm-btn').on('click', () => {
- if ($('#field-code').text() === '') {
- toastr.error('巡检编号不能为空');
- return;
- }
- if ($('#field-check_item').text() === '—') {
- toastr.error('请至少提供检查项目名称');
- return;
- }
- const data = {
- code: $('#field-code').text() !== '—' ? $('#field-code').text() : newCode,
- check_item: $('#field-check_item').text() !== '—' ? $('#field-check_item').text() : '',
- check_situation: $('#field-check_situation').text() !== '—' ? $('#field-check_situation').text() : '',
- action: $('#field-action').text() !== '—' ? $('#field-action').text() : '',
- check_date: $('#field-date').text() !== '—' ? $('#field-date').text() : '',
- inspector: $('#field-inspector').text() !== '—' ? $('#field-inspector').text() : inspector,
- }
- postData(`/wap/sp/${spid}/${type}/tender/${tender_id}/inspection/save`, { type: 'addByWap', insert: data }, function (rst) {
- window.location.href = `/wap/sp/${spid}/${type}/tender/${tender_id}/inspection/${rst.id}/information`;
- });
- // 可跳转页面如 window.location.href = '/form/preview';
- });
- $('#modify-btn').on('click', () => {
- $formPreview.addClass('d-none');
- appendMessage('请告诉我并完善你想调整的信息。', 'ai');
- });
- const originalHeight = window.innerHeight;
- $('#user-input').on('focus', function () {
- const $input = $(this);
- const checkKeyboardOpen = function () {
- const newHeight = window.innerHeight;
- if (newHeight < originalHeight) {
- // 键盘已弹出
- setTimeout(function () {
- $input[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
- }, 100);
- $(window).off('resize', checkKeyboardOpen);
- }
- };
- $(window).on('resize', checkKeyboardOpen);
- });
- // const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
- // recognition.continuous = false;
- // recognition.lang = 'zh-CN';
- // recognition.interimResults = false;
- //
- // let isRecording = false;
- // let pressTimer = null;
- //
- // $('#voice-icon').on('click', function () {
- //
- // if (!isRecording) {
- // // 👉 开始录音
- // try {
- // recognition.start();
- // isRecording = true;
- // $(this).addClass('active');
- // $('#recording-toast').fadeIn(); // 显示录音提示
- // } catch (e) {
- // console.error('开始录音失败:', e);
- // }
- // } else {
- // // 👉 停止录音
- // recognition.stop();
- // isRecording = false;
- // $(this).removeClass('active');
- // $('#recording-toast').fadeOut(); // 隐藏录音提示
- // }
- // });
- //
- // // 👉 识别结果
- // recognition.onresult = function (event) {
- // const result = event.results[0][0].transcript.trim();
- // $('#user-input').val(result);
- // };
- //
- // // 👉 错误处理
- // recognition.onerror = function (err) {
- // console.error('语音识别出错:', err);
- // $('#voice-icon').removeClass('active');
- // $('#recording-toast').fadeOut();
- // isRecording = false;
- // };
- //
- // // 👉 识别自然结束(可能用户不说话)
- // recognition.onend = function () {
- // $('#voice-icon').removeClass('active');
- // $('#recording-toast').fadeOut();
- // isRecording = false;
- // };
- if (isWeChat()) {
- $('#voice-icon').hide(); // 在微信内隐藏语音按钮
- // 输入框恢复正常 padding
- // $('#user-input').css('padding-left', '0px !important');
- $('.chat-footer').addClass('wx');
- // const style = document.createElement('style');
- // style.innerHTML = `
- // .chat-footer { padding-top: 22px !important; }
- // .chat-footer .input-group { bottom: 22px !important; }
- // `;
- // document.head.appendChild(style);
- }
- let isRecording = false;
- let mediaStream = null;
- let audioCtx = null;
- let processor = null;
- let buffers = [];
- const TARGET_SAMPLE_RATE = 16000;
- // start / collect audio
- async function startRecording() {
- mediaStream = await navigator.mediaDevices.getUserMedia({
- audio: { echoCancellation: true, noiseSuppression: true }
- });
- audioCtx = new (window.AudioContext || window.webkitAudioContext)();
- const source = audioCtx.createMediaStreamSource(mediaStream);
- processor = audioCtx.createScriptProcessor(4096, 1, 1);
- buffers = [];
- processor.onaudioprocess = (e) => {
- const ch = e.inputBuffer.getChannelData(0);
- buffers.push(new Float32Array(ch));
- };
- source.connect(processor);
- processor.connect(audioCtx.destination);
- }
- function mergeBuffers(buffers) {
- let total = buffers.reduce((s, b) => s + b.length, 0);
- const out = new Float32Array(total);
- let offset = 0;
- for (const b of buffers) {
- out.set(b, offset);
- offset += b.length;
- }
- return out;
- }
- function downsampleBuffer(buffer, inSampleRate, outSampleRate) {
- if (outSampleRate === inSampleRate) return buffer;
- const ratio = inSampleRate / outSampleRate;
- const newLength = Math.round(buffer.length / ratio);
- const result = new Float32Array(newLength);
- let offsetResult = 0;
- let offsetBuffer = 0;
- while (offsetResult < newLength) {
- const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
- let accum = 0, count = 0;
- for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
- accum += buffer[i];
- count++;
- }
- result[offsetResult] = accum / Math.max(1, count);
- offsetResult++;
- offsetBuffer = nextOffsetBuffer;
- }
- return result;
- }
- function floatTo16BitPCM(float32Array) {
- const l = float32Array.length;
- const buf = new Int16Array(l);
- for (let i = 0; i < l; i++) {
- let s = Math.max(-1, Math.min(1, float32Array[i]));
- buf[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
- }
- return buf;
- }
- function makeWav(int16Arr, sampleRate) {
- const buffer = new ArrayBuffer(44 + int16Arr.length * 2);
- const view = new DataView(buffer);
- function writeString(view, offset, str) {
- for (let i = 0; i < str.length; i++) view.setUint8(offset + i, str.charCodeAt(i));
- }
- writeString(view, 0, 'RIFF');
- view.setUint32(4, 36 + int16Arr.length * 2, true);
- writeString(view, 8, 'WAVE');
- writeString(view, 12, 'fmt ');
- view.setUint32(16, 16, true);
- view.setUint16(20, 1, true);
- view.setUint16(22, 1, true);
- view.setUint32(24, sampleRate, true);
- view.setUint32(28, sampleRate * 2, true);
- view.setUint16(32, 2, true);
- view.setUint16(34, 16, true);
- writeString(view, 36, 'data');
- view.setUint32(40, int16Arr.length * 2, true);
- let p = 44;
- for (let i = 0; i < int16Arr.length; i++, p += 2) {
- view.setInt16(p, int16Arr[i], true);
- }
- return new Blob([view], { type: 'audio/wav' });
- }
- async function stopRecordingAndBuildWav() {
- try {
- processor.disconnect();
- audioCtx.close();
- mediaStream.getTracks().forEach(t => t.stop());
- } catch (e) {}
- const merged = mergeBuffers(buffers);
- const inSampleRate = (audioCtx && audioCtx.sampleRate) || 48000;
- const down = downsampleBuffer(merged, inSampleRate, TARGET_SAMPLE_RATE);
- const int16 = floatTo16BitPCM(down);
- const wavBlob = makeWav(int16, TARGET_SAMPLE_RATE);
- return wavBlob;
- }
- $('#voice-icon').on('click', async function () {
- if (!isRecording) {
- try {
- await startRecording();
- isRecording = true;
- $(this).addClass('active');
- $('#recording-toast').text('正在录音...').fadeIn();
- } catch (e) {
- console.error('start fail', e);
- toastr.error('无法获取麦克风权限');
- // toastr.error(e);
- }
- } else {
- isRecording = false;
- $(this).removeClass('active');
- $('#recording-toast').text('上传中...').fadeIn();
- const wav = await stopRecordingAndBuildWav();
- try {
- // 构造 FormData(file 字段名为 audio)
- const fd = new FormData();
- fd.append('audio', wav, 'rec.wav');
- const resp = await postDataWithFileAsync('/wap/voice/oneshot', fd);
- // 你的封装应返回解析后的 JSON
- if (resp) {
- // 当前 input 的原有内容
- const oldText = $('#user-input').val() || '';
- // 去掉中文标点
- const newText = resp ? resp.replace(/[,。!?;:]/g, '') : '';
- $('#user-input').val(oldText + (newText || ''));
- } else {
- toastr.warning('抱歉,未能识别成功');
- // toastr.error('未能识别成功:' + (resp && resp.msg ? resp.msg : '未知'));
- }
- } catch (err) {
- // alert(err);
- // console.error(err);
- toastr.error('上传或识别出错');
- } finally {
- $('#recording-toast').fadeOut();
- $('#recording-toast').text('正在录音...');
- }
- }
- });
- });
- function isWeChat() {
- const ua = navigator.userAgent.toLowerCase();
- return /micromessenger/.test(ua);
- }
- </script>
- </body>
- </html>
|