vue_zhongyou 1.0.22 → 1.0.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package//345/212/237/350/203/275/344/273/243/347/240/201/AI/345/257/271/350/257/235/aiChatPage.vue +159 -184
- package//345/212/237/350/203/275/344/273/243/347/240/201//345/275/225/351/237/263/inputCom.vue +591 -0
- package//345/212/237/350/203/275/344/273/243/347/240/201//351/241/265/351/235/242/345/223/215/345/272/224/345/274/217/351/200/202/351/205/215/vite.config.js +20 -0
- package//346/217/222/344/273/266/mkcert-v1.4.4-linux-amd64 +0 -0
package/package.json
CHANGED
package//345/212/237/350/203/275/344/273/243/347/240/201/AI/345/257/271/350/257/235/aiChatPage.vue
CHANGED
|
@@ -30,10 +30,13 @@
|
|
|
30
30
|
<!-- 文字介绍 -->
|
|
31
31
|
<div v-if="message.output" class="message-output" v-html="formatMessage(message.output)"></div>
|
|
32
32
|
<!-- 表单内容 -->
|
|
33
|
-
<
|
|
33
|
+
<DynamicMobileForm
|
|
34
|
+
v-model="message.formData"
|
|
34
35
|
:schema="message.formSchema"
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
submit-button-label="确认"
|
|
37
|
+
reset-button-label="重置"
|
|
38
|
+
@submit="handleSubmit"
|
|
39
|
+
@change="handleChange"
|
|
37
40
|
/>
|
|
38
41
|
</div>
|
|
39
42
|
|
|
@@ -81,7 +84,12 @@
|
|
|
81
84
|
</div>
|
|
82
85
|
|
|
83
86
|
<!-- 输入区域 -->
|
|
84
|
-
<
|
|
87
|
+
<inputCom
|
|
88
|
+
ref="inputComRef"
|
|
89
|
+
:isLoading="isLoading"
|
|
90
|
+
@sendMessage="sendMessage"
|
|
91
|
+
></inputCom>
|
|
92
|
+
<!-- <div class="input-area">
|
|
85
93
|
<div class="input-wrapper">
|
|
86
94
|
<van-field
|
|
87
95
|
v-model="inputText"
|
|
@@ -106,15 +114,17 @@
|
|
|
106
114
|
发送
|
|
107
115
|
</van-button>
|
|
108
116
|
</div>
|
|
109
|
-
</div>
|
|
117
|
+
</div> -->
|
|
110
118
|
</div>
|
|
111
119
|
</template>
|
|
112
120
|
|
|
113
121
|
<script setup>
|
|
114
|
-
import { ref, nextTick, onMounted, watch } from 'vue'
|
|
122
|
+
import { ref, nextTick, onMounted, watch, onUnmounted } from 'vue'
|
|
115
123
|
import { showToast } from 'vant'
|
|
116
124
|
import ChatForm from '@/components/ChatForm.vue'
|
|
117
125
|
import NewChatCardList from '@/components/newChatCardList.vue'
|
|
126
|
+
import DynamicMobileForm from '@/components/dynamicMobileForm.vue'
|
|
127
|
+
import inputCom from '@/components/inputCom.vue'
|
|
118
128
|
|
|
119
129
|
// 消息列表
|
|
120
130
|
const messages = ref([])
|
|
@@ -126,6 +136,13 @@ const isLoading = ref(false)
|
|
|
126
136
|
const chatContainerRef = ref(null)
|
|
127
137
|
// 会话ID
|
|
128
138
|
const sessionId = ref('')
|
|
139
|
+
// 输入组件引用
|
|
140
|
+
const inputComRef = ref(null)
|
|
141
|
+
// SSE连接引用
|
|
142
|
+
const eventSourceRef = ref(null)
|
|
143
|
+
// 当前正在接收流式响应的消息索引
|
|
144
|
+
const streamingMessageIndex = ref(-1)
|
|
145
|
+
|
|
129
146
|
|
|
130
147
|
// 判断消息类型
|
|
131
148
|
const isTextMessage = (message) => {
|
|
@@ -190,10 +207,8 @@ const generateSuggestions = (content) => {
|
|
|
190
207
|
// 这里可以根据实际内容生成相关的推荐信息
|
|
191
208
|
// 目前先使用一些示例推荐信息
|
|
192
209
|
const sampleSuggestions = [
|
|
193
|
-
'
|
|
194
|
-
'
|
|
195
|
-
'查看相关文档',
|
|
196
|
-
'获取更多帮助'
|
|
210
|
+
'查找当前待办',
|
|
211
|
+
'发起出差流程',
|
|
197
212
|
]
|
|
198
213
|
|
|
199
214
|
// 根据内容类型生成不同的推荐信息
|
|
@@ -206,9 +221,9 @@ const generateSuggestions = (content) => {
|
|
|
206
221
|
return ['比较不同产品', '查看用户评价', '获取优惠信息']
|
|
207
222
|
}
|
|
208
223
|
} else if (content && content.type === 'form') {
|
|
209
|
-
return ['
|
|
224
|
+
return ['查找当前待办', '查看我发起的流程',]
|
|
210
225
|
} else if (content && content.type === 'cardList') {
|
|
211
|
-
return ['
|
|
226
|
+
return ['创建出差流程', '创建请假流程', '查看待办列表']
|
|
212
227
|
}
|
|
213
228
|
|
|
214
229
|
// 默认推荐信息
|
|
@@ -217,169 +232,165 @@ const generateSuggestions = (content) => {
|
|
|
217
232
|
|
|
218
233
|
// 发送消息
|
|
219
234
|
const sendMessage = async () => {
|
|
220
|
-
const text =
|
|
235
|
+
const text = inputComRef.value.inputText.trim()
|
|
221
236
|
if (!text || isLoading.value) return
|
|
222
237
|
|
|
238
|
+
// 关闭之前的SSE连接
|
|
239
|
+
if (eventSourceRef.value) {
|
|
240
|
+
eventSourceRef.value.close()
|
|
241
|
+
eventSourceRef.value = null
|
|
242
|
+
}
|
|
243
|
+
|
|
223
244
|
// 添加用户消息
|
|
224
245
|
addMessage('user', text)
|
|
225
|
-
|
|
246
|
+
inputComRef.value.inputText = ''
|
|
226
247
|
|
|
227
248
|
// 添加AI加载消息
|
|
228
249
|
addMessage('assistant', '', true)
|
|
229
250
|
isLoading.value = true
|
|
230
251
|
|
|
231
|
-
//
|
|
232
|
-
|
|
252
|
+
// 记录当前消息索引
|
|
253
|
+
streamingMessageIndex.value = messages.value.length - 1
|
|
254
|
+
|
|
255
|
+
// 拼装请求体
|
|
256
|
+
const requestBody = {
|
|
233
257
|
sessionId: sessionId.value,
|
|
234
258
|
input: text,
|
|
235
259
|
payload: {
|
|
236
|
-
sceneType
|
|
237
|
-
queryType
|
|
238
|
-
processType
|
|
260
|
+
sceneType: "QUERY",
|
|
261
|
+
queryType: "CREATOR",
|
|
262
|
+
processType: "BUSINESS_TRIP",
|
|
239
263
|
queryBackLogReq: {
|
|
240
264
|
userId: '123456',
|
|
241
|
-
pageNum:1,
|
|
265
|
+
pageNum: 1,
|
|
242
266
|
pageSize: 10
|
|
243
267
|
}
|
|
244
268
|
}
|
|
245
269
|
}
|
|
246
270
|
|
|
247
271
|
try {
|
|
248
|
-
//
|
|
249
|
-
|
|
272
|
+
// 创建SSE连接
|
|
273
|
+
const baseURL = import.meta.env.VITE_API_BASE_URL || ''
|
|
274
|
+
const url = `${baseURL}/api/ai/chat/stream`
|
|
275
|
+
|
|
276
|
+
// 构建查询参数
|
|
277
|
+
const params = new URLSearchParams()
|
|
278
|
+
params.append('sessionId', requestBody.sessionId)
|
|
279
|
+
params.append('input', requestBody.input)
|
|
280
|
+
params.append('payload', JSON.stringify(requestBody.payload))
|
|
281
|
+
|
|
282
|
+
const fullUrl = `${url}?${params.toString()}`
|
|
283
|
+
|
|
284
|
+
// 创建EventSource连接
|
|
285
|
+
eventSourceRef.value = new EventSource(fullUrl)
|
|
286
|
+
|
|
287
|
+
// 监听消息事件
|
|
288
|
+
eventSourceRef.value.onmessage = (event) => {
|
|
289
|
+
try {
|
|
290
|
+
const data = JSON.parse(event.data)
|
|
291
|
+
handleSSEMessage(data)
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error('解析SSE消息失败:', error)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 监听错误事件
|
|
298
|
+
eventSourceRef.value.onerror = (error) => {
|
|
299
|
+
console.error('SSE连接错误:', error)
|
|
300
|
+
handleSSEError()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 监听连接打开事件
|
|
304
|
+
eventSourceRef.value.onopen = () => {
|
|
305
|
+
console.log('SSE连接已建立')
|
|
306
|
+
}
|
|
307
|
+
|
|
250
308
|
} catch (error) {
|
|
251
|
-
console.error('
|
|
252
|
-
showToast('
|
|
253
|
-
|
|
254
|
-
messages.value.pop()
|
|
255
|
-
} finally {
|
|
256
|
-
isLoading.value = false
|
|
309
|
+
console.error('创建SSE连接失败:', error)
|
|
310
|
+
showToast('连接失败,请重试')
|
|
311
|
+
handleSSEError()
|
|
257
312
|
}
|
|
258
313
|
}
|
|
259
314
|
|
|
260
|
-
//
|
|
261
|
-
const
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
} else if (userMessage.includes('帮助') || userMessage.includes('功能')) {
|
|
273
|
-
response = '我可以帮您解答问题、提供建议、分析数据等。请告诉我您需要什么帮助?'
|
|
274
|
-
} else if (userMessage.includes('时间')) {
|
|
275
|
-
response = `当前时间是:${new Date().toLocaleString('zh-CN')}`
|
|
276
|
-
} else if (userMessage.includes('表单')) {
|
|
277
|
-
// 返回表单类型消息,带介绍文字
|
|
278
|
-
response = {
|
|
279
|
-
type: 'form',
|
|
280
|
-
output: '请您填写以下信息,以便我们为您提供更好的服务。所有信息都将严格保密,请放心填写。',
|
|
281
|
-
schema: [
|
|
282
|
-
{
|
|
283
|
-
field: 'name',
|
|
284
|
-
label: '姓名',
|
|
285
|
-
type: 'input',
|
|
286
|
-
placeholder: '请输入您的姓名',
|
|
287
|
-
rules: [{ required: true, message: '请输入姓名' }]
|
|
288
|
-
},
|
|
289
|
-
{
|
|
290
|
-
field: 'email',
|
|
291
|
-
label: '邮箱',
|
|
292
|
-
type: 'input',
|
|
293
|
-
placeholder: '请输入您的邮箱',
|
|
294
|
-
rules: [{ required: true, message: '请输入邮箱' }, { pattern: /\w+@\w+\.\w+/, message: '邮箱格式不正确' }]
|
|
295
|
-
},
|
|
296
|
-
{
|
|
297
|
-
field: 'age',
|
|
298
|
-
label: '年龄',
|
|
299
|
-
type: 'input',
|
|
300
|
-
placeholder: '请输入您的年龄'
|
|
301
|
-
},
|
|
302
|
-
{
|
|
303
|
-
field: 'gender',
|
|
304
|
-
label: '性别',
|
|
305
|
-
type: 'radio',
|
|
306
|
-
options: [
|
|
307
|
-
{ label: '男', value: 'male' },
|
|
308
|
-
{ label: '女', value: 'female' }
|
|
309
|
-
]
|
|
310
|
-
},
|
|
311
|
-
{
|
|
312
|
-
field: 'hobbies',
|
|
313
|
-
label: '爱好',
|
|
314
|
-
type: 'checkbox',
|
|
315
|
-
options: [
|
|
316
|
-
{ label: '阅读', value: 'reading' },
|
|
317
|
-
{ label: '运动', value: 'sports' },
|
|
318
|
-
{ label: '音乐', value: 'music' },
|
|
319
|
-
{ label: '旅行', value: 'travel' }
|
|
320
|
-
]
|
|
321
|
-
},
|
|
322
|
-
{
|
|
323
|
-
field: 'bio',
|
|
324
|
-
label: '个人简介',
|
|
325
|
-
type: 'textarea',
|
|
326
|
-
placeholder: '请简单介绍一下自己',
|
|
327
|
-
rows: 3
|
|
328
|
-
}
|
|
329
|
-
]
|
|
315
|
+
// 处理SSE消息
|
|
316
|
+
const handleSSEMessage = (data) => {
|
|
317
|
+
const messageIndex = streamingMessageIndex.value
|
|
318
|
+
if (messageIndex === -1 || !messages.value[messageIndex]) return
|
|
319
|
+
|
|
320
|
+
const currentMessage = messages.value[messageIndex]
|
|
321
|
+
|
|
322
|
+
// 根据消息类型处理
|
|
323
|
+
if (data.type === 'text') {
|
|
324
|
+
// 文本流式更新
|
|
325
|
+
if (typeof currentMessage.content !== 'string') {
|
|
326
|
+
currentMessage.content = ''
|
|
330
327
|
}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
{
|
|
354
|
-
title: '信息科技部xxxaaaa啊啊啊你还是低哦是大家扫IDiOS滴哦啊啊大家送啊点喝酒哦洒几滴撒娇都i爱睡觉',
|
|
355
|
-
subtitle: '人工智能平台',
|
|
356
|
-
statusName : '待审',
|
|
357
|
-
creator : '刘松煜',
|
|
358
|
-
createTime : '2023-10-01',
|
|
359
|
-
currentNode : '节点1',
|
|
360
|
-
dpName : '信息科技部',
|
|
361
|
-
}
|
|
362
|
-
];
|
|
363
|
-
const total = 10
|
|
328
|
+
currentMessage.content += data.content || ''
|
|
329
|
+
scrollToBottom()
|
|
330
|
+
} else if (data.type === 'form') {
|
|
331
|
+
// 表单消息
|
|
332
|
+
currentMessage.type = 'form'
|
|
333
|
+
currentMessage.formSchema = data.schema
|
|
334
|
+
currentMessage.formData = data.data || {}
|
|
335
|
+
if (data.output) {
|
|
336
|
+
currentMessage.output = data.output
|
|
337
|
+
}
|
|
338
|
+
} else if (data.type === 'cardList') {
|
|
339
|
+
// 列表卡片消息
|
|
340
|
+
for (let key in data) {
|
|
341
|
+
currentMessage[key] = data[key]
|
|
342
|
+
}
|
|
343
|
+
} else if (data.type === 'done') {
|
|
344
|
+
// 流式传输完成
|
|
345
|
+
currentMessage.loading = false
|
|
346
|
+
isLoading.value = false
|
|
347
|
+
|
|
348
|
+
// 为AI助手消息添加推荐信息
|
|
349
|
+
currentMessage.suggestions = generateSuggestions(currentMessage.content)
|
|
364
350
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
currentPage: 1,
|
|
370
|
-
pageSize: 10,
|
|
371
|
-
total: total,
|
|
372
|
-
items: items,
|
|
373
|
-
loading: false // 初始化加载状态
|
|
351
|
+
// 关闭SSE连接
|
|
352
|
+
if (eventSourceRef.value) {
|
|
353
|
+
eventSourceRef.value.close()
|
|
354
|
+
eventSourceRef.value = null
|
|
374
355
|
}
|
|
375
|
-
|
|
376
|
-
|
|
356
|
+
streamingMessageIndex.value = -1
|
|
357
|
+
} else if (data.type === 'error') {
|
|
358
|
+
// 错误消息
|
|
359
|
+
handleSSEError(data.message || '服务器错误')
|
|
377
360
|
}
|
|
361
|
+
}
|
|
378
362
|
|
|
379
|
-
|
|
380
|
-
|
|
363
|
+
// 处理SSE错误
|
|
364
|
+
const handleSSEError = (errorMessage = '连接中断,请重试') => {
|
|
365
|
+
console.error('SSE错误:', errorMessage)
|
|
366
|
+
|
|
367
|
+
const messageIndex = streamingMessageIndex.value
|
|
368
|
+
if (messageIndex !== -1 && messages.value[messageIndex]) {
|
|
369
|
+
messages.value[messageIndex].loading = false
|
|
370
|
+
if (!messages.value[messageIndex].content) {
|
|
371
|
+
messages.value[messageIndex].content = errorMessage
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
isLoading.value = false
|
|
376
|
+
showToast(errorMessage)
|
|
377
|
+
|
|
378
|
+
// 关闭SSE连接
|
|
379
|
+
if (eventSourceRef.value) {
|
|
380
|
+
eventSourceRef.value.close()
|
|
381
|
+
eventSourceRef.value = null
|
|
382
|
+
}
|
|
383
|
+
streamingMessageIndex.value = -1
|
|
381
384
|
}
|
|
382
385
|
|
|
386
|
+
// 组件卸载时清理SSE连接
|
|
387
|
+
onUnmounted(() => {
|
|
388
|
+
if (eventSourceRef.value) {
|
|
389
|
+
eventSourceRef.value.close()
|
|
390
|
+
eventSourceRef.value = null
|
|
391
|
+
}
|
|
392
|
+
})
|
|
393
|
+
|
|
383
394
|
// 格式化消息内容(支持换行)
|
|
384
395
|
const formatMessage = (content) => {
|
|
385
396
|
if (!content) return ''
|
|
@@ -440,7 +451,7 @@ const handleShiftEnter = () => {
|
|
|
440
451
|
}
|
|
441
452
|
|
|
442
453
|
// 处理表单提交
|
|
443
|
-
const
|
|
454
|
+
const handleSubmit = (formData) => {
|
|
444
455
|
// 在实际应用中,这里应该将表单数据发送到服务器
|
|
445
456
|
console.log('表单提交数据:', formData)
|
|
446
457
|
|
|
@@ -463,7 +474,7 @@ const handleCardActionClick = (index, { action, item, index: itemIndex }) => {
|
|
|
463
474
|
const handleSuggestionClick = (suggestion) => {
|
|
464
475
|
console.log('Suggestion clicked:', suggestion)
|
|
465
476
|
// 将推荐信息作为用户消息发送
|
|
466
|
-
|
|
477
|
+
inputComRef.value.inputText = suggestion
|
|
467
478
|
sendMessage()
|
|
468
479
|
}
|
|
469
480
|
|
|
@@ -612,6 +623,7 @@ const handleLoadMore = (message) => {
|
|
|
612
623
|
display: flex;
|
|
613
624
|
flex-direction: column;
|
|
614
625
|
// max-width: 75%;
|
|
626
|
+
flex: 1;
|
|
615
627
|
gap: 6px;
|
|
616
628
|
}
|
|
617
629
|
|
|
@@ -688,45 +700,7 @@ const handleLoadMore = (message) => {
|
|
|
688
700
|
}
|
|
689
701
|
}
|
|
690
702
|
|
|
691
|
-
.input-area {
|
|
692
|
-
position: fixed;
|
|
693
|
-
bottom: 0;
|
|
694
|
-
left: 0;
|
|
695
|
-
right: 0;
|
|
696
|
-
background: #fff;
|
|
697
|
-
border-top: 1px solid #ebedf0;
|
|
698
|
-
padding: 8px 12px;
|
|
699
|
-
padding-bottom: calc(8px + env(safe-area-inset-bottom));
|
|
700
|
-
z-index: 100;
|
|
701
|
-
|
|
702
|
-
.input-wrapper {
|
|
703
|
-
display: flex;
|
|
704
|
-
align-items: flex-end;
|
|
705
|
-
gap: 8px;
|
|
706
|
-
max-width: 100%;
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
.chat-input {
|
|
710
|
-
flex: 1;
|
|
711
|
-
background: #f7f8fa;
|
|
712
|
-
border-radius: 20px;
|
|
713
|
-
padding: 8px 12px;
|
|
714
|
-
min-height: 40px;
|
|
715
|
-
max-height: 120px;
|
|
716
|
-
|
|
717
|
-
:deep(.van-field__control) {
|
|
718
|
-
font-size: 15px;
|
|
719
|
-
line-height: 1.5;
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
703
|
|
|
723
|
-
.send-btn {
|
|
724
|
-
flex-shrink: 0;
|
|
725
|
-
height: 40px;
|
|
726
|
-
padding: 0 20px;
|
|
727
|
-
border-radius: 20px;
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
704
|
|
|
731
705
|
|
|
732
706
|
/* 推荐信息样式 */
|
|
@@ -745,6 +719,7 @@ const handleLoadMore = (message) => {
|
|
|
745
719
|
.suggestion-tag {
|
|
746
720
|
margin-right: 8px;
|
|
747
721
|
margin-bottom: 8px;
|
|
722
|
+
padding: 4px;
|
|
748
723
|
background-color: #f0f0f0;
|
|
749
724
|
border-color: #ddd;
|
|
750
725
|
color: #666;
|
package//345/212/237/350/203/275/344/273/243/347/240/201//345/275/225/351/237/263/inputCom.vue
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
1
|
+
template
|
|
2
|
+
<template>
|
|
3
|
+
<div class="input-area">
|
|
4
|
+
<!-- 输入区域 -->
|
|
5
|
+
<div v-if="!isReadyRecording" class="input-wrapper">
|
|
6
|
+
<van-field
|
|
7
|
+
v-model="inputText"
|
|
8
|
+
type="textarea"
|
|
9
|
+
rows="1"
|
|
10
|
+
autosize
|
|
11
|
+
placeholder="输入您的问题..."
|
|
12
|
+
:disabled="isLoading"
|
|
13
|
+
@keydown.enter.exact.prevent="handleEnter"
|
|
14
|
+
@keydown.shift.enter.exact="handleShiftEnter"
|
|
15
|
+
@focus="handleInputFocus"
|
|
16
|
+
class="chat-input"
|
|
17
|
+
/>
|
|
18
|
+
<img v-if="inputText.length === 0" @click="handleClickBtn('recording')" src="@/assets/svg/voice.svg" alt="清除图标" class="clear-icon" />
|
|
19
|
+
<img v-else @click="handleEnter" src="@/assets/svg/send.svg" alt="发送图标" class="send-icon" />
|
|
20
|
+
</div>
|
|
21
|
+
<!-- 录音区域 -->
|
|
22
|
+
<div v-else
|
|
23
|
+
class="voice-container"
|
|
24
|
+
:class="{
|
|
25
|
+
recording: isRecording
|
|
26
|
+
}"
|
|
27
|
+
@touchstart.prevent="startRecord" @touchend="handleTouchEnd"
|
|
28
|
+
@touchmove="handleTouchMove"
|
|
29
|
+
@touchcancel="cancelRecord"
|
|
30
|
+
@mousedown.prevent="startRecord"
|
|
31
|
+
@mouseup="handleMouseUp"
|
|
32
|
+
@mouseleave="handleMouseLeave"
|
|
33
|
+
>
|
|
34
|
+
<div class="btn-tip" v-show="isRecording">{{ isTouchInside ? '松手发送,上移取消' : '松手取消' }}</div>
|
|
35
|
+
<div v-if="isRecording" class="waveform-container" ref="waveformRef"></div>
|
|
36
|
+
<div v-show="!isRecording" class="input-wrapper" style="display: flex; align-items: center;max-width: 100%;gap: 8px;">
|
|
37
|
+
<div class="hold-say">按住说话</div>
|
|
38
|
+
<img src="@/assets/svg/sendText.svg" alt="录音图标" class="voice-icon"
|
|
39
|
+
@mousedown.stop @touchstart.stop @click.stop="handleClickBtn('cancel')"
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
</template>
|
|
45
|
+
|
|
46
|
+
<script setup>
|
|
47
|
+
import { ref, onMounted, onUnmounted,nextTick } from 'vue'
|
|
48
|
+
import WaveSurfer from 'wavesurfer.js'
|
|
49
|
+
|
|
50
|
+
const props = defineProps({
|
|
51
|
+
isLoading: {
|
|
52
|
+
type: Boolean,
|
|
53
|
+
default: false
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const emit = defineEmits(['sendMessage'])
|
|
58
|
+
|
|
59
|
+
const inputText = ref('')
|
|
60
|
+
const isReadyRecording = ref(false) // 是否准备好录音
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
const handleEnter = () => {
|
|
65
|
+
if (inputText.value.trim()) {
|
|
66
|
+
emit('sendMessage', inputText.value)
|
|
67
|
+
inputText.value = ''
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const handleShiftEnter = () => {
|
|
72
|
+
inputText.value += '\n'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const handleInputFocus = () => {
|
|
76
|
+
if (props.isLoading) {
|
|
77
|
+
setTimeout(() => {
|
|
78
|
+
inputText.value = ''
|
|
79
|
+
}, 500)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const handleClickBtn = (type) => {
|
|
83
|
+
if (type === 'recording') {
|
|
84
|
+
isReadyRecording.value = true
|
|
85
|
+
} else if (type === 'cancel') {
|
|
86
|
+
isReadyRecording.value = false
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
defineExpose({
|
|
91
|
+
inputText
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
// 录音逻辑
|
|
97
|
+
|
|
98
|
+
const isRecording = ref(false)
|
|
99
|
+
const audioUrl = ref('')
|
|
100
|
+
const recordTip = ref('')
|
|
101
|
+
const isTouchInside = ref(true) // 跟踪触摸是否在按钮内
|
|
102
|
+
const voiceButtonRef = ref(null)
|
|
103
|
+
const waveformRef = ref(null)
|
|
104
|
+
let mediaRecorder = null // 媒体录制器实例
|
|
105
|
+
let stream = null // 音频流
|
|
106
|
+
let recordTimer = null // 录音定时器
|
|
107
|
+
const audioChunks = ref([]) // 存储录音二进制数据
|
|
108
|
+
const waveSurferInstance = ref(null) // 波形图实例
|
|
109
|
+
const audioContext = ref(null) // 音频上下文
|
|
110
|
+
const analyser = ref(null) // 分析器
|
|
111
|
+
const source = ref(null) // 音频源
|
|
112
|
+
const animationFrame = ref(null) // 动画帧
|
|
113
|
+
const dataArray = ref(null) // 音频数据数组
|
|
114
|
+
|
|
115
|
+
onUnmounted(() => {
|
|
116
|
+
cancelRecord()
|
|
117
|
+
// 确保清理所有资源
|
|
118
|
+
if (animationFrame.value) {
|
|
119
|
+
cancelAnimationFrame(animationFrame.value);
|
|
120
|
+
}
|
|
121
|
+
if (waveSurferInstance.value) {
|
|
122
|
+
waveSurferInstance.value.destroy();
|
|
123
|
+
waveSurferInstance.value = null;
|
|
124
|
+
}
|
|
125
|
+
if (audioContext.value) {
|
|
126
|
+
audioContext.value.close();
|
|
127
|
+
audioContext.value = null;
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
// 开始录音(使用原生MediaRecorder)
|
|
131
|
+
const startRecord = async () => {
|
|
132
|
+
try {
|
|
133
|
+
// 判断浏览器是否支持录音API
|
|
134
|
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
135
|
+
recordTip.value = '当前浏览器不支持录音功能'
|
|
136
|
+
return
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// 获取麦克风权限,开启音频流
|
|
140
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
141
|
+
|
|
142
|
+
// 检测浏览器支持的音频格式
|
|
143
|
+
let options = { mimeType: 'audio/webm' }
|
|
144
|
+
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
|
|
145
|
+
options = { mimeType: 'audio/webm;codecs=opus' }
|
|
146
|
+
} else if (MediaRecorder.isTypeSupported('audio/webm')) {
|
|
147
|
+
options = { mimeType: 'audio/webm' }
|
|
148
|
+
} else if (MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
|
149
|
+
options = { mimeType: 'audio/ogg;codecs=opus' }
|
|
150
|
+
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
|
|
151
|
+
options = { mimeType: 'audio/mp4' }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
mediaRecorder = new MediaRecorder(stream, options)
|
|
155
|
+
audioChunks.value = []
|
|
156
|
+
recordTip.value = ''
|
|
157
|
+
|
|
158
|
+
// 实时收集录音数据
|
|
159
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
160
|
+
audioChunks.value.push(e.data)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 开始录音
|
|
164
|
+
mediaRecorder.start()
|
|
165
|
+
isRecording.value = true
|
|
166
|
+
recordTip.value = '正在录音...'
|
|
167
|
+
|
|
168
|
+
// 设置最长录音时间为1分钟
|
|
169
|
+
recordTimer = setTimeout(() => {
|
|
170
|
+
stopRecord();
|
|
171
|
+
recordTip.value = '录音已超时(最长1分钟)';
|
|
172
|
+
}, 60 * 1000); // 60秒
|
|
173
|
+
|
|
174
|
+
// 创建实时波形图
|
|
175
|
+
await createRealTimeWaveform();
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error('录音开启失败:', err)
|
|
178
|
+
const errMap = {
|
|
179
|
+
'NotAllowedError': '麦克风权限被拒绝!请开启',
|
|
180
|
+
'NotFoundError': '未检测到麦克风',
|
|
181
|
+
'SecurityError': '请在HTTPS环境下使用'
|
|
182
|
+
};
|
|
183
|
+
recordTip.value = errMap[err.name] || `录音失败:${err.message}`;
|
|
184
|
+
isRecording.value = false
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 停止录音
|
|
189
|
+
const stopRecord = () => {
|
|
190
|
+
if (!isRecording.value || !mediaRecorder) return
|
|
191
|
+
if (mediaRecorder.state !== 'recording') return
|
|
192
|
+
|
|
193
|
+
// 清除录音定时器
|
|
194
|
+
if (recordTimer) {
|
|
195
|
+
clearTimeout(recordTimer);
|
|
196
|
+
recordTimer = null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// 停止录音并释放麦克风
|
|
200
|
+
mediaRecorder.stop()
|
|
201
|
+
stream.getTracks().forEach(track => track.stop())
|
|
202
|
+
isRecording.value = false
|
|
203
|
+
|
|
204
|
+
// 将音频转换为WAV格式
|
|
205
|
+
const convertToWav = async (blob) => {
|
|
206
|
+
return new Promise(async (resolve, reject) => {
|
|
207
|
+
try {
|
|
208
|
+
// 创建音频上下文
|
|
209
|
+
const audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
|
210
|
+
sampleRate: 16000 // 设置采样率为16000Hz,这是语音识别常用采样率
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
// 将Blob转换为ArrayBuffer
|
|
214
|
+
const arrayBuffer = await blob.arrayBuffer()
|
|
215
|
+
|
|
216
|
+
// 解码音频数据
|
|
217
|
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
|
218
|
+
|
|
219
|
+
// 创建WAV文件头
|
|
220
|
+
const numOfChan = audioBuffer.numberOfChannels
|
|
221
|
+
const length = audioBuffer.length * numOfChan * 2
|
|
222
|
+
const buffer = new ArrayBuffer(44 + length)
|
|
223
|
+
const view = new DataView(buffer)
|
|
224
|
+
|
|
225
|
+
// RIFF标识符
|
|
226
|
+
writeString(view, 0, 'RIFF')
|
|
227
|
+
// 文件长度
|
|
228
|
+
view.setUint32(4, 32 + length, true)
|
|
229
|
+
// WAVE标识符
|
|
230
|
+
writeString(view, 8, 'WAVE')
|
|
231
|
+
// fmt标识符
|
|
232
|
+
writeString(view, 12, 'fmt ')
|
|
233
|
+
// fmt块长度
|
|
234
|
+
view.setUint32(16, 16, true)
|
|
235
|
+
// 格式类型 (PCM)
|
|
236
|
+
view.setUint16(20, 1, true)
|
|
237
|
+
// 通道数
|
|
238
|
+
view.setUint16(22, numOfChan, true)
|
|
239
|
+
// 采样率
|
|
240
|
+
view.setUint32(24, audioBuffer.sampleRate, true)
|
|
241
|
+
// 字节率
|
|
242
|
+
view.setUint32(28, audioBuffer.sampleRate * numOfChan * 2, true)
|
|
243
|
+
// 块对齐
|
|
244
|
+
view.setUint16(32, numOfChan * 2, true)
|
|
245
|
+
// 位深度
|
|
246
|
+
view.setUint16(34, 16, true)
|
|
247
|
+
// data标识符
|
|
248
|
+
writeString(view, 36, 'data')
|
|
249
|
+
// 数据长度
|
|
250
|
+
view.setUint32(40, length, true)
|
|
251
|
+
|
|
252
|
+
// 将PCM数据写入缓冲区
|
|
253
|
+
floatTo16BitPCM(view, 44, audioBuffer.getChannelData(0))
|
|
254
|
+
if (numOfChan === 2) {
|
|
255
|
+
floatTo16BitPCM(view, 44 + audioBuffer.length * 2, audioBuffer.getChannelData(1))
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 创建WAV Blob
|
|
259
|
+
const wavBlob = new Blob([view], { type: 'audio/wav' })
|
|
260
|
+
resolve(wavBlob)
|
|
261
|
+
} catch (error) {
|
|
262
|
+
reject(error)
|
|
263
|
+
}
|
|
264
|
+
})
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// 辅助函数:写入字符串到DataView
|
|
268
|
+
const writeString = (view, offset, string) => {
|
|
269
|
+
for (let i = 0; i < string.length; i++) {
|
|
270
|
+
view.setUint8(offset + i, string.charCodeAt(i))
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// 辅助函数:将32位浮点数转换为16位PCM
|
|
275
|
+
const floatTo16BitPCM = (view, offset, buffer) => {
|
|
276
|
+
for (let i = 0; i < buffer.length; i++, offset += 2) {
|
|
277
|
+
const s = Math.max(-1, Math.min(1, buffer[i]))
|
|
278
|
+
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 录音结束的回调 - 生成音频文件
|
|
283
|
+
mediaRecorder.onstop = async () => {
|
|
284
|
+
try {
|
|
285
|
+
console.log(audioChunks.value);
|
|
286
|
+
|
|
287
|
+
// 使用录制时的实际MIME类型
|
|
288
|
+
const audioBlob = new Blob(audioChunks.value, { type: mediaRecorder.mimeType })
|
|
289
|
+
|
|
290
|
+
// 转换为WAV格式
|
|
291
|
+
// audioUrl.value = URL.createObjectURL(audioBlob)
|
|
292
|
+
const wavBlob = await convertToWav(audioBlob)
|
|
293
|
+
|
|
294
|
+
audioUrl.value = URL.createObjectURL(wavBlob)
|
|
295
|
+
recordTip.value = '录音完成,已自动播放'
|
|
296
|
+
|
|
297
|
+
// ✅ 核心:这里把 wavBlob 传给后端做【语音转文字】即可
|
|
298
|
+
// 传参格式:new FormData().append('file', wavBlob, 'voice.wav')
|
|
299
|
+
console.log('原始录音文件Blob对象:', audioBlob)
|
|
300
|
+
console.log('原始录音文件MIME类型:', mediaRecorder.mimeType)
|
|
301
|
+
console.log('原始录音文件大小:', audioBlob.size)
|
|
302
|
+
console.log('WAV格式录音文件Blob对象:', wavBlob)
|
|
303
|
+
console.log('WAV格式录音文件MIME类型:', wavBlob.type)
|
|
304
|
+
console.log('WAV格式录音文件大小:', wavBlob.size)
|
|
305
|
+
} catch (error) {
|
|
306
|
+
console.error('音频转换失败:', error)
|
|
307
|
+
recordTip.value = '音频转换失败'
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// 停止波形图
|
|
311
|
+
stopRealTimeWaveform();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 检查触摸点是否在元素内
|
|
315
|
+
const isTouchWithinElement = (event, element) => {
|
|
316
|
+
const touch = event.touches[0] || event.changedTouches[0];
|
|
317
|
+
const rect = element.getBoundingClientRect();
|
|
318
|
+
return (
|
|
319
|
+
touch.clientX >= rect.left &&
|
|
320
|
+
touch.clientX <= rect.right &&
|
|
321
|
+
touch.clientY >= rect.top &&
|
|
322
|
+
touch.clientY <= rect.bottom
|
|
323
|
+
);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
// 检查鼠标是否在元素内
|
|
327
|
+
const isMouseWithinElement = (event, element) => {
|
|
328
|
+
const rect = element.getBoundingClientRect();
|
|
329
|
+
return (
|
|
330
|
+
event.clientX >= rect.left &&
|
|
331
|
+
event.clientX <= rect.right &&
|
|
332
|
+
event.clientY >= rect.top &&
|
|
333
|
+
event.clientY <= rect.bottom
|
|
334
|
+
);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
// 触摸移动事件处理
|
|
338
|
+
const handleTouchMove = (event) => {
|
|
339
|
+
const button = event.currentTarget;
|
|
340
|
+
isTouchInside.value = isTouchWithinElement(event, button);
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// 触摸结束事件处理
|
|
344
|
+
const handleTouchEnd = (event) => {
|
|
345
|
+
console.log('触摸结束事件触发');
|
|
346
|
+
if (!isRecording.value) return;
|
|
347
|
+
|
|
348
|
+
const button = event.currentTarget;
|
|
349
|
+
const isWithin = isTouchWithinElement(event, button);
|
|
350
|
+
|
|
351
|
+
if (isWithin) {
|
|
352
|
+
// 手指在按钮内松开,停止录音(发送)
|
|
353
|
+
stopRecord();
|
|
354
|
+
} else {
|
|
355
|
+
// 手指在按钮外松开,取消录音
|
|
356
|
+
cancelRecord();
|
|
357
|
+
}
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// 鼠标抬起事件处理
|
|
361
|
+
const handleMouseUp = (event) => {
|
|
362
|
+
console.log('鼠标抬起事件触发');
|
|
363
|
+
if (!isRecording.value) return;
|
|
364
|
+
|
|
365
|
+
const button = event.currentTarget;
|
|
366
|
+
const isWithin = isMouseWithinElement(event, button);
|
|
367
|
+
|
|
368
|
+
if (isWithin) {
|
|
369
|
+
// 鼠标在按钮内抬起,停止录音(发送)
|
|
370
|
+
stopRecord();
|
|
371
|
+
} else {
|
|
372
|
+
// 鼠标在按钮外抬起,取消录音
|
|
373
|
+
cancelRecord();
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// 鼠标离开事件处理
|
|
378
|
+
const handleMouseLeave = (event) => {
|
|
379
|
+
console.log('鼠标离开按钮区域');
|
|
380
|
+
|
|
381
|
+
if (isRecording.value) {
|
|
382
|
+
// 鼠标离开按钮区域,取消录音
|
|
383
|
+
cancelRecord();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
// 创建实时波形图
|
|
388
|
+
const createRealTimeWaveform = async () => {
|
|
389
|
+
if (!mediaRecorder) return
|
|
390
|
+
|
|
391
|
+
// 确保 DOM 已更新
|
|
392
|
+
await nextTick();
|
|
393
|
+
|
|
394
|
+
// 如果已有波形图实例,先销毁
|
|
395
|
+
if (waveSurferInstance.value) {
|
|
396
|
+
waveSurferInstance.value.destroy();
|
|
397
|
+
waveSurferInstance.value = null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// 创建音频上下文和分析器
|
|
401
|
+
audioContext.value = new (window.AudioContext || window.webkitAudioContext)();
|
|
402
|
+
analyser.value = audioContext.value.createAnalyser();
|
|
403
|
+
source.value = audioContext.value.createMediaStreamSource(stream);
|
|
404
|
+
source.value.connect(analyser.value);
|
|
405
|
+
|
|
406
|
+
// 配置分析器
|
|
407
|
+
analyser.value.fftSize = 256;
|
|
408
|
+
const bufferLength = analyser.value.frequencyBinCount;
|
|
409
|
+
dataArray.value = new Uint8Array(bufferLength);
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
// 使用 canvas 手动绘制波形
|
|
413
|
+
const container = waveformRef.value;
|
|
414
|
+
if (!container) return;
|
|
415
|
+
|
|
416
|
+
// 确保容器干净
|
|
417
|
+
container.innerHTML = '';
|
|
418
|
+
|
|
419
|
+
// 创建 canvas 元素
|
|
420
|
+
const canvas = document.createElement('canvas');
|
|
421
|
+
canvas.width = container.clientWidth || 160;
|
|
422
|
+
canvas.height = container.clientHeight || 50;
|
|
423
|
+
|
|
424
|
+
canvas.style.width = '100%';
|
|
425
|
+
canvas.style.height = '100%';
|
|
426
|
+
container.appendChild(canvas);
|
|
427
|
+
|
|
428
|
+
const ctx = canvas.getContext('2d');
|
|
429
|
+
|
|
430
|
+
// 实时绘制波形
|
|
431
|
+
const draw = () => {
|
|
432
|
+
if (!isRecording.value || !analyser.value || !ctx) {
|
|
433
|
+
if (animationFrame.value) {
|
|
434
|
+
cancelAnimationFrame(animationFrame.value);
|
|
435
|
+
animationFrame.value = null;
|
|
436
|
+
}
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 获取频率数据
|
|
441
|
+
analyser.value.getByteFrequencyData(dataArray.value);
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
// 清空画布
|
|
445
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
446
|
+
|
|
447
|
+
// 设置波形颜色
|
|
448
|
+
ctx.fillStyle = '#fff';
|
|
449
|
+
|
|
450
|
+
// 计算每个条的宽度和间距
|
|
451
|
+
const barWidth = 2;
|
|
452
|
+
const barSpacing = 2;
|
|
453
|
+
const totalBarWidth = barWidth + barSpacing;
|
|
454
|
+
|
|
455
|
+
// 绘制波形条
|
|
456
|
+
for (let i = 0; i < dataArray.value.length; i++) {
|
|
457
|
+
const value = dataArray.value[i];
|
|
458
|
+
const percent = value / 255; // 转换为0到1的范围
|
|
459
|
+
|
|
460
|
+
// 设置最小高度为canvas高度的5%,最大高度为canvas高度的80%
|
|
461
|
+
const minHeight = canvas.height * 0.1;
|
|
462
|
+
const maxHeight = canvas.height * 0.6;
|
|
463
|
+
const height = Math.max(minHeight, Math.min(canvas.height * percent, maxHeight));
|
|
464
|
+
const offset = (canvas.height - height) / 2;
|
|
465
|
+
const x = i * totalBarWidth;
|
|
466
|
+
|
|
467
|
+
// 绘制条形
|
|
468
|
+
ctx.fillRect(x, offset, barWidth, height);
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
animationFrame.value = requestAnimationFrame(draw);
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
draw();
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
// 停止实时波形图
|
|
478
|
+
const stopRealTimeWaveform = () => {
|
|
479
|
+
if (animationFrame.value) {
|
|
480
|
+
cancelAnimationFrame(animationFrame.value);
|
|
481
|
+
animationFrame.value = null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (audioContext.value) {
|
|
485
|
+
audioContext.value.close();
|
|
486
|
+
audioContext.value = null;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (analyser.value) {
|
|
490
|
+
analyser.value = null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (source.value) {
|
|
494
|
+
source.value = null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 清空波形图容器
|
|
498
|
+
const container = waveformRef.value;
|
|
499
|
+
if (container) {
|
|
500
|
+
container.innerHTML = '';
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
// 取消录音(手指滑出按钮、页面销毁时触发)
|
|
505
|
+
const cancelRecord = () => {
|
|
506
|
+
if (isRecording.value && mediaRecorder) {
|
|
507
|
+
// 清除录音定时器
|
|
508
|
+
if (recordTimer) {
|
|
509
|
+
clearTimeout(recordTimer);
|
|
510
|
+
recordTimer = null;
|
|
511
|
+
}
|
|
512
|
+
mediaRecorder.stop()
|
|
513
|
+
stream?.getTracks().forEach(track => track.stop())
|
|
514
|
+
audioChunks.value = []
|
|
515
|
+
isRecording.value = false
|
|
516
|
+
recordTip.value = '已取消录音'
|
|
517
|
+
}
|
|
518
|
+
// 停止波形图
|
|
519
|
+
stopRealTimeWaveform();
|
|
520
|
+
// 重置触摸状态
|
|
521
|
+
isTouchInside.value = true;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
</script>
|
|
525
|
+
|
|
526
|
+
<style scoped>
|
|
527
|
+
.input-area {
|
|
528
|
+
position: fixed;
|
|
529
|
+
bottom: 0;
|
|
530
|
+
left: 0;
|
|
531
|
+
right: 0;
|
|
532
|
+
background: #fff;
|
|
533
|
+
border-top: 1px solid #ebedf0;
|
|
534
|
+
/* padding: 8px 12px;
|
|
535
|
+
padding-bottom: calc(8px + env(safe-area-inset-bottom)); */
|
|
536
|
+
z-index: 100;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.input-wrapper {
|
|
540
|
+
display: flex;
|
|
541
|
+
align-items: center;
|
|
542
|
+
padding: 8px 12px;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.chat-input {
|
|
546
|
+
flex: 1;
|
|
547
|
+
margin-right: 10px;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
.send-btn {
|
|
551
|
+
height: 32px;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.btn-tip{
|
|
555
|
+
position: absolute;
|
|
556
|
+
top: 0px;
|
|
557
|
+
left: 50%;
|
|
558
|
+
transform: translate(-50%,-100%);
|
|
559
|
+
text-align: center;
|
|
560
|
+
color: #848080;
|
|
561
|
+
}
|
|
562
|
+
.recording{
|
|
563
|
+
background: #2217eb;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.voice-container{
|
|
567
|
+
width: 100%;
|
|
568
|
+
position: relative;
|
|
569
|
+
.waveform-container {
|
|
570
|
+
position: absolute;
|
|
571
|
+
z-index: 200;
|
|
572
|
+
height: 100px;
|
|
573
|
+
/* height: 100%; */
|
|
574
|
+
/* height: 80px; */
|
|
575
|
+
display: flex;
|
|
576
|
+
align-items: center;
|
|
577
|
+
justify-content: center;
|
|
578
|
+
position: relative;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
.hold-say{
|
|
582
|
+
flex: 1;
|
|
583
|
+
height: 44px;
|
|
584
|
+
text-align: center;
|
|
585
|
+
line-height: 44px;
|
|
586
|
+
color: #848080;
|
|
587
|
+
font-weight: bold;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
</style>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import { createVuePlugin } from 'vite-plugin-vue2'
|
|
3
|
+
import px2rem from 'postcss-pxtorem'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [
|
|
7
|
+
createVuePlugin()
|
|
8
|
+
],
|
|
9
|
+
css: {
|
|
10
|
+
postcss: {
|
|
11
|
+
plugins: [
|
|
12
|
+
px2rem({
|
|
13
|
+
rootValue: 16, // 设计稿为375px时,rootValue设为16(16px * 375/375 = 16px)
|
|
14
|
+
propList: ['*'],
|
|
15
|
+
minPixelValue: 1,
|
|
16
|
+
})
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
})
|
|
Binary file
|