@flexem/chat-box 1.0.0
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/README.md +638 -0
- package/miniprogram_dist/TEST_CASES.md +256 -0
- package/miniprogram_dist/assets/icons/icon-arrow-down.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-arrow-up.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-avatar-default.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-back.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-camera.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-close.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-copy.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-delete.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-edit-msg.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-edit.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-file.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-image.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-keyboard.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-menu.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-play-voice.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-plus.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-regenerate.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-thinking.svg +1 -0
- package/miniprogram_dist/assets/icons/icon-voice.svg +1 -0
- package/miniprogram_dist/components/attachment/index.js +169 -0
- package/miniprogram_dist/components/attachment/index.json +4 -0
- package/miniprogram_dist/components/attachment/index.wxml +40 -0
- package/miniprogram_dist/components/attachment/index.wxss +119 -0
- package/miniprogram_dist/components/input-bar/index.js +934 -0
- package/miniprogram_dist/components/input-bar/index.json +6 -0
- package/miniprogram_dist/components/input-bar/index.wxml +132 -0
- package/miniprogram_dist/components/input-bar/index.wxss +324 -0
- package/miniprogram_dist/components/message/index.js +988 -0
- package/miniprogram_dist/components/message/index.json +4 -0
- package/miniprogram_dist/components/message/index.wxml +285 -0
- package/miniprogram_dist/components/message/index.wxss +575 -0
- package/miniprogram_dist/components/sidebar/index.js +506 -0
- package/miniprogram_dist/components/sidebar/index.json +4 -0
- package/miniprogram_dist/components/sidebar/index.wxml +137 -0
- package/miniprogram_dist/components/sidebar/index.wxss +264 -0
- package/miniprogram_dist/index.js +1316 -0
- package/miniprogram_dist/index.json +8 -0
- package/miniprogram_dist/index.wxml +172 -0
- package/miniprogram_dist/index.wxss +291 -0
- package/miniprogram_dist/package.json +5 -0
- package/miniprogram_dist/utils/api.js +474 -0
- package/miniprogram_dist/utils/audio.js +860 -0
- package/miniprogram_dist/utils/storage.js +168 -0
- package/package.json +27 -0
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 输入栏组件
|
|
3
|
+
* 支持文本输入、语音输入、附件发送
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const api = require('../../utils/api.js');
|
|
7
|
+
|
|
8
|
+
// 获取录音管理器(用于不使用语音识别时的纯录音)
|
|
9
|
+
const recorderManager = wx.getRecorderManager();
|
|
10
|
+
|
|
11
|
+
// 录音配置
|
|
12
|
+
const RECORD_OPTIONS = {
|
|
13
|
+
duration: 60000,
|
|
14
|
+
sampleRate: 16000,
|
|
15
|
+
numberOfChannels: 1,
|
|
16
|
+
encodeBitRate: 48000,
|
|
17
|
+
format: 'mp3',
|
|
18
|
+
frameSize: 50
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
Component({
|
|
22
|
+
properties: {
|
|
23
|
+
// 占位文本
|
|
24
|
+
placeholder: {
|
|
25
|
+
type: String,
|
|
26
|
+
value: '输入消息...'
|
|
27
|
+
},
|
|
28
|
+
// 最大字数
|
|
29
|
+
maxLength: {
|
|
30
|
+
type: Number,
|
|
31
|
+
value: 2000
|
|
32
|
+
},
|
|
33
|
+
// 是否禁用
|
|
34
|
+
disabled: {
|
|
35
|
+
type: Boolean,
|
|
36
|
+
value: false
|
|
37
|
+
},
|
|
38
|
+
// 是否正在生成中
|
|
39
|
+
isGenerating: {
|
|
40
|
+
type: Boolean,
|
|
41
|
+
value: false
|
|
42
|
+
},
|
|
43
|
+
// 语音转文字函数(用于识别已录制的音频文件)
|
|
44
|
+
voiceToText: {
|
|
45
|
+
type: null,
|
|
46
|
+
value: null
|
|
47
|
+
},
|
|
48
|
+
// 语音识别管理器(WechatSI 的 RecordRecognitionManager,边录边识别)
|
|
49
|
+
speechRecognitionManager: {
|
|
50
|
+
type: null,
|
|
51
|
+
value: null
|
|
52
|
+
},
|
|
53
|
+
// API 基础地址(aiChatUrl)
|
|
54
|
+
baseUrl: {
|
|
55
|
+
type: String,
|
|
56
|
+
value: ''
|
|
57
|
+
},
|
|
58
|
+
// OSS 服务地址
|
|
59
|
+
ossServiceUrl: {
|
|
60
|
+
type: String,
|
|
61
|
+
value: ''
|
|
62
|
+
},
|
|
63
|
+
// 文件下载服务地址
|
|
64
|
+
blobStorageDownloadServiceUrl: {
|
|
65
|
+
type: String,
|
|
66
|
+
value: ''
|
|
67
|
+
},
|
|
68
|
+
// 用户 Token
|
|
69
|
+
token: {
|
|
70
|
+
type: String,
|
|
71
|
+
value: ''
|
|
72
|
+
},
|
|
73
|
+
// 默认输入值
|
|
74
|
+
defaultValue: {
|
|
75
|
+
type: String,
|
|
76
|
+
value: ''
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
data: {
|
|
81
|
+
inputValue: '', // 输入内容
|
|
82
|
+
inputHeight: 50, // 输入框高度
|
|
83
|
+
maxInputHeight: 200, // 最大输入框高度(5行)
|
|
84
|
+
isVoiceMode: false, // 是否语音模式
|
|
85
|
+
isRecording: false, // 是否正在录音
|
|
86
|
+
isRecognizing: false, // 是否正在识别
|
|
87
|
+
isCancelArea: false, // 是否在取消区域(上滑取消)
|
|
88
|
+
showAttachment: false, // 是否显示附件选择器
|
|
89
|
+
attachments: [], // 附件列表
|
|
90
|
+
canSend: false, // 是否可发送
|
|
91
|
+
keyboardHeight: 0, // 键盘高度
|
|
92
|
+
textareaFocus: false, // textarea 是否聚焦
|
|
93
|
+
recognizedText: '', // 语音识别的中间结果
|
|
94
|
+
recordStartTime: 0, // 录音开始时间
|
|
95
|
+
voiceStartY: 0 // 录音开始时的 Y 坐标
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
observers: {
|
|
99
|
+
'inputValue, attachments': function(inputValue, attachments) {
|
|
100
|
+
this.setData({
|
|
101
|
+
canSend: !!(inputValue && inputValue.trim()) || (attachments && attachments.length > 0)
|
|
102
|
+
});
|
|
103
|
+
// 通知父组件附件区域高度变化
|
|
104
|
+
this.triggerEvent('attachmentheight', {
|
|
105
|
+
hasAttachments: attachments && attachments.length > 0,
|
|
106
|
+
count: attachments ? attachments.length : 0
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
'defaultValue': function(value) {
|
|
110
|
+
if (value) {
|
|
111
|
+
this.setData({ inputValue: value });
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
'speechRecognitionManager': function(manager) {
|
|
115
|
+
if (manager) {
|
|
116
|
+
this.initSpeechRecognition();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
lifetimes: {
|
|
122
|
+
attached() {
|
|
123
|
+
this.initRecorder();
|
|
124
|
+
// speechRecognitionManager 通过 observer 初始化
|
|
125
|
+
if (this.properties.defaultValue) {
|
|
126
|
+
this.setData({ inputValue: this.properties.defaultValue });
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
detached() {
|
|
130
|
+
if (this.data.isRecording) {
|
|
131
|
+
this.stopRecording();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
methods: {
|
|
137
|
+
/**
|
|
138
|
+
* 初始化录音管理器(用于不使用语音识别时)
|
|
139
|
+
*/
|
|
140
|
+
initRecorder() {
|
|
141
|
+
// 保存当前组件实例引用
|
|
142
|
+
const self = this;
|
|
143
|
+
|
|
144
|
+
recorderManager.onStop((res) => {
|
|
145
|
+
console.log('录音结束', res);
|
|
146
|
+
|
|
147
|
+
// 如果使用了 speechRecognitionManager,不需要在这里处理
|
|
148
|
+
if (self.properties.speechRecognitionManager) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (res.duration < 1000) {
|
|
153
|
+
wx.showToast({
|
|
154
|
+
title: '录音时间太短',
|
|
155
|
+
icon: 'none'
|
|
156
|
+
});
|
|
157
|
+
self.setData({ isRecording: false });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
self.recognizeVoice(res.tempFilePath);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
recorderManager.onError((err) => {
|
|
165
|
+
console.error('录音错误', err);
|
|
166
|
+
wx.showToast({
|
|
167
|
+
title: '录音失败',
|
|
168
|
+
icon: 'none'
|
|
169
|
+
});
|
|
170
|
+
self.setData({ isRecording: false });
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* 初始化语音识别管理器(WechatSI 边录边识别)
|
|
176
|
+
*/
|
|
177
|
+
initSpeechRecognition() {
|
|
178
|
+
const manager = this.properties.speechRecognitionManager;
|
|
179
|
+
if (!manager) return;
|
|
180
|
+
|
|
181
|
+
// 避免重复初始化
|
|
182
|
+
if (this._speechRecognitionInitialized) return;
|
|
183
|
+
this._speechRecognitionInitialized = true;
|
|
184
|
+
|
|
185
|
+
const self = this;
|
|
186
|
+
|
|
187
|
+
console.log('初始化语音识别管理器');
|
|
188
|
+
|
|
189
|
+
manager.onStart = () => {
|
|
190
|
+
console.log('语音识别开始');
|
|
191
|
+
self.setData({ recognizedText: '' });
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
manager.onRecognize = (result) => {
|
|
195
|
+
console.log('onRecognize:', result);
|
|
196
|
+
if (result.result) {
|
|
197
|
+
self.setData({ recognizedText: result.result });
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
manager.onStop = (result) => {
|
|
202
|
+
console.log('语音识别结束 onStop:', result);
|
|
203
|
+
self.setData({
|
|
204
|
+
isRecording: false,
|
|
205
|
+
isRecognizing: false
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// 检查是否被用户取消(上滑取消)
|
|
209
|
+
if (self._isVoiceCancelled) {
|
|
210
|
+
console.log('语音已被用户取消,不发送消息');
|
|
211
|
+
self._isVoiceCancelled = false;
|
|
212
|
+
self.setData({ recognizedText: '' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const text = result.result || self.data.recognizedText || '';
|
|
217
|
+
console.log('识别结果:', text);
|
|
218
|
+
|
|
219
|
+
// 获取已上传完成的附件
|
|
220
|
+
const attachments = self.data.attachments.filter(item => !item.uploading);
|
|
221
|
+
|
|
222
|
+
if (text.trim() || attachments.length > 0) {
|
|
223
|
+
// 语音识别后自动发送(包含已上传的附件)
|
|
224
|
+
self.triggerEvent('send', {
|
|
225
|
+
content: text.trim(),
|
|
226
|
+
attachments: attachments.map(item => ({
|
|
227
|
+
type: item.type,
|
|
228
|
+
name: item.name,
|
|
229
|
+
url: item.remoteUrl || item.url, // 优先使用远程URL
|
|
230
|
+
parsedContent: item.parsedContent
|
|
231
|
+
})),
|
|
232
|
+
isVoice: true
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// 清空附件
|
|
236
|
+
self.setData({ attachments: [] });
|
|
237
|
+
} else {
|
|
238
|
+
wx.showToast({
|
|
239
|
+
title: '未识别到语音内容',
|
|
240
|
+
icon: 'none'
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
self.setData({ recognizedText: '' });
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
manager.onError = (err) => {
|
|
247
|
+
console.error('语音识别错误:', err);
|
|
248
|
+
self.setData({
|
|
249
|
+
isRecording: false,
|
|
250
|
+
isRecognizing: false,
|
|
251
|
+
recognizedText: ''
|
|
252
|
+
});
|
|
253
|
+
wx.showToast({
|
|
254
|
+
title: '语音识别失败',
|
|
255
|
+
icon: 'none'
|
|
256
|
+
});
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 停止录音/识别
|
|
262
|
+
*/
|
|
263
|
+
stopRecording() {
|
|
264
|
+
const manager = this.properties.speechRecognitionManager;
|
|
265
|
+
if (manager) {
|
|
266
|
+
manager.stop();
|
|
267
|
+
} else {
|
|
268
|
+
recorderManager.stop();
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* 文本输入
|
|
274
|
+
*/
|
|
275
|
+
onInput(e) {
|
|
276
|
+
this.setData({
|
|
277
|
+
inputValue: e.detail.value
|
|
278
|
+
});
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 输入框行数变化
|
|
283
|
+
*/
|
|
284
|
+
onLineChange(e) {
|
|
285
|
+
const lineHeight = 35;
|
|
286
|
+
const baseHeight = 50; // 单行基准高度
|
|
287
|
+
const lines = Math.min(e.detail.lineCount, 5);
|
|
288
|
+
const newHeight = lineHeight * lines;
|
|
289
|
+
this.setData({
|
|
290
|
+
inputHeight: newHeight
|
|
291
|
+
});
|
|
292
|
+
// 通知父组件输入框高度增量(相对于单行的增加量)
|
|
293
|
+
const extraHeight = newHeight - baseHeight;
|
|
294
|
+
this.triggerEvent('inputheight', { height: extraHeight });
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 输入框聚焦
|
|
299
|
+
*/
|
|
300
|
+
onInputFocus(e) {
|
|
301
|
+
// focus 事件中不再处理键盘高度,由 keyboardheightchange 事件处理
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 输入框失焦
|
|
306
|
+
*/
|
|
307
|
+
onInputBlur() {
|
|
308
|
+
// blur 事件中不再处理键盘高度,由 keyboardheightchange 事件处理
|
|
309
|
+
},
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* 键盘高度变化(更可靠的事件)
|
|
313
|
+
*/
|
|
314
|
+
onKeyboardHeightChange(e) {
|
|
315
|
+
const keyboardHeight = e.detail.height || 0;
|
|
316
|
+
this.setData({ keyboardHeight });
|
|
317
|
+
this.triggerEvent('keyboardheight', { height: keyboardHeight });
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 切换输入模式
|
|
322
|
+
*/
|
|
323
|
+
toggleInputMode() {
|
|
324
|
+
const toTextMode = this.data.isVoiceMode;
|
|
325
|
+
this.setData({
|
|
326
|
+
isVoiceMode: !this.data.isVoiceMode,
|
|
327
|
+
textareaFocus: toTextMode
|
|
328
|
+
});
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* 语音按钮按下
|
|
333
|
+
*/
|
|
334
|
+
onVoiceStart(e) {
|
|
335
|
+
// 记录按下时间,用于判断是否为快速点击
|
|
336
|
+
this._voiceTouchStartTime = Date.now();
|
|
337
|
+
this._voiceTouchCancelled = false;
|
|
338
|
+
// 重置语音取消标志
|
|
339
|
+
this._isVoiceCancelled = false;
|
|
340
|
+
// 标记是否正在处理权限(授权弹窗等),此时不应提示"没有识别到语音"
|
|
341
|
+
this._isHandlingPermission = false;
|
|
342
|
+
// 标记权限是否已授权
|
|
343
|
+
this._hasRecordPermission = false;
|
|
344
|
+
|
|
345
|
+
// 记录起始 Y 坐标,用于判断上滑取消
|
|
346
|
+
const touch = e.touches[0];
|
|
347
|
+
this.setData({
|
|
348
|
+
voiceStartY: touch ? touch.clientY : 0,
|
|
349
|
+
isCancelArea: false
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// 立即检查录音权限(不延迟,这样点击也能触发授权弹窗)
|
|
353
|
+
wx.getSetting({
|
|
354
|
+
success: (res) => {
|
|
355
|
+
const recordAuth = res.authSetting['scope.record'];
|
|
356
|
+
|
|
357
|
+
if (recordAuth === true) {
|
|
358
|
+
// 已授权,标记权限状态
|
|
359
|
+
this._hasRecordPermission = true;
|
|
360
|
+
|
|
361
|
+
// 如果用户已经松开,不启动录音
|
|
362
|
+
if (this._voiceTouchCancelled) {
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// 延迟启动录音,避免快速点击也触发录音
|
|
367
|
+
this._recordDelayTimer = setTimeout(() => {
|
|
368
|
+
if (!this._voiceTouchCancelled) {
|
|
369
|
+
this.startRecording();
|
|
370
|
+
}
|
|
371
|
+
}, 150);
|
|
372
|
+
} else if (recordAuth === false) {
|
|
373
|
+
// 用户之前拒绝过,需要去设置页开启
|
|
374
|
+
this._isHandlingPermission = true;
|
|
375
|
+
wx.showModal({
|
|
376
|
+
title: '提示',
|
|
377
|
+
content: '请授权录音权限以使用语音功能',
|
|
378
|
+
showCancel: true,
|
|
379
|
+
confirmText: '去设置',
|
|
380
|
+
success: (modalRes) => {
|
|
381
|
+
if (modalRes.confirm) {
|
|
382
|
+
wx.openSetting();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
} else {
|
|
387
|
+
// 首次请求授权(点击或按住都会触发)
|
|
388
|
+
this._isHandlingPermission = true;
|
|
389
|
+
wx.authorize({
|
|
390
|
+
scope: 'scope.record',
|
|
391
|
+
success: () => {
|
|
392
|
+
// 授权成功后提示用户重新按住说话,不自动开始录音
|
|
393
|
+
wx.showToast({
|
|
394
|
+
title: '授权成功,请重新按住说话',
|
|
395
|
+
icon: 'none'
|
|
396
|
+
});
|
|
397
|
+
},
|
|
398
|
+
fail: () => {
|
|
399
|
+
wx.showToast({
|
|
400
|
+
title: '需要录音权限',
|
|
401
|
+
icon: 'none'
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 语音按钮滑动(检测上滑取消)
|
|
412
|
+
*/
|
|
413
|
+
onVoiceMove(e) {
|
|
414
|
+
if (!this.data.isRecording) return;
|
|
415
|
+
|
|
416
|
+
const touch = e.touches[0];
|
|
417
|
+
if (!touch) return;
|
|
418
|
+
|
|
419
|
+
const currentY = touch.clientY;
|
|
420
|
+
const startY = this.data.voiceStartY;
|
|
421
|
+
// 上滑超过 50px 进入取消区域
|
|
422
|
+
const cancelThreshold = 50;
|
|
423
|
+
const isCancelArea = startY - currentY > cancelThreshold;
|
|
424
|
+
|
|
425
|
+
if (isCancelArea !== this.data.isCancelArea) {
|
|
426
|
+
this.setData({ isCancelArea });
|
|
427
|
+
// 进入取消区域时震动提示
|
|
428
|
+
if (isCancelArea) {
|
|
429
|
+
wx.vibrateShort({ type: 'light' });
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* 语音按钮松开
|
|
436
|
+
*/
|
|
437
|
+
onVoiceEnd() {
|
|
438
|
+
// 检查是否在取消区域
|
|
439
|
+
const shouldCancel = this.data.isCancelArea;
|
|
440
|
+
|
|
441
|
+
// 重置取消区域状态
|
|
442
|
+
this.setData({ isCancelArea: false });
|
|
443
|
+
|
|
444
|
+
// 标记已取消,防止延迟启动的录音
|
|
445
|
+
this._voiceTouchCancelled = true;
|
|
446
|
+
|
|
447
|
+
// 清除延迟录音定时器
|
|
448
|
+
if (this._recordDelayTimer) {
|
|
449
|
+
clearTimeout(this._recordDelayTimer);
|
|
450
|
+
this._recordDelayTimer = null;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// 如果在取消区域,取消录音
|
|
454
|
+
if (shouldCancel && this.data.isRecording) {
|
|
455
|
+
// 设置取消标志,防止 onStop 回调发送消息
|
|
456
|
+
this._isVoiceCancelled = true;
|
|
457
|
+
this.stopRecording();
|
|
458
|
+
this.setData({ isRecording: false, recognizedText: '' });
|
|
459
|
+
wx.showToast({
|
|
460
|
+
title: '已取消',
|
|
461
|
+
icon: 'none'
|
|
462
|
+
});
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// 如果正在处理权限(授权弹窗等),不提示
|
|
467
|
+
if (this._isHandlingPermission) {
|
|
468
|
+
this._isHandlingPermission = false;
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 如果录音还没开始
|
|
473
|
+
if (!this.data.isRecording) {
|
|
474
|
+
// 只有在已授权的情况下才提示"没有识别到语音"
|
|
475
|
+
// 未授权的情况下不提示(因为会弹出授权弹窗或设置弹窗)
|
|
476
|
+
if (this._hasRecordPermission) {
|
|
477
|
+
const touchDuration = Date.now() - (this._voiceTouchStartTime || 0);
|
|
478
|
+
if (touchDuration < 500) {
|
|
479
|
+
wx.showToast({
|
|
480
|
+
title: '没有识别到语音',
|
|
481
|
+
icon: 'none'
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 如果录音已开始但时间太短,停止并提示
|
|
489
|
+
const recordDuration = Date.now() - this.data.recordStartTime;
|
|
490
|
+
if (recordDuration < 500) {
|
|
491
|
+
this.stopRecording();
|
|
492
|
+
this.setData({ isRecording: false, recognizedText: '' });
|
|
493
|
+
wx.showToast({
|
|
494
|
+
title: '没有识别到语音',
|
|
495
|
+
icon: 'none'
|
|
496
|
+
});
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
this.setData({ isRecording: false });
|
|
501
|
+
this.stopRecording();
|
|
502
|
+
},
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* 语音按钮取消(系统中断,如来电)
|
|
506
|
+
*/
|
|
507
|
+
onVoiceCancel() {
|
|
508
|
+
// 标记已取消,防止延迟启动的录音
|
|
509
|
+
this._voiceTouchCancelled = true;
|
|
510
|
+
|
|
511
|
+
// 重置取消区域状态
|
|
512
|
+
this.setData({ isCancelArea: false });
|
|
513
|
+
|
|
514
|
+
// 清除延迟录音定时器
|
|
515
|
+
if (this._recordDelayTimer) {
|
|
516
|
+
clearTimeout(this._recordDelayTimer);
|
|
517
|
+
this._recordDelayTimer = null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
if (this.data.isRecording) {
|
|
521
|
+
// 设置取消标志,防止 onStop 回调发送消息
|
|
522
|
+
this._isVoiceCancelled = true;
|
|
523
|
+
this.stopRecording();
|
|
524
|
+
this.setData({ isRecording: false, recognizedText: '' });
|
|
525
|
+
wx.showToast({
|
|
526
|
+
title: '已取消',
|
|
527
|
+
icon: 'none'
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
},
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* 长按语音按钮
|
|
534
|
+
*/
|
|
535
|
+
onVoiceLongPress() {
|
|
536
|
+
// 长按触发振动反馈
|
|
537
|
+
wx.vibrateShort({ type: 'medium' });
|
|
538
|
+
},
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* 开始录音
|
|
542
|
+
*/
|
|
543
|
+
startRecording() {
|
|
544
|
+
if (this.data.isRecording) return;
|
|
545
|
+
|
|
546
|
+
// 记录录音开始时间
|
|
547
|
+
this.setData({
|
|
548
|
+
isRecording: true,
|
|
549
|
+
recognizedText: '',
|
|
550
|
+
recordStartTime: Date.now()
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
const manager = this.properties.speechRecognitionManager;
|
|
554
|
+
if (manager) {
|
|
555
|
+
// 使用 WechatSI 的边录边识别
|
|
556
|
+
manager.start({
|
|
557
|
+
lang: 'zh_CN'
|
|
558
|
+
});
|
|
559
|
+
} else {
|
|
560
|
+
// 使用普通录音
|
|
561
|
+
recorderManager.start(RECORD_OPTIONS);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
wx.vibrateShort({ type: 'light' });
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* 语音识别
|
|
569
|
+
*/
|
|
570
|
+
recognizeVoice(filePath) {
|
|
571
|
+
this.setData({
|
|
572
|
+
isRecording: false,
|
|
573
|
+
isRecognizing: true
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
const voiceToTextFn = this.properties.voiceToText;
|
|
577
|
+
|
|
578
|
+
if (typeof voiceToTextFn !== 'function') {
|
|
579
|
+
console.error('请传入 voiceToText 属性');
|
|
580
|
+
wx.showToast({
|
|
581
|
+
title: '未配置语音识别',
|
|
582
|
+
icon: 'none'
|
|
583
|
+
});
|
|
584
|
+
this.setData({ isRecognizing: false });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
voiceToTextFn({
|
|
589
|
+
filePath: filePath,
|
|
590
|
+
lang: 'zh_CN',
|
|
591
|
+
success: (res) => {
|
|
592
|
+
console.log('语音识别成功', res);
|
|
593
|
+
this.setData({ isRecognizing: false });
|
|
594
|
+
|
|
595
|
+
// 获取已上传完成的附件
|
|
596
|
+
const attachments = this.data.attachments.filter(item => !item.uploading);
|
|
597
|
+
|
|
598
|
+
if ((res.result && res.result.trim()) || attachments.length > 0) {
|
|
599
|
+
// 语音识别后自动发送(包含已上传的附件)
|
|
600
|
+
this.triggerEvent('send', {
|
|
601
|
+
content: (res.result || '').trim(),
|
|
602
|
+
attachments: attachments.map(item => ({
|
|
603
|
+
type: item.type,
|
|
604
|
+
name: item.name,
|
|
605
|
+
url: item.remoteUrl || item.url, // 优先使用远程URL
|
|
606
|
+
parsedContent: item.parsedContent
|
|
607
|
+
})),
|
|
608
|
+
isVoice: true
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
// 清空附件
|
|
612
|
+
this.setData({ attachments: [] });
|
|
613
|
+
} else {
|
|
614
|
+
wx.showToast({
|
|
615
|
+
title: '未识别到语音内容',
|
|
616
|
+
icon: 'none'
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
fail: (err) => {
|
|
621
|
+
console.error('语音识别失败', err);
|
|
622
|
+
this.setData({ isRecognizing: false });
|
|
623
|
+
wx.showToast({
|
|
624
|
+
title: '语音识别失败',
|
|
625
|
+
icon: 'none'
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
});
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* 显示附件选择器
|
|
633
|
+
*/
|
|
634
|
+
showAttachmentPicker() {
|
|
635
|
+
this.setData({ showAttachment: true });
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* 隐藏附件选择器
|
|
640
|
+
*/
|
|
641
|
+
hideAttachmentPicker() {
|
|
642
|
+
this.setData({ showAttachment: false });
|
|
643
|
+
},
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 附件选择
|
|
647
|
+
*/
|
|
648
|
+
onAttachmentSelect(e) {
|
|
649
|
+
const { type, files } = e.detail;
|
|
650
|
+
this.hideAttachmentPicker();
|
|
651
|
+
|
|
652
|
+
if (!files || files.length === 0) return;
|
|
653
|
+
|
|
654
|
+
// 附件数量限制:最多5个
|
|
655
|
+
const MAX_COUNT = 5;
|
|
656
|
+
const currentCount = this.data.attachments.filter(item => item.type === type).length;
|
|
657
|
+
const typeLabel = type === 'image' ? '图片' : '文件';
|
|
658
|
+
|
|
659
|
+
if (currentCount + files.length > MAX_COUNT) {
|
|
660
|
+
const tipMsg = currentCount > 0
|
|
661
|
+
? `您已选择${files.length}个${typeLabel},但当前已上传${currentCount}个${typeLabel},超出限制`
|
|
662
|
+
: `您已选择${files.length}个${typeLabel},超出限制`;
|
|
663
|
+
wx.showToast({
|
|
664
|
+
title: `每次最多上传${MAX_COUNT}个${typeLabel}`,
|
|
665
|
+
icon: 'none',
|
|
666
|
+
duration: 2000
|
|
667
|
+
});
|
|
668
|
+
console.warn(tipMsg);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// 上传文件
|
|
673
|
+
files.forEach(file => {
|
|
674
|
+
this.uploadFile(file);
|
|
675
|
+
});
|
|
676
|
+
},
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 上传文件
|
|
680
|
+
*/
|
|
681
|
+
async uploadFile(file) {
|
|
682
|
+
const attachmentId = Date.now() + '_' + Math.random().toString(36).slice(2, 11);
|
|
683
|
+
|
|
684
|
+
// 添加到附件列表(上传中状态)
|
|
685
|
+
const newAttachment = {
|
|
686
|
+
id: attachmentId,
|
|
687
|
+
type: file.type || 'file',
|
|
688
|
+
name: file.name || '未知文件',
|
|
689
|
+
url: file.path, // 本地预览路径(不会改变)
|
|
690
|
+
remoteUrl: '', // 远程URL(上传成功后填充)
|
|
691
|
+
uploading: true,
|
|
692
|
+
progress: 0
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
this.setData({
|
|
696
|
+
attachments: [...this.data.attachments, newAttachment]
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
try {
|
|
700
|
+
// 获取上传 token(使用 ossServiceUrl)
|
|
701
|
+
const ossUrl = this.properties.ossServiceUrl;
|
|
702
|
+
if (!ossUrl) {
|
|
703
|
+
throw new Error('OSS 服务地址未配置');
|
|
704
|
+
}
|
|
705
|
+
const uploadToken = await api.getQiniuToken(ossUrl, this.properties.token);
|
|
706
|
+
|
|
707
|
+
const key = `chat-files/${Date.now()}-${Math.random().toString(36).slice(2, 11)}-${file.name || 'file'}`;
|
|
708
|
+
|
|
709
|
+
// 使用 blobStorageDownloadServiceUrl 作为下载地址
|
|
710
|
+
const downloadUrl = this.properties.blobStorageDownloadServiceUrl;
|
|
711
|
+
if (!downloadUrl) {
|
|
712
|
+
throw new Error('文件下载服务地址未配置');
|
|
713
|
+
}
|
|
714
|
+
const fileUrl = `${downloadUrl}/${key}`;
|
|
715
|
+
|
|
716
|
+
// 上传到七牛
|
|
717
|
+
api.uploadFile({
|
|
718
|
+
filePath: file.path,
|
|
719
|
+
uploadToken: uploadToken,
|
|
720
|
+
key: key,
|
|
721
|
+
onProgress: (progress) => {
|
|
722
|
+
this.updateAttachmentProgress(attachmentId, progress);
|
|
723
|
+
},
|
|
724
|
+
onSuccess: async (result) => {
|
|
725
|
+
// 上传成功,更新附件信息
|
|
726
|
+
const finalUrl = fileUrl;
|
|
727
|
+
|
|
728
|
+
// 解析文件/图片内容
|
|
729
|
+
let parsedContent = '';
|
|
730
|
+
try {
|
|
731
|
+
const parseResult = await api.parseFileContent(
|
|
732
|
+
this.properties.baseUrl,
|
|
733
|
+
this.properties.token,
|
|
734
|
+
finalUrl
|
|
735
|
+
);
|
|
736
|
+
parsedContent = parseResult.content || parseResult || '';
|
|
737
|
+
if (!parsedContent || parsedContent.trim() === '') {
|
|
738
|
+
parsedContent = file.type === 'image' ? `Image: ${file.name}` : '未识别到内容';
|
|
739
|
+
}
|
|
740
|
+
} catch (e) {
|
|
741
|
+
console.error('解析文件内容失败', e);
|
|
742
|
+
parsedContent = file.type === 'image' ? `Image: ${file.name}` : '未识别到内容';
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
this.updateAttachment(attachmentId, {
|
|
746
|
+
remoteUrl: finalUrl, // 保存远程URL,但不改变 url(避免图片重新加载)
|
|
747
|
+
uploading: false,
|
|
748
|
+
progress: 100,
|
|
749
|
+
parsedContent: parsedContent
|
|
750
|
+
});
|
|
751
|
+
},
|
|
752
|
+
onError: (err) => {
|
|
753
|
+
console.error('上传失败', err);
|
|
754
|
+
wx.showToast({
|
|
755
|
+
title: '上传失败',
|
|
756
|
+
icon: 'none'
|
|
757
|
+
});
|
|
758
|
+
this.removeAttachment({ currentTarget: { dataset: { id: attachmentId } } });
|
|
759
|
+
}
|
|
760
|
+
});
|
|
761
|
+
} catch (error) {
|
|
762
|
+
console.error('获取上传凭证失败', error);
|
|
763
|
+
wx.showToast({
|
|
764
|
+
title: '上传失败',
|
|
765
|
+
icon: 'none'
|
|
766
|
+
});
|
|
767
|
+
this.removeAttachment({ currentTarget: { dataset: { id: attachmentId } } });
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
|
|
771
|
+
/**
|
|
772
|
+
* 更新附件上传进度
|
|
773
|
+
*/
|
|
774
|
+
updateAttachmentProgress(id, progress) {
|
|
775
|
+
const attachments = this.data.attachments.map(item => {
|
|
776
|
+
if (item.id === id) {
|
|
777
|
+
return { ...item, progress };
|
|
778
|
+
}
|
|
779
|
+
return item;
|
|
780
|
+
});
|
|
781
|
+
this.setData({ attachments });
|
|
782
|
+
},
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* 更新附件信息
|
|
786
|
+
*/
|
|
787
|
+
updateAttachment(id, updates) {
|
|
788
|
+
const attachments = this.data.attachments.map(item => {
|
|
789
|
+
if (item.id === id) {
|
|
790
|
+
return { ...item, ...updates };
|
|
791
|
+
}
|
|
792
|
+
return item;
|
|
793
|
+
});
|
|
794
|
+
this.setData({ attachments });
|
|
795
|
+
},
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* 预览附件图片
|
|
799
|
+
*/
|
|
800
|
+
onPreviewAttachment(e) {
|
|
801
|
+
const url = e.currentTarget.dataset.url;
|
|
802
|
+
// 获取所有图片附件的 URL
|
|
803
|
+
const imageUrls = this.data.attachments
|
|
804
|
+
.filter(item => item.type === 'image')
|
|
805
|
+
.map(item => item.url);
|
|
806
|
+
|
|
807
|
+
if (imageUrls.length > 0 && url) {
|
|
808
|
+
wx.previewImage({
|
|
809
|
+
current: url,
|
|
810
|
+
urls: imageUrls
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
|
|
815
|
+
/**
|
|
816
|
+
* 移除附件
|
|
817
|
+
*/
|
|
818
|
+
removeAttachment(e) {
|
|
819
|
+
const id = e.currentTarget.dataset.id;
|
|
820
|
+
const attachments = this.data.attachments.filter(item => item.id !== id);
|
|
821
|
+
this.setData({ attachments });
|
|
822
|
+
},
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* 清空输入框(供外部调用)
|
|
826
|
+
*/
|
|
827
|
+
clearInput() {
|
|
828
|
+
this.setData({
|
|
829
|
+
inputValue: '',
|
|
830
|
+
inputHeight: 50, // 恢复到默认高度
|
|
831
|
+
attachments: [],
|
|
832
|
+
canSend: false
|
|
833
|
+
});
|
|
834
|
+
},
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* 发送消息
|
|
838
|
+
*/
|
|
839
|
+
onSend() {
|
|
840
|
+
const content = this.data.inputValue.trim();
|
|
841
|
+
const attachments = this.data.attachments.filter(item => !item.uploading);
|
|
842
|
+
|
|
843
|
+
if (!content && attachments.length === 0) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
// 检查是否有正在上传的附件
|
|
848
|
+
const uploadingCount = this.data.attachments.filter(item => item.uploading).length;
|
|
849
|
+
if (uploadingCount > 0) {
|
|
850
|
+
wx.showToast({
|
|
851
|
+
title: '请等待附件上传完成',
|
|
852
|
+
icon: 'none'
|
|
853
|
+
});
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// 触发发送事件
|
|
858
|
+
this.triggerEvent('send', {
|
|
859
|
+
content: content,
|
|
860
|
+
attachments: attachments.map(item => ({
|
|
861
|
+
type: item.type,
|
|
862
|
+
name: item.name,
|
|
863
|
+
url: item.remoteUrl || item.url, // 优先使用远程URL,如果没有则使用本地URL
|
|
864
|
+
parsedContent: item.parsedContent
|
|
865
|
+
})),
|
|
866
|
+
isVoice: false
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
// 清空输入
|
|
870
|
+
this.setData({
|
|
871
|
+
inputValue: '',
|
|
872
|
+
attachments: [],
|
|
873
|
+
inputHeight: 50
|
|
874
|
+
});
|
|
875
|
+
},
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* 清空输入
|
|
879
|
+
*/
|
|
880
|
+
clear() {
|
|
881
|
+
this.setData({
|
|
882
|
+
inputValue: '',
|
|
883
|
+
attachments: [],
|
|
884
|
+
inputHeight: 40
|
|
885
|
+
});
|
|
886
|
+
},
|
|
887
|
+
|
|
888
|
+
/**
|
|
889
|
+
* 设置输入内容
|
|
890
|
+
*/
|
|
891
|
+
setValue(value) {
|
|
892
|
+
this.setData({ inputValue: value });
|
|
893
|
+
},
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* 设置附件(供外部调用,用于编辑消息时恢复附件)
|
|
897
|
+
* @param {Array} attachments - 附件数组,格式: [{ type, name, url }]
|
|
898
|
+
*/
|
|
899
|
+
setAttachments(attachments) {
|
|
900
|
+
if (!attachments || attachments.length === 0) {
|
|
901
|
+
this.setData({ attachments: [] });
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// 转换为组件内部格式
|
|
906
|
+
const formattedAttachments = attachments.map((item, index) => ({
|
|
907
|
+
id: Date.now() + '_' + index + '_' + Math.random().toString(36).slice(2, 11),
|
|
908
|
+
type: item.type || 'file',
|
|
909
|
+
name: item.name || '未知文件',
|
|
910
|
+
url: item.url,
|
|
911
|
+
uploading: false,
|
|
912
|
+
progress: 100,
|
|
913
|
+
parsedContent: item.parsedContent || ''
|
|
914
|
+
}));
|
|
915
|
+
|
|
916
|
+
this.setData({ attachments: formattedAttachments });
|
|
917
|
+
},
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* 聚焦输入框
|
|
921
|
+
*/
|
|
922
|
+
focus() {
|
|
923
|
+
// 切换到文本模式并聚焦
|
|
924
|
+
this.setData({ isVoiceMode: false });
|
|
925
|
+
},
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* 停止生成
|
|
929
|
+
*/
|
|
930
|
+
onStop() {
|
|
931
|
+
this.triggerEvent('stop');
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
});
|