@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,193 @@
|
|
|
1
|
+
// eslint-disable-next-line no-restricted-imports
|
|
2
|
+
import { ref, type Ref, watch } from 'vue'
|
|
3
|
+
|
|
4
|
+
export interface TypewriterOptions {
|
|
5
|
+
/** 打字速度(毫秒/字符),默认 30ms */
|
|
6
|
+
speed?: number
|
|
7
|
+
/** 是否自动开始 */
|
|
8
|
+
autoStart?: boolean
|
|
9
|
+
/** 内容更新时的回调(用于自动滚动) */
|
|
10
|
+
onUpdate?: (currentIndex: number, totalLength: number) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface TypewriterResult {
|
|
14
|
+
/** 当前显示的文本 */
|
|
15
|
+
displayText: Ref<string>
|
|
16
|
+
/** 是否正在打字中 */
|
|
17
|
+
isTyping: Ref<boolean>
|
|
18
|
+
/** 是否已完成 */
|
|
19
|
+
isComplete: Ref<boolean>
|
|
20
|
+
/** 开始打字 */
|
|
21
|
+
start: () => void
|
|
22
|
+
/** 跳过动画,直接显示全部 */
|
|
23
|
+
skip: () => void
|
|
24
|
+
/** 重置状态 */
|
|
25
|
+
reset: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 打字机效果 composable
|
|
30
|
+
* @param fullText 完整文本内容
|
|
31
|
+
* @param enabled 是否启用打字机效果
|
|
32
|
+
* @param options 配置选项
|
|
33
|
+
*/
|
|
34
|
+
export const useTypewriter = (
|
|
35
|
+
fullText: Ref<string>,
|
|
36
|
+
enabled: Ref<boolean>,
|
|
37
|
+
options: TypewriterOptions = {},
|
|
38
|
+
) => {
|
|
39
|
+
const { speed = 30, autoStart = true, onUpdate } = options
|
|
40
|
+
|
|
41
|
+
const displayText = ref('')
|
|
42
|
+
const isTyping = ref(false)
|
|
43
|
+
const isComplete = ref(false)
|
|
44
|
+
let currentIndex = 0
|
|
45
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
46
|
+
|
|
47
|
+
const clearTimer = () => {
|
|
48
|
+
if (timer) {
|
|
49
|
+
clearTimeout(timer)
|
|
50
|
+
timer = null
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const typeNextChar = () => {
|
|
55
|
+
const text = fullText.value || ''
|
|
56
|
+
|
|
57
|
+
if (currentIndex < text.length) {
|
|
58
|
+
// 处理 Markdown 代码块,确保代码块标签完整显示
|
|
59
|
+
const nextChar = text[currentIndex]
|
|
60
|
+
displayText.value += nextChar
|
|
61
|
+
currentIndex++
|
|
62
|
+
|
|
63
|
+
// 检查是否在代码块标记内,如果是则加速显示整个标记
|
|
64
|
+
const isInCodeBlockMarker =
|
|
65
|
+
/```\w*$/.test(text.slice(0, currentIndex)) || /^\s*```/.test(text.slice(currentIndex - 3))
|
|
66
|
+
|
|
67
|
+
// 如果在代码块标记附近,加速显示
|
|
68
|
+
const actualSpeed = isInCodeBlockMarker ? 10 : speed
|
|
69
|
+
|
|
70
|
+
// 触发更新回调(用于自动滚动)
|
|
71
|
+
onUpdate?.(currentIndex, text.length)
|
|
72
|
+
|
|
73
|
+
timer = setTimeout(typeNextChar, actualSpeed)
|
|
74
|
+
} else {
|
|
75
|
+
isTyping.value = false
|
|
76
|
+
isComplete.value = true
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const start = () => {
|
|
81
|
+
if (!enabled.value) {
|
|
82
|
+
displayText.value = fullText.value || ''
|
|
83
|
+
isComplete.value = true
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
clearTimer()
|
|
88
|
+
displayText.value = ''
|
|
89
|
+
currentIndex = 0
|
|
90
|
+
isTyping.value = true
|
|
91
|
+
isComplete.value = false
|
|
92
|
+
typeNextChar()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const skip = () => {
|
|
96
|
+
clearTimer()
|
|
97
|
+
displayText.value = fullText.value || ''
|
|
98
|
+
isTyping.value = false
|
|
99
|
+
isComplete.value = true
|
|
100
|
+
currentIndex = displayText.value.length
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const reset = () => {
|
|
104
|
+
clearTimer()
|
|
105
|
+
displayText.value = ''
|
|
106
|
+
currentIndex = 0
|
|
107
|
+
isTyping.value = false
|
|
108
|
+
isComplete.value = false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 监听 fullText 变化,重新开始打字
|
|
112
|
+
watch(
|
|
113
|
+
() => fullText.value,
|
|
114
|
+
(newText, oldText) => {
|
|
115
|
+
if (!enabled.value) {
|
|
116
|
+
displayText.value = newText || ''
|
|
117
|
+
isComplete.value = true
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 如果是追加内容(流式响应),继续打字
|
|
122
|
+
if (isTyping.value && newText && oldText && newText.startsWith(oldText)) {
|
|
123
|
+
// 继续打字,不需要重置
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 否则重新开始
|
|
128
|
+
reset()
|
|
129
|
+
if (autoStart && newText) {
|
|
130
|
+
start()
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
{ immediate: true },
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
// 监听 enabled 变化
|
|
137
|
+
watch(
|
|
138
|
+
() => enabled.value,
|
|
139
|
+
(enabled) => {
|
|
140
|
+
if (!enabled) {
|
|
141
|
+
skip()
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
displayText,
|
|
148
|
+
isTyping,
|
|
149
|
+
isComplete,
|
|
150
|
+
start,
|
|
151
|
+
skip,
|
|
152
|
+
reset,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 管理多个消息的打字机状态
|
|
158
|
+
*/
|
|
159
|
+
export const useTypewriterManager = () => {
|
|
160
|
+
const typingStates = new Map<string, TypewriterResult>()
|
|
161
|
+
|
|
162
|
+
const getOrCreate = (
|
|
163
|
+
messageId: string,
|
|
164
|
+
fullText: Ref<string>,
|
|
165
|
+
enabled: Ref<boolean>,
|
|
166
|
+
options?: TypewriterOptions,
|
|
167
|
+
): TypewriterResult => {
|
|
168
|
+
if (!typingStates.has(messageId)) {
|
|
169
|
+
const state = useTypewriter(fullText, enabled, options)
|
|
170
|
+
typingStates.set(messageId, state)
|
|
171
|
+
}
|
|
172
|
+
return typingStates.get(messageId)!
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const remove = (messageId: string) => {
|
|
176
|
+
const state = typingStates.get(messageId)
|
|
177
|
+
if (state) {
|
|
178
|
+
state.reset()
|
|
179
|
+
typingStates.delete(messageId)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const clear = () => {
|
|
184
|
+
typingStates.forEach((state) => state.reset())
|
|
185
|
+
typingStates.clear()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
getOrCreate,
|
|
190
|
+
remove,
|
|
191
|
+
clear,
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
@use '../../styles/base' as base;
|
|
2
|
+
|
|
3
|
+
.#{base.$prefix}-bubble {
|
|
4
|
+
&__wrapper {
|
|
5
|
+
padding: 32rpx;
|
|
6
|
+
border-radius: 24rpx;
|
|
7
|
+
background-color: #ffffff;
|
|
8
|
+
color: rgba(0, 0, 0, 0.85);
|
|
9
|
+
border-top-left-radius: 0;
|
|
10
|
+
box-shadow: 1px 4rpx 24rpx 0 rgba(0, 0, 0, 0.05);
|
|
11
|
+
|
|
12
|
+
&--dark {
|
|
13
|
+
background-color: #f6f7f8;
|
|
14
|
+
color: rgba(0, 0, 0, 0.85);
|
|
15
|
+
border-top-left-radius: 24rpx;
|
|
16
|
+
border-top-right-radius: 0;
|
|
17
|
+
padding: 32rpx;
|
|
18
|
+
box-shadow: none;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
&-tool {
|
|
22
|
+
display: flex;
|
|
23
|
+
gap: 24rpx;
|
|
24
|
+
border-top: 1px solid #f6f7f8;
|
|
25
|
+
margin-top: 32rpx;
|
|
26
|
+
padding-top: 24rpx;
|
|
27
|
+
|
|
28
|
+
&-icon {
|
|
29
|
+
width: 48rpx;
|
|
30
|
+
height: 48rpx;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<view :class="[p('wrapper'), { [p('wrapper', 'dark')]: !showTool }]">
|
|
3
|
+
<view style="white-space: pre-line">
|
|
4
|
+
<slot></slot>
|
|
5
|
+
</view>
|
|
6
|
+
<view v-if="showTool" :class="p(['wrapper', 'tool'])">
|
|
7
|
+
<image
|
|
8
|
+
:src="`${staticPrefix}/icon-msg-${audioIcon}.png`"
|
|
9
|
+
:class="p(['wrapper', 'tool', 'icon'])"
|
|
10
|
+
@click="onPlay"
|
|
11
|
+
/>
|
|
12
|
+
<image
|
|
13
|
+
:src="`${staticPrefix}/icon-msg-copy.png`"
|
|
14
|
+
:class="p(['wrapper', 'tool', 'icon'])"
|
|
15
|
+
@click="onCopy"
|
|
16
|
+
/>
|
|
17
|
+
</view>
|
|
18
|
+
</view>
|
|
19
|
+
</template>
|
|
20
|
+
|
|
21
|
+
<script setup lang="ts">
|
|
22
|
+
import { staticPrefix } from '../../configs'
|
|
23
|
+
import { EAudioPlayState, useInnerAudio, usePrefix } from '../../composables'
|
|
24
|
+
import type { BubbleMessage } from '../bubble-list/types'
|
|
25
|
+
|
|
26
|
+
defineOptions({
|
|
27
|
+
options: {
|
|
28
|
+
styleIsolation: 'shared',
|
|
29
|
+
virtualHost: true,
|
|
30
|
+
},
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const props = defineProps<{
|
|
34
|
+
showTool?: boolean
|
|
35
|
+
message: BubbleMessage
|
|
36
|
+
}>()
|
|
37
|
+
|
|
38
|
+
const emit = defineEmits<{
|
|
39
|
+
/** 音频播放事件 */
|
|
40
|
+
(e: 'audio-play'): void
|
|
41
|
+
/** 音频暂停事件 */
|
|
42
|
+
(e: 'audio-pause'): void
|
|
43
|
+
/** 音频停止事件 */
|
|
44
|
+
(e: 'audio-stop'): void
|
|
45
|
+
/** 文本转语音事件 */
|
|
46
|
+
(e: 'text2audio', text: string): Promise<string> | void
|
|
47
|
+
}>()
|
|
48
|
+
|
|
49
|
+
const p = usePrefix('bubble')
|
|
50
|
+
|
|
51
|
+
const { state, isPlaying, playingId, load, play, pause } = useInnerAudio()
|
|
52
|
+
|
|
53
|
+
const audioIcon = computed(() => {
|
|
54
|
+
return isPlaying.value && playingId.value === props.message.id ? 'voice-pause' : 'voice'
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
async function onPlay() {
|
|
58
|
+
const { audioUrl, id, content } = props.message
|
|
59
|
+
if (!audioUrl) {
|
|
60
|
+
if (content) {
|
|
61
|
+
emit('text2audio', content)
|
|
62
|
+
}
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
if (id !== playingId.value) {
|
|
66
|
+
load(id, audioUrl, true)
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
switch (state.value) {
|
|
70
|
+
case EAudioPlayState.Playing:
|
|
71
|
+
pause()
|
|
72
|
+
break
|
|
73
|
+
case EAudioPlayState.Paused:
|
|
74
|
+
case EAudioPlayState.Ended:
|
|
75
|
+
play()
|
|
76
|
+
break
|
|
77
|
+
default:
|
|
78
|
+
load(id, audioUrl, true)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function onCopy() {
|
|
83
|
+
uni.setClipboardData({
|
|
84
|
+
data: props.message.content || '',
|
|
85
|
+
success() {
|
|
86
|
+
uni.showToast({
|
|
87
|
+
title: '复制成功',
|
|
88
|
+
icon: 'success',
|
|
89
|
+
})
|
|
90
|
+
},
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
</script>
|
|
94
|
+
|
|
95
|
+
<style lang="scss">
|
|
96
|
+
@import './index.scss';
|
|
97
|
+
</style>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
@use '../../styles/base' as base;
|
|
2
|
+
|
|
3
|
+
.#{base.$prefix}-history {
|
|
4
|
+
display: flex;
|
|
5
|
+
flex-direction: column;
|
|
6
|
+
|
|
7
|
+
&__head {
|
|
8
|
+
font-weight: 600;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
&__title {
|
|
12
|
+
display: flex;
|
|
13
|
+
justify-content: space-between;
|
|
14
|
+
padding: 40rpx;
|
|
15
|
+
|
|
16
|
+
&-text {
|
|
17
|
+
height: 40rpx;
|
|
18
|
+
font-size: 28rpx;
|
|
19
|
+
color: rgba(0, 0, 0, 0.85);
|
|
20
|
+
font-weight: 600;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
&__edit {
|
|
25
|
+
height: 40rpx;
|
|
26
|
+
font-size: 20rpx;
|
|
27
|
+
color: rgba(0, 0, 0, 0.45);
|
|
28
|
+
font-weight: 400;
|
|
29
|
+
display: flex;
|
|
30
|
+
align-items: center;
|
|
31
|
+
gap: 24rpx;
|
|
32
|
+
|
|
33
|
+
&-switch {
|
|
34
|
+
width: 32rpx;
|
|
35
|
+
height: 32rpx;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
&__body {
|
|
40
|
+
flex: 1;
|
|
41
|
+
overflow: auto;
|
|
42
|
+
&-inner {
|
|
43
|
+
display: flex;
|
|
44
|
+
flex-direction: column;
|
|
45
|
+
// gap: 24rpx;
|
|
46
|
+
padding: 40rpx;
|
|
47
|
+
padding-top: 0;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
&__group {
|
|
52
|
+
& + & {
|
|
53
|
+
margin-top: 40rpx;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
&-title {
|
|
57
|
+
line-height: 28rpx;
|
|
58
|
+
font-size: 20rpx;
|
|
59
|
+
color: rgba(0, 0, 0, 0.65);
|
|
60
|
+
font-weight: 400;
|
|
61
|
+
padding-left: 8rpx;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
&__item {
|
|
66
|
+
display: flex;
|
|
67
|
+
gap: 24rpx;
|
|
68
|
+
align-items: center;
|
|
69
|
+
margin-top: 24rpx;
|
|
70
|
+
|
|
71
|
+
&-title {
|
|
72
|
+
flex: 1;
|
|
73
|
+
overflow: hidden;
|
|
74
|
+
white-space: nowrap;
|
|
75
|
+
text-overflow: ellipsis;
|
|
76
|
+
line-height: 40rpx;
|
|
77
|
+
font-size: 28rpx;
|
|
78
|
+
color: #000000;
|
|
79
|
+
font-weight: 400;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
&-clear {
|
|
83
|
+
width: 32rpx;
|
|
84
|
+
height: 32rpx;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<view :class="p()">
|
|
3
|
+
<view :class="p('head')">
|
|
4
|
+
<slot name="title-before"></slot>
|
|
5
|
+
<view :class="p('title')">
|
|
6
|
+
<view :class="p(['title', 'text'])">对话记录</view>
|
|
7
|
+
<view v-if="isEditing" :class="p('edit')">
|
|
8
|
+
<view :class="p(['edit', 'clear'])" @click="$emit('deleteAll')">清空</view>
|
|
9
|
+
<view>|</view>
|
|
10
|
+
<view @click="endEdit">完成</view>
|
|
11
|
+
</view>
|
|
12
|
+
<image
|
|
13
|
+
v-else
|
|
14
|
+
:class="p(['edit', 'switch'])"
|
|
15
|
+
:src="`${staticPrefix}/icon-del.png`"
|
|
16
|
+
@click="isEditing = true"
|
|
17
|
+
/>
|
|
18
|
+
</view>
|
|
19
|
+
</view>
|
|
20
|
+
<scroll-view :class="p('body')" scroll-y @scrolltolower="$emit('reachBottom')">
|
|
21
|
+
<view :class="p('body-inner')">
|
|
22
|
+
<!-- 分组 -->
|
|
23
|
+
<view v-for="group in groups" :key="group.title" :class="p('group')">
|
|
24
|
+
<view :class="p(['group', 'title'])">{{ group.title }}</view>
|
|
25
|
+
<!-- 会话列表 -->
|
|
26
|
+
<view
|
|
27
|
+
v-for="session in group.sessions"
|
|
28
|
+
:key="session.id"
|
|
29
|
+
:class="p('item')"
|
|
30
|
+
@click="$emit('itemClick', session)"
|
|
31
|
+
>
|
|
32
|
+
<view :class="p(['item', 'title'])">{{ session.label }}</view>
|
|
33
|
+
<image
|
|
34
|
+
v-if="isEditing"
|
|
35
|
+
:src="`${staticPrefix}/icon-del.png`"
|
|
36
|
+
:class="p(['item', 'clear'])"
|
|
37
|
+
@click.stop="$emit('delete', session)"
|
|
38
|
+
/>
|
|
39
|
+
</view>
|
|
40
|
+
</view>
|
|
41
|
+
<view
|
|
42
|
+
style="
|
|
43
|
+
text-align: center;
|
|
44
|
+
margin-top: 40rpx;
|
|
45
|
+
font-size: 24rpx;
|
|
46
|
+
color: rgba(0, 0, 0, 0.45);
|
|
47
|
+
"
|
|
48
|
+
>{{ hasMore ? '加载中…' : '没有更多了~' }}</view
|
|
49
|
+
>
|
|
50
|
+
</view>
|
|
51
|
+
</scroll-view>
|
|
52
|
+
</view>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<script setup lang="ts">
|
|
56
|
+
// 会话历史
|
|
57
|
+
// eslint-disable-next-line no-restricted-imports
|
|
58
|
+
import { computed, ref } from 'vue'
|
|
59
|
+
import { staticPrefix } from '../../configs'
|
|
60
|
+
import { usePrefix } from '../../composables'
|
|
61
|
+
import type { HistoryProps, Session } from './types'
|
|
62
|
+
|
|
63
|
+
defineOptions({
|
|
64
|
+
options: {
|
|
65
|
+
styleIsolation: 'shared',
|
|
66
|
+
virtualHost: true,
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const props = withDefaults(defineProps<HistoryProps>(), {
|
|
71
|
+
sessions: () => [],
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
defineEmits<{
|
|
75
|
+
itemClick: [session: Session]
|
|
76
|
+
delete: [session: Session]
|
|
77
|
+
deleteAll: []
|
|
78
|
+
reachBottom: []
|
|
79
|
+
}>()
|
|
80
|
+
|
|
81
|
+
const p = usePrefix('history')
|
|
82
|
+
|
|
83
|
+
const isEditing = ref(false)
|
|
84
|
+
|
|
85
|
+
const groups = computed<
|
|
86
|
+
{
|
|
87
|
+
title: string
|
|
88
|
+
sessions: Session[]
|
|
89
|
+
}[]
|
|
90
|
+
>(() => {
|
|
91
|
+
const groupMap = new Map<string, Session[]>()
|
|
92
|
+
|
|
93
|
+
for (const session of props.sessions) {
|
|
94
|
+
const date = session.date
|
|
95
|
+
if (!groupMap.has(date)) {
|
|
96
|
+
groupMap.set(date, [])
|
|
97
|
+
}
|
|
98
|
+
groupMap.get(date)!.push(session)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return Array.from(groupMap.entries()).map(([title, sessions]) => ({
|
|
102
|
+
title,
|
|
103
|
+
sessions,
|
|
104
|
+
}))
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
function endEdit() {
|
|
108
|
+
isEditing.value = false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
defineExpose({
|
|
112
|
+
endEdit,
|
|
113
|
+
})
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<style lang="scss">
|
|
117
|
+
@import './index.scss';
|
|
118
|
+
</style>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export interface Session {
|
|
2
|
+
id: string
|
|
3
|
+
/** 日常 */
|
|
4
|
+
date: string
|
|
5
|
+
/** 显示文本(首条会话内容?) */
|
|
6
|
+
label: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface HistoryProps {
|
|
10
|
+
/** 数据加载中 */
|
|
11
|
+
loading?: boolean
|
|
12
|
+
/** 是否有更多数据 */
|
|
13
|
+
hasMore?: boolean
|
|
14
|
+
/** 会话列表 */
|
|
15
|
+
sessions?: Session[]
|
|
16
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
@use '../../styles/base' as base;
|
|
2
|
+
|
|
3
|
+
.#{base.$prefix}-prompts {
|
|
4
|
+
display: flex;
|
|
5
|
+
gap: 12rpx;
|
|
6
|
+
|
|
7
|
+
&__item {
|
|
8
|
+
background-color: #fff;
|
|
9
|
+
padding: 15rpx 20rpx;
|
|
10
|
+
line-height: 34rpx;
|
|
11
|
+
box-shadow: 0 2px 15px 0 rgba(0, 0, 0, 0.05);
|
|
12
|
+
border-radius: 32rpx;
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
gap: 12rpx;
|
|
16
|
+
|
|
17
|
+
&--disabled {
|
|
18
|
+
opacity: 0.6;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
&:active {
|
|
22
|
+
background-color: #f0f0f0;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
&__item-icon {
|
|
27
|
+
width: 32rpx;
|
|
28
|
+
height: 32rpx;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<view :class="p()">
|
|
3
|
+
<view
|
|
4
|
+
v-for="(item, index) in items"
|
|
5
|
+
:key="item.id || index"
|
|
6
|
+
:class="[p('item'), { [p('item', 'disabled')]: item.disabled }]"
|
|
7
|
+
@click="onClick(item)"
|
|
8
|
+
>
|
|
9
|
+
<image v-if="item.icon" :src="item.icon" :class="p(['item', 'icon'])" mode="aspectFit" />
|
|
10
|
+
<text>{{ item.label }}</text>
|
|
11
|
+
</view>
|
|
12
|
+
</view>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup lang="ts">
|
|
16
|
+
// 提示集
|
|
17
|
+
import { usePrefix } from '../../composables'
|
|
18
|
+
import type { PromptItem, PromptsProps } from './types'
|
|
19
|
+
|
|
20
|
+
defineOptions({
|
|
21
|
+
options: {
|
|
22
|
+
styleIsolation: 'shared',
|
|
23
|
+
virtualHost: true,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
withDefaults(defineProps<PromptsProps>(), {
|
|
28
|
+
items: () => [],
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits<{
|
|
32
|
+
itemClick: [item: PromptItem]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const p = usePrefix('prompts')
|
|
36
|
+
|
|
37
|
+
function onClick(item: PromptItem) {
|
|
38
|
+
if (!item.disabled) {
|
|
39
|
+
emit('itemClick', item)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<style lang="scss">
|
|
45
|
+
@import './index.scss';
|
|
46
|
+
</style>
|