ChatBottom.vue 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. <template>
  2. <div class="chat-input">
  3. <div class="chat-input-box">
  4. <div class="img-box" @click="isRecording = !isRecording">
  5. <img v-if="isRecording" src="../img/icon/keyboard.png" alt="" />
  6. <img v-else src="../img/icon/macphone.png" alt="" />
  7. </div>
  8. <div class="chat-input-box-main" :class="{ 'is-recording': isRecording }">
  9. <template v-if="isRecording">
  10. <div class="recording" @mousedown="handleMousedown" @mouseup="handleMouseup">
  11. <Recording v-if="recording" @on-complete="recordingComplete" />
  12. <span v-else>长按说话</span>
  13. </div>
  14. </template>
  15. <div v-if="!isRecording" class="textarea-box">
  16. <div
  17. ref="textareaRef"
  18. class="textarea-box-input"
  19. contenteditable="true"
  20. @input="handleInput"
  21. @keydown.enter.ctrl.prevent="keydownEnter"
  22. @keydown.enter.prevent.exact="throttleHandle"
  23. ></div>
  24. <!-- 模拟placeholder -->
  25. <div v-if="!inputText" class="textarea-content"> 请输入内容 </div>
  26. </div>
  27. <div class="upload-box">
  28. <!-- <div class="img-box upload-img">-->
  29. <!-- <img src="../img/icon/icon-image.png" alt="" />-->
  30. <!-- <input-->
  31. <!-- id="img-upload"-->
  32. <!-- type="file"-->
  33. <!-- accept="image/jpeg,image/jpg,image/png,image/gif"-->
  34. <!-- @change="getFile"-->
  35. <!-- />-->
  36. <!-- </div>-->
  37. <!-- <div class="img-box upload-file">-->
  38. <!-- <img src="../img/icon/icon-file.png" alt="" />-->
  39. <!-- <input-->
  40. <!-- id="img-upload"-->
  41. <!-- type="file"-->
  42. <!-- accept=".pdf,.doc,.docx,.xls,.xlsx"-->
  43. <!-- @change="getFile"-->
  44. <!-- />-->
  45. <!-- </div>-->
  46. <div v-if="!isRecording" class="send" @click="throttleHandle">
  47. <img src="../img/chat/send.png" alt="" />
  48. </div>
  49. </div>
  50. </div>
  51. </div>
  52. <!-- 弹窗 -->
  53. <AiMessage :message-option="messageOption" v-model:visible="messageVisible" />
  54. </div>
  55. </template>
  56. <script setup lang="ts">
  57. import { _throttle } from '../utils'
  58. import AiMessage from './AiMessage.vue'
  59. import Recording from './Recording.vue'
  60. import { ref } from 'vue'
  61. const inputText = ref('') // 正在输入的值 可作为类placeholder的显示控制
  62. const isRecording = ref(false) // 是否处于录音状态
  63. const recording = ref(false) // 正在识别录音
  64. const recordingTime = ref(0) // 录音时间 低于500ms 不进行
  65. const messageVisible = ref(false)
  66. const messageOption: Recordable = ref({})
  67. const textareaRef = ref()
  68. const emit = defineEmits(['handleSend'])
  69. const handleSend = () => {
  70. emit('handleSend', inputText.value)
  71. textareaRef.value.innerText = ''
  72. inputText.value = ''
  73. }
  74. const handleInput = (text) => {
  75. inputText.value = text.target.textContent
  76. }
  77. // 输入节流
  78. const throttleHandle = _throttle(function () {
  79. inputText.value = textareaRef.value.innerText
  80. handleSend()
  81. }, 2000)
  82. // 换行
  83. const keydownEnter = (event) => {
  84. const div = textareaRef.value
  85. // 返回一个 Selection 对象,表示用户选择的文本范围或光标的当前位置
  86. const selection = window.getSelection() as Recordable
  87. // 返回一个包含当前选区内容的区域对象。
  88. const range = selection.getRangeAt(0)
  89. // startOffset:开始偏移量,选定文本的第一个字符在文本输入字段中的位置
  90. if (range.startOffset === range.endOffset && range.startOffset === div.textContent.length) {
  91. const newline = document.createElement('br')
  92. div.appendChild(newline)
  93. // 搞两个br是为了在文字末尾换行时 要点击两次的问题
  94. const newline2 = document.createElement('br')
  95. div.appendChild(newline2)
  96. // 该方法将选定文本的开始位置设置为给定节点之后
  97. range.setStartAfter(newline)
  98. // range.collapse(true)
  99. event.preventDefault()
  100. } else if (event.ctrlKey) {
  101. // 如果按下 Ctrl 键
  102. const newline = document.createElement('br')
  103. range.insertNode(newline)
  104. range.collapse(false)
  105. event.preventDefault()
  106. }
  107. }
  108. // const getFile = (e) => {
  109. // const file = e.target.files[0]
  110. // const url = window.URL.createObjectURL(file)
  111. // emit('handleSend', {
  112. // component: file.type.startsWith('image/') ? 'ImgMessage' : 'FileMessage',
  113. // messageProps: {
  114. // url,
  115. // // type: file.type.startsWith('image/') ? 'image' : 'file',
  116. // fileName:
  117. // file.name.length > 25 ? `${file.name.slice(0, 10)}...${file.name.slice(-15)}` : file.name,
  118. // fileSize: `${file.size / 1000}k`
  119. // }
  120. // })
  121. // e.target.value = ''
  122. // }
  123. const handleMousedown = () => {
  124. recording.value = true
  125. recordingTime.value = Date.now()
  126. }
  127. const handleMouseup = () => {
  128. if (Date.now() - recordingTime.value < 500) {
  129. recording.value = false
  130. messageVisible.value = true
  131. messageOption.value = {
  132. text: '语音时间太短',
  133. type: 'warning'
  134. }
  135. }
  136. // 抬起时一秒后直接结束
  137. setTimeout(() => {
  138. recording.value = false
  139. }, 1000)
  140. }
  141. // 转写完成事件
  142. const recordingComplete = (text) => {
  143. recording.value = false
  144. if (!text) {
  145. messageVisible.value = true
  146. messageOption.value = {
  147. text: '未识别到内容',
  148. type: 'warning'
  149. }
  150. return
  151. }
  152. emit('handleSend', text)
  153. }
  154. </script>
  155. <style lang="less" scoped>
  156. .chat-input {
  157. padding: 8px 12px 24px;
  158. &-items {
  159. display: flex;
  160. margin-bottom: 8px;
  161. .img-box {
  162. background: #fff;
  163. border-radius: 6px;
  164. width: 28px;
  165. height: 28px;
  166. margin-right: 8px;
  167. display: flex;
  168. justify-content: center;
  169. align-items: center;
  170. }
  171. .item {
  172. height: 28px;
  173. padding: 4px 8px;
  174. background: #fff;
  175. border-radius: 6px;
  176. box-shadow: 0px -2px 8px 0px rgba(16, 108, 199, 0.08);
  177. font-size: 12px;
  178. font-weight: 400;
  179. color: #333333;
  180. line-height: 20px;
  181. text-shadow: 0px -2px 8px 0px rgba(16, 108, 199, 0.08);
  182. cursor: pointer;
  183. & + .item {
  184. margin-left: 8px;
  185. }
  186. }
  187. }
  188. &-box {
  189. display: flex;
  190. align-items: flex-end;
  191. > div {
  192. margin-left: 8px;
  193. &:first-child {
  194. margin-left: 0;
  195. }
  196. }
  197. &-main {
  198. flex: 1;
  199. background-color: #fff;
  200. display: flex;
  201. justify-content: space-between;
  202. align-items: center;
  203. flex-wrap: wrap;
  204. padding: 0 8px 0 12px;
  205. border-radius: 6px;
  206. overflow: hidden;
  207. // 录音状态下 样式需切换
  208. &.is-recording {
  209. background-color: transparent;
  210. padding: 0;
  211. .upload-box {
  212. margin-left: 8px;
  213. .img-box {
  214. width: 42px;
  215. }
  216. }
  217. }
  218. .textarea-box {
  219. // 设置最小值 多了自动撑开 单独一行
  220. min-width: calc(100% - 50px);
  221. position: relative;
  222. min-height: 32px;
  223. user-select: none;
  224. .textarea-content {
  225. height: 32px;
  226. line-height: 32px;
  227. color: #999;
  228. position: absolute;
  229. left: 0;
  230. top: 0;
  231. pointer-events: none;
  232. }
  233. &-input {
  234. width: 100%;
  235. height: 100%;
  236. background-color: #fff;
  237. word-break: break-all;
  238. white-space: pre-wrap;
  239. line-height: 20px;
  240. padding: 4px;
  241. max-height: 108px;
  242. min-height: 32px;
  243. overflow: auto;
  244. -webkit-user-modify: read-write-plaintext-only;
  245. user-select: none;
  246. &:focus {
  247. & + .textarea-content {
  248. display: none;
  249. }
  250. }
  251. }
  252. }
  253. .send {
  254. align-self: flex-end;
  255. width: 42px;
  256. height: 42px;
  257. display: flex;
  258. justify-content: center;
  259. align-items: center;
  260. cursor: pointer;
  261. img {
  262. width: 34px;
  263. height: 34px;
  264. }
  265. }
  266. .recording {
  267. //width: calc(100% - 110px);
  268. width: 100%;
  269. height: 42px;
  270. display: flex;
  271. justify-content: center;
  272. align-items: center;
  273. background-color: #fff;
  274. border-radius: 6px;
  275. }
  276. }
  277. .img-box {
  278. position: relative;
  279. width: 42px;
  280. height: 42px;
  281. background-color: #fff;
  282. border-radius: 6px;
  283. display: flex;
  284. justify-content: center;
  285. align-items: center;
  286. cursor: pointer;
  287. .svg-icon {
  288. width: 24px;
  289. height: 24px;
  290. }
  291. input {
  292. display: block;
  293. width: 24px;
  294. height: 24px;
  295. position: absolute;
  296. left: 4px;
  297. top: 9px;
  298. opacity: 0;
  299. }
  300. }
  301. .upload-box {
  302. // 输入内容过多时 单独一行
  303. flex: 1;
  304. background-color: #fff;
  305. display: flex;
  306. justify-content: right;
  307. border-radius: 6px;
  308. align-self: flex-end;
  309. .img-box {
  310. width: 32px;
  311. }
  312. }
  313. }
  314. }
  315. </style>