@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.
Files changed (32) hide show
  1. package/components/bubble-list/index.scss +254 -0
  2. package/components/bubble-list/index.vue +505 -0
  3. package/components/bubble-list/types.ts +110 -0
  4. package/components/bubble-list/use-typewriter.ts +193 -0
  5. package/components/bubble-wrapper/index.scss +34 -0
  6. package/components/bubble-wrapper/index.vue +97 -0
  7. package/components/history/index.scss +87 -0
  8. package/components/history/index.vue +118 -0
  9. package/components/history/types.ts +16 -0
  10. package/components/index.ts +4 -0
  11. package/components/prompts/index.scss +30 -0
  12. package/components/prompts/index.vue +46 -0
  13. package/components/prompts/types.ts +11 -0
  14. package/components/sender/image-picker.ts +100 -0
  15. package/components/sender/index.scss +288 -0
  16. package/components/sender/index.vue +374 -0
  17. package/components/sender/types.ts +56 -0
  18. package/composables/index.ts +6 -0
  19. package/composables/use-inner-audio/audio-manager.ts +22 -0
  20. package/composables/use-inner-audio/enums.ts +15 -0
  21. package/composables/use-inner-audio/types.ts +8 -0
  22. package/composables/use-inner-audio/use-inner-audio.ts +149 -0
  23. package/composables/use-prefix.ts +54 -0
  24. package/composables/use-recorder/h5-recorder-manager.ts +492 -0
  25. package/composables/use-recorder/native-recorder-manager.ts +181 -0
  26. package/composables/use-recorder/recorder-manager.ts +40 -0
  27. package/composables/use-recorder/types.ts +73 -0
  28. package/composables/use-recorder/use-recorder.ts +205 -0
  29. package/configs/index.ts +5 -0
  30. package/index.ts +3 -0
  31. package/package.json +90 -0
  32. 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,8 @@
1
+ /**
2
+ * 音频播放相关类型定义
3
+ */
4
+
5
+ /** 音频错误信息 */
6
+ export interface AudioError {
7
+ errMsg: string
8
+ }
@@ -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
+ // }