@foundbyte/uni-agent 1.0.0-alpha.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/components/bubble-list/index.scss +254 -0
- package/components/bubble-list/index.vue +505 -0
- package/components/bubble-list/types.ts +110 -0
- package/components/bubble-list/use-typewriter.ts +193 -0
- package/components/bubble-wrapper/index.scss +34 -0
- package/components/bubble-wrapper/index.vue +97 -0
- package/components/history/index.scss +87 -0
- package/components/history/index.vue +118 -0
- package/components/history/types.ts +16 -0
- package/components/index.ts +4 -0
- package/components/prompts/index.scss +30 -0
- package/components/prompts/index.vue +46 -0
- package/components/prompts/types.ts +11 -0
- package/components/sender/image-picker.ts +100 -0
- package/components/sender/index.scss +288 -0
- package/components/sender/index.vue +374 -0
- package/components/sender/types.ts +56 -0
- package/composables/index.ts +6 -0
- package/composables/use-inner-audio/audio-manager.ts +22 -0
- package/composables/use-inner-audio/enums.ts +15 -0
- package/composables/use-inner-audio/types.ts +8 -0
- package/composables/use-inner-audio/use-inner-audio.ts +149 -0
- package/composables/use-prefix.ts +54 -0
- package/composables/use-recorder/h5-recorder-manager.ts +492 -0
- package/composables/use-recorder/native-recorder-manager.ts +181 -0
- package/composables/use-recorder/recorder-manager.ts +40 -0
- package/composables/use-recorder/types.ts +73 -0
- package/composables/use-recorder/use-recorder.ts +205 -0
- package/configs/index.ts +5 -0
- package/index.ts +3 -0
- package/package.json +90 -0
- package/styles/_base.scss +8 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<view :class="p()" @contextmenu.prevent>
|
|
3
|
+
<slot></slot>
|
|
4
|
+
<view :class="[p('input-wrapper'), { [p('input-wrapper', 'multi-line')]: isMultiLine }]">
|
|
5
|
+
<!-- 切换按钮 -->
|
|
6
|
+
<view v-show="enableVoice && !canSend" :class="p('mode-toggle')" @click="toggleMode">
|
|
7
|
+
<image
|
|
8
|
+
:class="[p('toggle-icon'), { [p('toggle-icon', 'disabled')]: disabled }]"
|
|
9
|
+
:src="`${staticPrefix}/icon-sender-${inputMode === 'text' ? 'voice' : 'kb'}.png`"
|
|
10
|
+
/>
|
|
11
|
+
</view>
|
|
12
|
+
|
|
13
|
+
<!-- 文本输入模式 -->
|
|
14
|
+
<template v-if="inputMode === 'text'">
|
|
15
|
+
<textarea
|
|
16
|
+
v-model="inputText"
|
|
17
|
+
:class="p('chat-input')"
|
|
18
|
+
:maxlength="maxlength"
|
|
19
|
+
:auto-height="true"
|
|
20
|
+
:placeholder="placeholder"
|
|
21
|
+
:fixed="true"
|
|
22
|
+
:show-confirm-bar="false"
|
|
23
|
+
:cursor-spacing="cursorSpacing"
|
|
24
|
+
@focus="isAttachmentPanelShow = false"
|
|
25
|
+
@linechange="onLineChange"
|
|
26
|
+
@confirm="onSend"
|
|
27
|
+
/>
|
|
28
|
+
</template>
|
|
29
|
+
|
|
30
|
+
<!-- 语音输入模式 -->
|
|
31
|
+
<template v-else>
|
|
32
|
+
<view
|
|
33
|
+
:class="[
|
|
34
|
+
p('voice-input-btn'),
|
|
35
|
+
{ [p('voice-input-btn', 'recording')]: isRecording },
|
|
36
|
+
{ [p('voice-input-btn', 'disabled')]: disabled },
|
|
37
|
+
{ [p('voice-input-btn', 'will-cancel')]: isWillCancel },
|
|
38
|
+
]"
|
|
39
|
+
@touchstart="onVoiceStart"
|
|
40
|
+
@touchend="onVoiceEnd"
|
|
41
|
+
@touchmove="onVoiceMove"
|
|
42
|
+
@touchcancel="onVoiceCancel"
|
|
43
|
+
>
|
|
44
|
+
<text>{{ isRecording ? '松开结束' : '按住说话' }}</text>
|
|
45
|
+
</view>
|
|
46
|
+
</template>
|
|
47
|
+
|
|
48
|
+
<!-- 发送按钮 -->
|
|
49
|
+
<image
|
|
50
|
+
v-if="canSend"
|
|
51
|
+
:src="`${staticPrefix}/btn-send.png`"
|
|
52
|
+
:class="[p('send-btn'), { [p('send-btn', 'disabled')]: !canSend || disabled }]"
|
|
53
|
+
@click="onSend"
|
|
54
|
+
/>
|
|
55
|
+
<!-- 附件按钮 (+号) -->
|
|
56
|
+
<view
|
|
57
|
+
v-if="enableAttachment && !canSend"
|
|
58
|
+
:class="[
|
|
59
|
+
p('attachment-toggle'),
|
|
60
|
+
{
|
|
61
|
+
[p('attachment-toggle', 'active')]: isAttachmentPanelShow,
|
|
62
|
+
[p('attachment-toggle', 'disabled')]: disabled,
|
|
63
|
+
},
|
|
64
|
+
]"
|
|
65
|
+
@click="toggleAttachmentPanel"
|
|
66
|
+
>
|
|
67
|
+
<image :src="`${staticPrefix}/icon-sender-plus.png`" :class="p('attachment-icon')" />
|
|
68
|
+
</view>
|
|
69
|
+
</view>
|
|
70
|
+
|
|
71
|
+
<!-- 附件选择面板 -->
|
|
72
|
+
<view
|
|
73
|
+
:class="[
|
|
74
|
+
p('attachment-panel'),
|
|
75
|
+
{
|
|
76
|
+
[p('attachment-panel', 'visible')]: isAttachmentPanelShow,
|
|
77
|
+
},
|
|
78
|
+
]"
|
|
79
|
+
>
|
|
80
|
+
<view :class="p('attachment-options')">
|
|
81
|
+
<view :class="p('attachment-item')" @click="onAttachmentSelect('album')">
|
|
82
|
+
<view :class="p(['attachment-item-icon', 'wrap'])">
|
|
83
|
+
<image
|
|
84
|
+
:src="`${staticPrefix}/icon-sender-tool-album.png`"
|
|
85
|
+
:class="p('attachment-item-icon')"
|
|
86
|
+
/>
|
|
87
|
+
</view>
|
|
88
|
+
<text :class="p('attachment-item-label')">相册</text>
|
|
89
|
+
</view>
|
|
90
|
+
<view :class="p('attachment-item')" @click="onAttachmentSelect('camera')">
|
|
91
|
+
<view :class="p(['attachment-item-icon', 'wrap'])">
|
|
92
|
+
<image
|
|
93
|
+
:src="`${staticPrefix}/icon-sender-tool-camera.png`"
|
|
94
|
+
:class="p('attachment-item-icon')"
|
|
95
|
+
/>
|
|
96
|
+
</view>
|
|
97
|
+
<text :class="p('attachment-item-label')">拍照</text>
|
|
98
|
+
</view>
|
|
99
|
+
</view>
|
|
100
|
+
</view>
|
|
101
|
+
|
|
102
|
+
<!-- 录音遮罩层 -->
|
|
103
|
+
<view v-if="isRecording" :class="p('voice-overlay')" @touchmove.stop.prevent>
|
|
104
|
+
<view :class="p(['voice-overlay', 'content'])">
|
|
105
|
+
<!-- 语音转换文本显示区域 -->
|
|
106
|
+
<!-- <view :class="p('voice-output-text')">{{ formatRecordingTime(recordingTime) }}</view> -->
|
|
107
|
+
<!-- 麦克风 -->
|
|
108
|
+
<view :class="p('voice-mic')">
|
|
109
|
+
<view
|
|
110
|
+
:class="[
|
|
111
|
+
p('voice-mic-bg'),
|
|
112
|
+
{
|
|
113
|
+
[p('voice-mic-bg', 'will-cancel')]: isWillCancel,
|
|
114
|
+
},
|
|
115
|
+
]"
|
|
116
|
+
></view>
|
|
117
|
+
<text :class="p('voice-mic-text')">
|
|
118
|
+
{{ isWillCancel ? '松手取消' : '松手发送,上移取消' }}
|
|
119
|
+
</text>
|
|
120
|
+
<!-- 录音动画 -->
|
|
121
|
+
<view :class="p('voice-marquee-viewport')">
|
|
122
|
+
<view
|
|
123
|
+
:class="[
|
|
124
|
+
p('voice-marquee-track'),
|
|
125
|
+
{ [p('voice-marquee-track', 'paused')]: isWillCancel },
|
|
126
|
+
]"
|
|
127
|
+
>
|
|
128
|
+
<!-- 图片1 -->
|
|
129
|
+
<view :class="p('voice-marquee-item')">
|
|
130
|
+
<image :class="p('voice-marquee-img')" :src="`${staticPrefix}/recorder.gif`" />
|
|
131
|
+
</view>
|
|
132
|
+
<!-- 图片2(完全复制,用于无缝衔接) -->
|
|
133
|
+
<view :class="p('voice-marquee-item')">
|
|
134
|
+
<image :class="p('voice-marquee-img')" :src="`${staticPrefix}/recorder.gif`" />
|
|
135
|
+
</view>
|
|
136
|
+
</view>
|
|
137
|
+
</view>
|
|
138
|
+
</view>
|
|
139
|
+
</view>
|
|
140
|
+
</view>
|
|
141
|
+
</view>
|
|
142
|
+
</template>
|
|
143
|
+
|
|
144
|
+
<script setup lang="ts">
|
|
145
|
+
// eslint-disable-next-line no-restricted-imports
|
|
146
|
+
import { computed, ref } from 'vue'
|
|
147
|
+
import { staticPrefix } from '../../configs'
|
|
148
|
+
import { usePrefix, useRecorder } from '../../composables'
|
|
149
|
+
import { chooseImage } from './image-picker'
|
|
150
|
+
import type { AttachmentType, ChooseImageResult, InputMode, SenderProps } from './types'
|
|
151
|
+
import type { RecorderResult } from '../../composables'
|
|
152
|
+
|
|
153
|
+
defineOptions({
|
|
154
|
+
options: {
|
|
155
|
+
styleIsolation: 'shared',
|
|
156
|
+
virtualHost: true,
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
const props = withDefaults(defineProps<SenderProps>(), {
|
|
161
|
+
disabled: false,
|
|
162
|
+
placeholder: '请输入消息...',
|
|
163
|
+
maxlength: 500,
|
|
164
|
+
cursorSpacing: 20,
|
|
165
|
+
enableVoice: true,
|
|
166
|
+
defaultMode: 'text',
|
|
167
|
+
enableAttachment: true,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const emit = defineEmits<{
|
|
171
|
+
/** 发送消息事件 */
|
|
172
|
+
(e: 'send', text: string): void
|
|
173
|
+
/** 语音输入事件 */
|
|
174
|
+
// (e: 'voice', type: VoiceEventType): void
|
|
175
|
+
/** 语音输入结果事件 */
|
|
176
|
+
(e: 'voice', result: RecorderResult): void
|
|
177
|
+
/** 输入模式切换事件 */
|
|
178
|
+
(e: 'modeChange', mode: InputMode): void
|
|
179
|
+
/** 图片选择完成事件 */
|
|
180
|
+
(e: 'imageSelected', result: ChooseImageResult): void
|
|
181
|
+
/** 图片选择错误事件 */
|
|
182
|
+
(e: 'imageError', error: string): void
|
|
183
|
+
}>()
|
|
184
|
+
|
|
185
|
+
const p = usePrefix('sender')
|
|
186
|
+
|
|
187
|
+
// 录音功能
|
|
188
|
+
const {
|
|
189
|
+
isRecording,
|
|
190
|
+
start: startRecording,
|
|
191
|
+
stop: stopRecording,
|
|
192
|
+
cancel: cancelRecording,
|
|
193
|
+
} = useRecorder()
|
|
194
|
+
|
|
195
|
+
// 输入模式: text-文本输入 voice-语音输入
|
|
196
|
+
const inputMode = ref<InputMode>(props.defaultMode)
|
|
197
|
+
|
|
198
|
+
// 是否将取消发送(手指上滑)
|
|
199
|
+
const isWillCancel = ref(false)
|
|
200
|
+
|
|
201
|
+
// 输入文本
|
|
202
|
+
const inputText = ref('')
|
|
203
|
+
|
|
204
|
+
// 是否显示附件面板
|
|
205
|
+
const isAttachmentPanelShow = ref(false)
|
|
206
|
+
|
|
207
|
+
// 录音开始时的触摸位置
|
|
208
|
+
let startY = 0
|
|
209
|
+
|
|
210
|
+
// 是否可以发送
|
|
211
|
+
const canSend = computed(() => {
|
|
212
|
+
return inputText.value.trim().length > 0
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 切换输入模式
|
|
217
|
+
*/
|
|
218
|
+
function toggleMode() {
|
|
219
|
+
if (props.disabled) return
|
|
220
|
+
isAttachmentPanelShow.value = false
|
|
221
|
+
inputMode.value = inputMode.value === 'text' ? 'voice' : 'text'
|
|
222
|
+
emit('modeChange', inputMode.value)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 切换附件面板显示状态
|
|
227
|
+
*/
|
|
228
|
+
function toggleAttachmentPanel() {
|
|
229
|
+
if (props.disabled) return
|
|
230
|
+
isAttachmentPanelShow.value = !isAttachmentPanelShow.value
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 选择附件类型
|
|
235
|
+
*/
|
|
236
|
+
function onAttachmentSelect(type: AttachmentType) {
|
|
237
|
+
isAttachmentPanelShow.value = false
|
|
238
|
+
|
|
239
|
+
// 根据类型选择图片来源
|
|
240
|
+
if (type === 'album' || type === 'camera') {
|
|
241
|
+
chooseImage(
|
|
242
|
+
{
|
|
243
|
+
sourceType: [type],
|
|
244
|
+
count: 1, // 默认最多选择1张
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
onSuccess(res) {
|
|
248
|
+
emit('imageSelected', res)
|
|
249
|
+
},
|
|
250
|
+
onError(e) {
|
|
251
|
+
emit('imageError', e)
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 开始语音输入
|
|
260
|
+
*/
|
|
261
|
+
async function onVoiceStart(event: TouchEvent) {
|
|
262
|
+
if (props.disabled) return
|
|
263
|
+
isWillCancel.value = false
|
|
264
|
+
startY = event.touches[0].clientY
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await startRecording({
|
|
268
|
+
duration: 1000 * 60, // 1分钟
|
|
269
|
+
format: 'aac',
|
|
270
|
+
})
|
|
271
|
+
} catch (error) {
|
|
272
|
+
console.error('启动录音失败:', error)
|
|
273
|
+
cancelRecording()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 语音输入时手指移动
|
|
279
|
+
*/
|
|
280
|
+
function onVoiceMove(event: TouchEvent) {
|
|
281
|
+
if (!isRecording.value) return
|
|
282
|
+
const currentY = event.touches[0].clientY
|
|
283
|
+
const moveDistance = startY - currentY
|
|
284
|
+
// 上滑超过 80rpx 时进入取消状态
|
|
285
|
+
isWillCancel.value = moveDistance > 80
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* 结束语音输入
|
|
290
|
+
*/
|
|
291
|
+
async function onVoiceEnd() {
|
|
292
|
+
// if (!isRecording.value) return
|
|
293
|
+
|
|
294
|
+
// 如果处于取消状态,则触发取消事件
|
|
295
|
+
if (isWillCancel.value) {
|
|
296
|
+
isWillCancel.value = false
|
|
297
|
+
cancelRecording()
|
|
298
|
+
} else {
|
|
299
|
+
try {
|
|
300
|
+
const result = await stopRecording()
|
|
301
|
+
if (result) {
|
|
302
|
+
emit('voice', result)
|
|
303
|
+
}
|
|
304
|
+
} catch (error) {
|
|
305
|
+
console.error('停止录音失败:', error)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* 取消语音输入
|
|
312
|
+
*/
|
|
313
|
+
function onVoiceCancel() {
|
|
314
|
+
if (!isRecording.value) return
|
|
315
|
+
isWillCancel.value = false
|
|
316
|
+
cancelRecording()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* 发送消息
|
|
321
|
+
*/
|
|
322
|
+
function onSend() {
|
|
323
|
+
const text = inputText.value.trim()
|
|
324
|
+
if (!text || props.disabled) return
|
|
325
|
+
|
|
326
|
+
// 触发发送事件
|
|
327
|
+
emit('send', text)
|
|
328
|
+
|
|
329
|
+
// 清空输入框
|
|
330
|
+
inputText.value = ''
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** 清空输入 */
|
|
334
|
+
function clear() {
|
|
335
|
+
inputText.value = ''
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** 设置输入值 */
|
|
339
|
+
function setValue(value: string) {
|
|
340
|
+
inputText.value = value
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** 获取当前输入值 */
|
|
344
|
+
function getValue(): string {
|
|
345
|
+
return inputText.value
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const isMultiLine = ref(false)
|
|
349
|
+
function onLineChange(e: { detail: { lineCount: number } }) {
|
|
350
|
+
isMultiLine.value = e.detail.lineCount > 1
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* 格式化录音时间
|
|
355
|
+
* @param ms 毫秒数
|
|
356
|
+
*/
|
|
357
|
+
// function formatRecordingTime(ms: number): string {
|
|
358
|
+
// const seconds = Math.floor(ms / 1000)
|
|
359
|
+
// const milliseconds = Math.floor((ms % 1000) / 100)
|
|
360
|
+
// return `${seconds}.${milliseconds}s`
|
|
361
|
+
// }
|
|
362
|
+
|
|
363
|
+
defineExpose({
|
|
364
|
+
clear,
|
|
365
|
+
setValue,
|
|
366
|
+
getValue,
|
|
367
|
+
toggleMode,
|
|
368
|
+
onAttachmentSelect,
|
|
369
|
+
})
|
|
370
|
+
</script>
|
|
371
|
+
|
|
372
|
+
<style lang="scss">
|
|
373
|
+
@import './index.scss';
|
|
374
|
+
</style>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** 输入模式 */
|
|
2
|
+
export type InputMode = 'text' | 'voice'
|
|
3
|
+
|
|
4
|
+
/** 输入框组件 Props */
|
|
5
|
+
export interface SenderProps {
|
|
6
|
+
/** 是否禁用发送按钮 */
|
|
7
|
+
disabled?: boolean
|
|
8
|
+
/** 输入框占位符 */
|
|
9
|
+
placeholder?: string
|
|
10
|
+
/** 最大输入长度 */
|
|
11
|
+
maxlength?: number
|
|
12
|
+
/** 光标与键盘的距离 */
|
|
13
|
+
cursorSpacing?: number
|
|
14
|
+
/** 是否启用语音输入功能 */
|
|
15
|
+
enableVoice?: boolean
|
|
16
|
+
/** 初始输入模式 */
|
|
17
|
+
defaultMode?: InputMode
|
|
18
|
+
/** 是否启用附件功能(+号按钮) */
|
|
19
|
+
enableAttachment?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 发送事件参数 */
|
|
23
|
+
export interface SendEvent {
|
|
24
|
+
text: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 附件类型 */
|
|
28
|
+
export type AttachmentType = 'album' | 'camera'
|
|
29
|
+
|
|
30
|
+
/** 附件事件参数 */
|
|
31
|
+
export interface AttachmentEvent {
|
|
32
|
+
type: AttachmentType
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 图片选择选项 */
|
|
36
|
+
export interface ChooseImageOptions {
|
|
37
|
+
/** 最多可以选择的图片张数,默认 9 */
|
|
38
|
+
count?: number
|
|
39
|
+
/** 图片压缩类型,默认 'compressed' */
|
|
40
|
+
sizeType?: ('original' | 'compressed')[]
|
|
41
|
+
/** 选择图片的来源,默认 ['album', 'camera'] */
|
|
42
|
+
sourceType?: ('album' | 'camera')[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 选择的图片信息 */
|
|
46
|
+
export interface ChooseImageResult {
|
|
47
|
+
/** 图片的本地临时文件路径列表 */
|
|
48
|
+
tempFilePaths: string[]
|
|
49
|
+
/** 图片的本地临时文件列表(包含文件信息) */
|
|
50
|
+
tempFiles: {
|
|
51
|
+
/** 本地临时文件路径 */
|
|
52
|
+
path: string
|
|
53
|
+
/** 本地临时文件大小,单位 B */
|
|
54
|
+
size: number
|
|
55
|
+
}[]
|
|
56
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export * from './use-inner-audio/enums'
|
|
2
|
+
export * from './use-inner-audio/types'
|
|
3
|
+
export { useInnerAudio } from './use-inner-audio/use-inner-audio'
|
|
4
|
+
export { usePrefix } from './use-prefix'
|
|
5
|
+
export * from './use-recorder/types'
|
|
6
|
+
export { useRecorder } from './use-recorder/use-recorder'
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** 全局音频实例 */
|
|
2
|
+
let audioInstance: UniApp.InnerAudioContext | null = null
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 获取全局音频实例(单例)
|
|
6
|
+
*/
|
|
7
|
+
export function getAudioInstance(): UniApp.InnerAudioContext {
|
|
8
|
+
if (!audioInstance) {
|
|
9
|
+
audioInstance = uni.createInnerAudioContext()
|
|
10
|
+
}
|
|
11
|
+
return audioInstance
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 销毁音频实例
|
|
16
|
+
*/
|
|
17
|
+
export function destroyAudioInstance(): void {
|
|
18
|
+
if (audioInstance) {
|
|
19
|
+
audioInstance.destroy()
|
|
20
|
+
audioInstance = null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** 音频播放状态 */
|
|
2
|
+
export enum EAudioPlayState {
|
|
3
|
+
/** 空闲状态,初始状态或未加载 */
|
|
4
|
+
Idle = 'idle',
|
|
5
|
+
/** 加载中 */
|
|
6
|
+
Loading = 'loading',
|
|
7
|
+
/** 播放中 */
|
|
8
|
+
Playing = 'playing',
|
|
9
|
+
/** 已暂停 */
|
|
10
|
+
Paused = 'paused',
|
|
11
|
+
/** 播放结束 */
|
|
12
|
+
Ended = 'ended',
|
|
13
|
+
/** 播放错误 */
|
|
14
|
+
Error = 'error',
|
|
15
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// eslint-disable-next-line no-restricted-imports
|
|
2
|
+
import { onUnmounted, ref } from 'vue'
|
|
3
|
+
import { destroyAudioInstance, getAudioInstance } from './audio-manager'
|
|
4
|
+
import type { AudioError } from './types'
|
|
5
|
+
import { EAudioPlayState } from './enums'
|
|
6
|
+
|
|
7
|
+
export { destroyAudioInstance, getAudioInstance }
|
|
8
|
+
|
|
9
|
+
// 全局当前播放id
|
|
10
|
+
const playingId = ref<string>()
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 音频播放器 Hooks
|
|
14
|
+
* 全局单例模式,确保整个应用只有一个音频实例
|
|
15
|
+
* @returns 音频播放控制方法和状态
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* const { state, currentTime, duration, play, pause, load } = useInnerAudio()
|
|
19
|
+
*
|
|
20
|
+
* // 加载并播放
|
|
21
|
+
* load('https://example.com/audio.mp3')
|
|
22
|
+
* play()
|
|
23
|
+
*/
|
|
24
|
+
export function useInnerAudio() {
|
|
25
|
+
const audio = getAudioInstance()
|
|
26
|
+
|
|
27
|
+
// 状态
|
|
28
|
+
const state = ref<EAudioPlayState>(EAudioPlayState.Idle)
|
|
29
|
+
const currentTime = ref(0)
|
|
30
|
+
const duration = ref(0)
|
|
31
|
+
const src = ref('')
|
|
32
|
+
const error = ref<AudioError | null>(null)
|
|
33
|
+
|
|
34
|
+
const isPlaying = computed(() => state.value === EAudioPlayState.Playing)
|
|
35
|
+
|
|
36
|
+
// 更新状态
|
|
37
|
+
const setState = (newState: EAudioPlayState) => {
|
|
38
|
+
state.value = newState
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 注册事件
|
|
42
|
+
audio.onPlay(() => setState(EAudioPlayState.Playing))
|
|
43
|
+
audio.onPause(() => setState(EAudioPlayState.Paused))
|
|
44
|
+
audio.onStop(() => {
|
|
45
|
+
setState(EAudioPlayState.Paused)
|
|
46
|
+
currentTime.value = 0
|
|
47
|
+
})
|
|
48
|
+
audio.onEnded(() => {
|
|
49
|
+
setState(EAudioPlayState.Ended)
|
|
50
|
+
currentTime.value = 0
|
|
51
|
+
})
|
|
52
|
+
audio.onTimeUpdate(() => {
|
|
53
|
+
currentTime.value = audio.currentTime
|
|
54
|
+
duration.value = audio.duration
|
|
55
|
+
})
|
|
56
|
+
audio.onError((err) => {
|
|
57
|
+
error.value = { errMsg: err.errMsg }
|
|
58
|
+
setState(EAudioPlayState.Error)
|
|
59
|
+
})
|
|
60
|
+
// audio.onWaiting(() => setState(EAudioPlayState.Loading))
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 加载音频
|
|
64
|
+
* @param id 唯一标识
|
|
65
|
+
* @param url 音频地址
|
|
66
|
+
* @param autoplay 是否自动播放
|
|
67
|
+
*/
|
|
68
|
+
const load = (id: string, url: string, autoplay = false) => {
|
|
69
|
+
playingId.value = id
|
|
70
|
+
src.value = url
|
|
71
|
+
audio.src = url
|
|
72
|
+
if (autoplay) {
|
|
73
|
+
audio.play()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 播放 */
|
|
78
|
+
const play = () => {
|
|
79
|
+
if (!src.value) {
|
|
80
|
+
console.warn('[useInnerAudio] 请先调用 load() 加载音频')
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
audio.play()
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 暂停 */
|
|
87
|
+
const pause = () => {
|
|
88
|
+
audio.pause()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 停止 */
|
|
92
|
+
const stop = () => {
|
|
93
|
+
audio.stop()
|
|
94
|
+
currentTime.value = 0
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 跳转到指定位置
|
|
99
|
+
* @param position 秒
|
|
100
|
+
*/
|
|
101
|
+
const seek = (position: number) => {
|
|
102
|
+
audio.seek(position)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 切换播放/暂停 */
|
|
106
|
+
const toggle = () => {
|
|
107
|
+
if (state.value === EAudioPlayState.Playing) {
|
|
108
|
+
pause()
|
|
109
|
+
} else {
|
|
110
|
+
play()
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 销毁 */
|
|
115
|
+
const destroy = () => {
|
|
116
|
+
destroyAudioInstance()
|
|
117
|
+
state.value = EAudioPlayState.Idle
|
|
118
|
+
src.value = ''
|
|
119
|
+
currentTime.value = 0
|
|
120
|
+
duration.value = 0
|
|
121
|
+
error.value = null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
onUnmounted(() => {
|
|
125
|
+
// 全局单例,不在卸载时销毁
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
// 状态
|
|
130
|
+
state,
|
|
131
|
+
currentTime,
|
|
132
|
+
duration,
|
|
133
|
+
src,
|
|
134
|
+
error,
|
|
135
|
+
isPlaying,
|
|
136
|
+
playingId,
|
|
137
|
+
|
|
138
|
+
// 方法
|
|
139
|
+
load,
|
|
140
|
+
play,
|
|
141
|
+
pause,
|
|
142
|
+
stop,
|
|
143
|
+
seek,
|
|
144
|
+
toggle,
|
|
145
|
+
destroy,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export default useInnerAudio
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/* eslint-disable no-restricted-syntax */
|
|
2
|
+
import { cssPrefix } from '../configs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 获取带前缀的 class 名称(BEM 规范)
|
|
6
|
+
* @param block 块级组件名称
|
|
7
|
+
* @returns 返回一个函数,用于生成带前缀的 BEM class 名
|
|
8
|
+
* @example
|
|
9
|
+
* const p = usePrefix('bubble')
|
|
10
|
+
* p() // 'ua-bubble'
|
|
11
|
+
* p('list') // 'ua-bubble__list'
|
|
12
|
+
* p('item', 'active') // 'ua-bubble__item ua-bubble__item--active'
|
|
13
|
+
* p('avatar', 'left') // 'ua-bubble__avatar ua-bubble__avatar--left'
|
|
14
|
+
* p(['avatar', 'image']) // 'ua-bubble__avatar-image'
|
|
15
|
+
*/
|
|
16
|
+
export function usePrefix(block: string) {
|
|
17
|
+
return (...args: (string | string[] | false | null | undefined)[]) => {
|
|
18
|
+
const validArgs = args.filter(Boolean) as (string | string[])[]
|
|
19
|
+
|
|
20
|
+
if (validArgs.length === 0) {
|
|
21
|
+
return `${cssPrefix}-${block}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 处理数组参数,如 ['avatar', 'image'] -> 'avatar-image'
|
|
25
|
+
const firstArg = validArgs[0]
|
|
26
|
+
if (Array.isArray(firstArg)) {
|
|
27
|
+
const element = firstArg.join('-')
|
|
28
|
+
return `${cssPrefix}-${block}__${element}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const baseClass = `${cssPrefix}-${block}__${firstArg}`
|
|
32
|
+
|
|
33
|
+
// 如果有额外的参数,作为 modifier
|
|
34
|
+
const modifiers = validArgs
|
|
35
|
+
.slice(1)
|
|
36
|
+
.filter((arg): arg is string => typeof arg === 'string')
|
|
37
|
+
.map((modifier) => `${cssPrefix}-${block}__${firstArg}--${modifier}`)
|
|
38
|
+
|
|
39
|
+
return modifiers.length > 0 ? [...modifiers].join(' ') : baseClass
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 获取简单的前缀 class(不区分组件)
|
|
45
|
+
* @param classes class 名称
|
|
46
|
+
* @returns 带前缀的 class 名
|
|
47
|
+
* @example
|
|
48
|
+
* prefix('btn') // 'ua-btn'
|
|
49
|
+
* prefix('btn', 'primary') // 'ua-btn ua-btn--primary'
|
|
50
|
+
*/
|
|
51
|
+
// export function prefix(...classes: (string | false | null | undefined)[]) {
|
|
52
|
+
// const validClasses = classes.filter(Boolean) as string[]
|
|
53
|
+
// return validClasses.map((cls) => `${cssPrefix}-${cls}`).join(' ')
|
|
54
|
+
// }
|