@opentiny/tiny-robot 0.2.0-alpha.0 → 0.2.0-alpha.2
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/dist/action-group/ActionGroup.vue.d.ts +26 -0
- package/dist/action-group/ActionGroupItem.vue.d.ts +18 -0
- package/dist/action-group/index.d.ts +12 -0
- package/dist/action-group/index.type.d.ts +17 -0
- package/dist/bubble/index.d.ts +2 -2
- package/dist/bubble/index.type.d.ts +16 -18
- package/dist/feedback/components/SourceList.vue.d.ts +11 -0
- package/dist/feedback/components/index.d.ts +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.type.d.ts +25 -0
- package/dist/feedback/index.vue.d.ts +13 -0
- package/dist/history/components/index.d.ts +2 -0
- package/dist/history/components/item-tag.vue.d.ts +5 -0
- package/dist/history/components/search-empty.vue.d.ts +7 -0
- package/dist/history/composables/index.d.ts +1 -0
- package/dist/history/composables/useEditItemTitle.d.ts +12 -0
- package/dist/history/index.d.ts +6 -0
- package/dist/history/index.type.d.ts +43 -0
- package/dist/history/index.vue.d.ts +2 -0
- package/dist/icon-button/index.d.ts +7 -0
- package/dist/icon-button/index.type.d.ts +6 -0
- package/dist/icon-button/index.vue.d.ts +6 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +57 -27
- package/dist/node_modules/.pnpm/@opentiny_utils@3.22.0/node_modules/@opentiny/utils/dist/index.es.js +1335 -884
- package/dist/node_modules/.pnpm/@opentiny_vue-common@3.22.0/node_modules/@opentiny/vue-common/lib/index.js +660 -144
- package/dist/node_modules/.pnpm/@opentiny_vue-hooks@3.22.0/node_modules/@opentiny/vue-hooks/dist/src/vue-popper.js +85 -0
- package/dist/node_modules/.pnpm/@opentiny_vue-locale@3.22.0/node_modules/@opentiny/vue-locale/lib/index.js +1783 -0
- package/dist/node_modules/.pnpm/@opentiny_vue-renderless@3.22.0/node_modules/@opentiny/vue-renderless/tooltip/index.js +77 -0
- package/dist/node_modules/.pnpm/@opentiny_vue-renderless@3.22.0/node_modules/@opentiny/vue-renderless/tooltip/vue.js +90 -0
- package/dist/node_modules/.pnpm/@opentiny_vue-tooltip@3.22.0/node_modules/@opentiny/vue-tooltip/lib/index.js +176 -0
- package/dist/node_modules/.pnpm/@opentiny_vue-tooltip@3.22.0/node_modules/@opentiny/vue-tooltip/lib/pc.js +248 -0
- package/dist/node_modules/.pnpm/@vueuse_core@13.1.0_vue@3.5.13/node_modules/@vueuse/core/index.js +310 -0
- package/dist/node_modules/.pnpm/@vueuse_shared@13.1.0_vue@3.5.13/node_modules/@vueuse/shared/index.js +110 -0
- package/dist/packages/components/src/action-group/ActionGroup.vue.js +7 -0
- package/dist/packages/components/src/action-group/ActionGroup.vue2.js +121 -0
- package/dist/packages/components/src/action-group/ActionGroupItem.vue.js +14 -0
- package/dist/packages/components/src/action-group/ActionGroupItem.vue2.js +4 -0
- package/dist/packages/components/src/action-group/index.js +17 -0
- package/dist/packages/components/src/bubble/Bubble.vue.js +7 -0
- package/dist/packages/components/src/bubble/Bubble.vue2.js +76 -0
- package/dist/packages/components/src/bubble/BubbleList.vue.js +7 -0
- package/dist/packages/components/src/bubble/BubbleList.vue2.js +50 -0
- package/dist/packages/components/src/bubble/index.js +2 -2
- package/dist/packages/components/src/container/index.vue.js +2 -2
- package/dist/packages/components/src/container/index.vue2.js +36 -36
- package/dist/packages/components/src/feedback/components/SourceList.vue.js +7 -0
- package/dist/packages/components/src/feedback/components/SourceList.vue2.js +52 -0
- package/dist/packages/components/src/feedback/index.js +9 -0
- package/dist/packages/components/src/feedback/index.vue.js +7 -0
- package/dist/packages/components/src/feedback/index.vue2.js +143 -0
- package/dist/packages/components/src/history/components/item-tag.vue.js +7 -0
- package/dist/packages/components/src/history/components/item-tag.vue2.js +21 -0
- package/dist/packages/components/src/history/components/search-empty.vue.js +7 -0
- package/dist/packages/components/src/history/components/search-empty.vue2.js +20 -0
- package/dist/packages/components/src/history/composables/useEditItemTitle.js +43 -0
- package/dist/packages/components/src/history/index.js +11 -0
- package/dist/packages/components/src/history/index.vue.js +7 -0
- package/dist/packages/components/src/history/index.vue2.js +130 -0
- package/dist/packages/components/src/icon-button/index.js +9 -0
- package/dist/packages/components/src/icon-button/index.vue.js +7 -0
- package/dist/packages/components/src/icon-button/index.vue2.js +22 -0
- package/dist/packages/components/src/prompts/prompt.vue.js +2 -2
- package/dist/packages/components/src/prompts/prompt.vue2.js +17 -15
- package/dist/packages/components/src/question/components/HotQuestions.vue.js +23 -23
- package/dist/packages/components/src/question/index.vue.js +18 -18
- package/dist/packages/components/src/sender/components/TemplateEditor.vue.js +7 -0
- package/dist/packages/components/src/sender/components/TemplateEditor.vue2.js +175 -0
- package/dist/packages/components/src/sender/index.vue.js +149 -128
- package/dist/packages/components/src/suggestion/components/CategoryNav.vue.js +38 -0
- package/dist/packages/components/src/suggestion/components/CategoryNav.vue2.js +4 -0
- package/dist/packages/components/src/suggestion/components/SuggestionCapsule.vue.js +107 -0
- package/dist/packages/components/src/suggestion/components/SuggestionCapsule.vue2.js +4 -0
- package/dist/packages/components/src/suggestion/components/SuggestionPanel.vue.js +123 -0
- package/dist/packages/components/src/suggestion/components/SuggestionPanel.vue2.js +4 -0
- package/dist/packages/components/src/suggestion/composables/useKeyboardNavigation.js +45 -0
- package/dist/packages/components/src/suggestion/composables/useTriggerDetection.js +17 -0
- package/dist/packages/components/src/suggestion/index.js +9 -0
- package/dist/packages/components/src/suggestion/index.vue.js +179 -0
- package/dist/packages/components/src/suggestion/index.vue2.js +4 -0
- package/dist/packages/components/src/suggestion/utils/dom.js +18 -0
- package/dist/packages/svgs/dist/tiny-robot-svgs.js +306 -90
- package/dist/question/components/HotQuestions.vue.d.ts +2 -2
- package/dist/sender/components/TemplateEditor.vue.d.ts +18 -0
- package/dist/sender/index.type.d.ts +47 -0
- package/dist/sender/index.vue.d.ts +70 -5
- package/dist/style.css +1 -1
- package/dist/suggestion/components/CategoryNav.vue.d.ts +45 -0
- package/dist/suggestion/components/SuggestionCapsule.vue.d.ts +32 -0
- package/dist/suggestion/components/SuggestionPanel.vue.d.ts +84 -0
- package/dist/suggestion/composables/useKeyboardNavigation.d.ts +18 -0
- package/dist/suggestion/composables/useSuggestionFilter.d.ts +10 -0
- package/dist/suggestion/composables/useTriggerDetection.d.ts +11 -0
- package/dist/suggestion/index.d.ts +7 -0
- package/dist/suggestion/index.type.d.ts +94 -0
- package/dist/suggestion/index.vue.d.ts +343 -0
- package/dist/suggestion/utils/dom.d.ts +20 -0
- package/package.json +4 -3
- package/src/action-group/ActionGroup.vue +247 -0
- package/src/action-group/ActionGroupItem.vue +9 -0
- package/src/action-group/index.ts +25 -0
- package/src/action-group/index.type.ts +21 -0
- package/src/bubble/Bubble.vue +153 -0
- package/src/bubble/BubbleList.vue +55 -0
- package/src/bubble/index.ts +2 -2
- package/src/bubble/index.type.ts +7 -21
- package/src/container/index.vue +10 -36
- package/src/feedback/components/SourceList.vue +112 -0
- package/src/feedback/components/index.ts +1 -0
- package/src/feedback/index.ts +12 -0
- package/src/feedback/index.type.ts +27 -0
- package/src/feedback/index.vue +166 -0
- package/src/history/components/index.ts +2 -0
- package/src/history/components/item-tag.vue +49 -0
- package/src/history/components/search-empty.vue +38 -0
- package/src/history/composables/index.ts +1 -0
- package/src/history/composables/useEditItemTitle.ts +75 -0
- package/src/history/index.ts +12 -0
- package/src/history/index.type.ts +50 -0
- package/src/history/index.vue +292 -0
- package/src/icon-button/index.ts +12 -0
- package/src/icon-button/index.type.ts +7 -0
- package/src/icon-button/index.vue +58 -0
- package/src/index.ts +33 -1
- package/src/prompts/prompt.vue +7 -21
- package/src/question/components/HotQuestions.vue +1 -1
- package/src/question/index.less +9 -10
- package/src/sender/components/TemplateEditor.vue +465 -0
- package/src/sender/index.less +17 -7
- package/src/sender/index.type.ts +51 -0
- package/src/sender/index.vue +56 -8
- package/src/sender/vars.less +3 -3
- package/src/suggestion/components/CategoryNav.vue +38 -0
- package/src/suggestion/components/SuggestionCapsule.vue +183 -0
- package/src/suggestion/components/SuggestionPanel.vue +147 -0
- package/src/suggestion/composables/useKeyboardNavigation.ts +101 -0
- package/src/suggestion/composables/useSuggestionFilter.ts +34 -0
- package/src/suggestion/composables/useTriggerDetection.ts +46 -0
- package/src/suggestion/index.less +497 -0
- package/src/suggestion/index.ts +12 -0
- package/src/suggestion/index.type.ts +101 -0
- package/src/suggestion/index.vue +338 -0
- package/src/suggestion/utils/dom.ts +66 -0
- package/src/suggestion/vars.less +141 -0
- package/dist/bubble/components/actions/copy.vue.d.ts +0 -10
- package/dist/bubble/components/actions/index.d.ts +0 -2
- package/dist/bubble/components/actions/refresh.vue.d.ts +0 -2
- package/dist/bubble/useScroll.d.ts +0 -4
- package/dist/packages/components/src/bubble/bubble-list.vue.js +0 -7
- package/dist/packages/components/src/bubble/bubble-list.vue2.js +0 -37
- package/dist/packages/components/src/bubble/bubble.vue.js +0 -7
- package/dist/packages/components/src/bubble/bubble.vue2.js +0 -118
- package/dist/packages/components/src/bubble/components/actions/copy.vue.js +0 -7
- package/dist/packages/components/src/bubble/components/actions/copy.vue2.js +0 -35
- package/dist/packages/components/src/bubble/components/actions/refresh.vue.js +0 -7
- package/dist/packages/components/src/bubble/components/actions/refresh.vue2.js +0 -16
- package/dist/packages/components/src/bubble/useScroll.js +0 -13
- package/src/bubble/bubble-list.vue +0 -42
- package/src/bubble/bubble.vue +0 -247
- package/src/bubble/components/actions/copy.vue +0 -54
- package/src/bubble/components/actions/index.ts +0 -2
- package/src/bubble/components/actions/refresh.vue +0 -31
- package/src/bubble/useScroll.ts +0 -14
- /package/dist/bubble/{bubble-list.vue.d.ts → BubbleList.vue.d.ts} +0 -0
package/src/sender/index.vue
CHANGED
|
@@ -6,6 +6,7 @@ import { useInputHandler } from './composables/useInputHandler'
|
|
|
6
6
|
import { useKeyboardHandler } from './composables/useKeyboardHandler'
|
|
7
7
|
import { useSpeechHandler } from './composables/useSpeechHandler'
|
|
8
8
|
import ActionButtons from './components/ActionButtons.vue'
|
|
9
|
+
import TemplateEditor from './components/TemplateEditor.vue'
|
|
9
10
|
import './index.less'
|
|
10
11
|
|
|
11
12
|
const props = withDefaults(defineProps<SenderProps>(), {
|
|
@@ -23,15 +24,33 @@ const props = withDefaults(defineProps<SenderProps>(), {
|
|
|
23
24
|
showWordLimit: false,
|
|
24
25
|
submitType: 'enter',
|
|
25
26
|
theme: 'light',
|
|
27
|
+
template: '',
|
|
28
|
+
hasContent: undefined,
|
|
26
29
|
})
|
|
27
30
|
|
|
28
31
|
const emit = defineEmits<SenderEmits>()
|
|
29
32
|
|
|
30
33
|
// 输入引用
|
|
31
34
|
const inputRef = ref<HTMLElement | null>(null)
|
|
35
|
+
const templateEditorRef = ref<InstanceType<typeof TemplateEditor> | null>(null)
|
|
36
|
+
|
|
37
|
+
// 是否显示模板编辑器
|
|
38
|
+
const showTemplateEditor = computed(() => !!props.template)
|
|
32
39
|
|
|
33
40
|
// 输入控制
|
|
34
|
-
const { inputValue, isComposing, clearInput }: InputHandler = useInputHandler(props, emit)
|
|
41
|
+
const { inputValue, isComposing, clearInput: originalClearInput }: InputHandler = useInputHandler(props, emit)
|
|
42
|
+
|
|
43
|
+
// 清空功能增强:同时处理模板和普通输入,并退出模板编辑模式
|
|
44
|
+
const clearInput = () => {
|
|
45
|
+
// 调用原始清空方法
|
|
46
|
+
originalClearInput()
|
|
47
|
+
|
|
48
|
+
// 如果当前是模板编辑模式,需要退出模板编辑模式
|
|
49
|
+
if (props.template) {
|
|
50
|
+
// 发出一个模板重置事件,通知父组件清除模板
|
|
51
|
+
emit('reset-template')
|
|
52
|
+
}
|
|
53
|
+
}
|
|
35
54
|
|
|
36
55
|
// 输入建议
|
|
37
56
|
const showSuggestions = ref(false)
|
|
@@ -46,6 +65,19 @@ const selectSuggestion = (value: string) => {
|
|
|
46
65
|
emit('suggestion-select', value)
|
|
47
66
|
}
|
|
48
67
|
|
|
68
|
+
// 模板相关处理
|
|
69
|
+
const handleTemplateInput = (value: string) => {
|
|
70
|
+
inputValue.value = value
|
|
71
|
+
emit('update:modelValue', value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 激活第一个模板字段
|
|
75
|
+
const activateTemplateFirstField = () => {
|
|
76
|
+
if (templateEditorRef.value) {
|
|
77
|
+
templateEditorRef.value.activateFirstField()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
49
81
|
// 语音识别
|
|
50
82
|
const speechOptions = computed(() => ({
|
|
51
83
|
...(typeof props.speech === 'object' ? props.speech : {}),
|
|
@@ -93,8 +125,8 @@ const handleBlur = (event: FocusEvent) => {
|
|
|
93
125
|
emit('blur', event)
|
|
94
126
|
}
|
|
95
127
|
|
|
96
|
-
//
|
|
97
|
-
const
|
|
128
|
+
// 初始化自适应对象
|
|
129
|
+
const autoSize = computed(() => (props.mode === 'multiple' ? { minRows: 2, maxRows: 5 } : { maxRows: 1 }))
|
|
98
130
|
|
|
99
131
|
const justifyContent = computed(
|
|
100
132
|
(): {
|
|
@@ -115,6 +147,7 @@ const justifyContent = computed(
|
|
|
115
147
|
// 状态计算
|
|
116
148
|
const isDisabled = computed(() => props.disabled)
|
|
117
149
|
const isLoading = computed(() => props.loading)
|
|
150
|
+
const hasContent = computed(() => (props.hasContent !== undefined ? props.hasContent : !!inputValue.value))
|
|
118
151
|
|
|
119
152
|
// 样式类
|
|
120
153
|
const senderClasses = computed(() => ({
|
|
@@ -144,7 +177,9 @@ watch(inputValue, () => {
|
|
|
144
177
|
// 暴露方法
|
|
145
178
|
defineExpose({
|
|
146
179
|
focus: () => {
|
|
147
|
-
if (
|
|
180
|
+
if (showTemplateEditor.value && templateEditorRef.value) {
|
|
181
|
+
activateTemplateFirstField()
|
|
182
|
+
} else if (inputRef.value) {
|
|
148
183
|
inputRef.value.focus()
|
|
149
184
|
} else {
|
|
150
185
|
const input = document.querySelector('.tiny-input__inner') as HTMLInputElement
|
|
@@ -163,6 +198,7 @@ defineExpose({
|
|
|
163
198
|
submit: triggerSubmit,
|
|
164
199
|
startSpeech,
|
|
165
200
|
stopSpeech,
|
|
201
|
+
activateTemplateFirstField,
|
|
166
202
|
})
|
|
167
203
|
</script>
|
|
168
204
|
|
|
@@ -187,12 +223,24 @@ defineExpose({
|
|
|
187
223
|
|
|
188
224
|
<!-- 内容区域 - 确保最小宽度,不被挤占 -->
|
|
189
225
|
<div class="tiny-sender__content-area">
|
|
226
|
+
<!-- 模板编辑器 -->
|
|
227
|
+
<template v-if="showTemplateEditor">
|
|
228
|
+
<TemplateEditor
|
|
229
|
+
ref="templateEditorRef"
|
|
230
|
+
:template="template"
|
|
231
|
+
:value="inputValue"
|
|
232
|
+
@update:value="handleTemplateInput"
|
|
233
|
+
@input="handleTemplateInput"
|
|
234
|
+
/>
|
|
235
|
+
</template>
|
|
236
|
+
<!-- 普通输入框 -->
|
|
190
237
|
<tiny-input
|
|
238
|
+
v-else
|
|
191
239
|
ref="inputRef"
|
|
192
240
|
:autosize="autoSize"
|
|
193
|
-
|
|
241
|
+
type="textarea"
|
|
194
242
|
:readonly="isLoading"
|
|
195
|
-
|
|
243
|
+
resize="none"
|
|
196
244
|
v-model="inputValue"
|
|
197
245
|
:disabled="isDisabled"
|
|
198
246
|
:placeholder="placeholder"
|
|
@@ -217,7 +265,7 @@ defineExpose({
|
|
|
217
265
|
:loading="loading"
|
|
218
266
|
:disabled="isDisabled"
|
|
219
267
|
:show-clear="clearable"
|
|
220
|
-
:has-content="
|
|
268
|
+
:has-content="hasContent"
|
|
221
269
|
:speech-status="speechState"
|
|
222
270
|
:submit-type="submitType"
|
|
223
271
|
@clear="clearInput"
|
|
@@ -253,7 +301,7 @@ defineExpose({
|
|
|
253
301
|
:loading="loading"
|
|
254
302
|
:disabled="isDisabled"
|
|
255
303
|
:show-clear="clearable"
|
|
256
|
-
:has-content="
|
|
304
|
+
:has-content="hasContent"
|
|
257
305
|
:speech-status="speechState"
|
|
258
306
|
:submit-type="submitType"
|
|
259
307
|
@clear="clearInput"
|
package/src/sender/vars.less
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
--tr-sender-padding-bottom: 10px;
|
|
18
18
|
--tr-sender-padding-left: 24px;
|
|
19
19
|
--tr-sender-gap: 8px;
|
|
20
|
-
--tr-sender-input-height:
|
|
20
|
+
--tr-sender-input-height: 26px;
|
|
21
21
|
--tr-sender-input-min-height: 60px;
|
|
22
22
|
--tr-sender-icon-size: 22px;
|
|
23
23
|
--tr-sender-icon-size-small: 18px;
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
|
|
49
49
|
// 内容区域 (Content)
|
|
50
50
|
--tr-sender-content-min-width: 180px;
|
|
51
|
-
--tr-sender-content-padding: 15px 10px
|
|
51
|
+
--tr-sender-content-padding: 15px 10px 12px 24px;
|
|
52
52
|
--tr-sender-content-padding-with-prefix: 15px 10px 10px 8px;
|
|
53
53
|
--tr-sender-content-flex-grow: 1;
|
|
54
54
|
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
|
|
69
69
|
/* 暗色主题 */
|
|
70
70
|
.theme-dark,
|
|
71
|
-
[data-theme=
|
|
71
|
+
[data-theme='dark'] {
|
|
72
72
|
--tr-sender-border-color: #4c4d4f;
|
|
73
73
|
--tr-sender-bg-color: #1c1c1c;
|
|
74
74
|
--tr-sender-text-color: #ffffff;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { PropType } from 'vue'
|
|
3
|
+
import { Category } from '../index.type'
|
|
4
|
+
|
|
5
|
+
defineProps({
|
|
6
|
+
categories: {
|
|
7
|
+
type: Array as PropType<Category[]>,
|
|
8
|
+
required: true,
|
|
9
|
+
},
|
|
10
|
+
activeCategory: {
|
|
11
|
+
type: String,
|
|
12
|
+
default: '',
|
|
13
|
+
},
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits(['category-select'])
|
|
17
|
+
|
|
18
|
+
const handleCategorySelect = (categoryId: string) => {
|
|
19
|
+
emit('category-select', categoryId)
|
|
20
|
+
}
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<div class="tr-suggestion-categories">
|
|
25
|
+
<div
|
|
26
|
+
v-for="category in categories"
|
|
27
|
+
:key="category.id"
|
|
28
|
+
class="tr-suggestion-categories-item"
|
|
29
|
+
:class="{ active: activeCategory === category.id }"
|
|
30
|
+
@click="handleCategorySelect(category.id)"
|
|
31
|
+
>
|
|
32
|
+
<slot name="category-label" :category="category">
|
|
33
|
+
<div class="category-icon" v-if="category.icon">{{ category.icon }}</div>
|
|
34
|
+
<span>{{ category.label }}</span>
|
|
35
|
+
</slot>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, watch, onMounted, onBeforeUnmount, nextTick, PropType, computed, watchEffect } from 'vue'
|
|
3
|
+
import type { SuggestionItem } from '../index.type'
|
|
4
|
+
import { IconEdit } from '@opentiny/tiny-robot-svgs'
|
|
5
|
+
import { getCachedTextWidth } from '../utils/dom'
|
|
6
|
+
|
|
7
|
+
const props = defineProps({
|
|
8
|
+
suggestions: {
|
|
9
|
+
type: Array as PropType<SuggestionItem[]>,
|
|
10
|
+
required: true,
|
|
11
|
+
},
|
|
12
|
+
isExpanded: {
|
|
13
|
+
type: Boolean,
|
|
14
|
+
default: false,
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits(['suggestion-click', 'show-expand-button'])
|
|
19
|
+
|
|
20
|
+
// eslint-disable-next-line
|
|
21
|
+
const capsuleRef = ref<any>(null)
|
|
22
|
+
const showExpandButton = ref(false)
|
|
23
|
+
const isExpandedRef = ref(false)
|
|
24
|
+
const visibleSuggestions = ref<SuggestionItem[]>([])
|
|
25
|
+
const hiddenSuggestions = ref<SuggestionItem[]>([])
|
|
26
|
+
const maxItemsPerRow = ref(0)
|
|
27
|
+
const resizeObserver = ref<ResizeObserver | null>(null)
|
|
28
|
+
|
|
29
|
+
// 计算每行的胶囊排列,实现从下到上、从左往右的排序
|
|
30
|
+
const arrangedHiddenSuggestions = computed(() => {
|
|
31
|
+
if (maxItemsPerRow.value <= 0 || hiddenSuggestions.value.length === 0) return []
|
|
32
|
+
|
|
33
|
+
// 创建行数组
|
|
34
|
+
const rows = []
|
|
35
|
+
const totalSuggestions = [...hiddenSuggestions.value]
|
|
36
|
+
|
|
37
|
+
// 按照每行最大容量分组
|
|
38
|
+
while (totalSuggestions.length > 0) {
|
|
39
|
+
rows.push(totalSuggestions.splice(0, maxItemsPerRow.value))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 返回行的数组,以便从下到上显示
|
|
43
|
+
return rows.reverse()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// 检查是否需要显示展开/收起按钮并计算能够容纳的最大胶囊数
|
|
47
|
+
const calculateLayout = async () => {
|
|
48
|
+
await nextTick()
|
|
49
|
+
if (!capsuleRef.value || props.suggestions.length === 0) {
|
|
50
|
+
showExpandButton.value = false
|
|
51
|
+
emit('show-expand-button', false)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const container = capsuleRef.value
|
|
56
|
+
const containerWidth = container.clientWidth
|
|
57
|
+
|
|
58
|
+
// 重置胶囊分组
|
|
59
|
+
visibleSuggestions.value = []
|
|
60
|
+
hiddenSuggestions.value = []
|
|
61
|
+
|
|
62
|
+
// 计算一行可以容纳多少个胶囊
|
|
63
|
+
let accumulatedWidth = 0
|
|
64
|
+
const margin = 8 // 胶囊之间的间距
|
|
65
|
+
|
|
66
|
+
// 先测量一个平均宽度来估算每行可容纳的胶囊数
|
|
67
|
+
let totalWidth = 0
|
|
68
|
+
const sampleSize = Math.min(3, props.suggestions.length)
|
|
69
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
70
|
+
// 使用缓存测量宽度
|
|
71
|
+
const itemWidth = getCachedTextWidth(props.suggestions[i].text, 'tr-common-suggestions_item') + margin
|
|
72
|
+
totalWidth += itemWidth
|
|
73
|
+
}
|
|
74
|
+
const avgItemWidth = totalWidth / sampleSize
|
|
75
|
+
maxItemsPerRow.value = Math.max(1, Math.floor(containerWidth / avgItemWidth))
|
|
76
|
+
|
|
77
|
+
// 计算每个胶囊的宽度并确定一行能容纳多少个
|
|
78
|
+
for (let i = 0; i < props.suggestions.length; i++) {
|
|
79
|
+
const suggestion = props.suggestions[i]
|
|
80
|
+
// 使用缓存测量宽度
|
|
81
|
+
const itemWidth = getCachedTextWidth(suggestion.text, 'tr-common-suggestions_item') + margin
|
|
82
|
+
|
|
83
|
+
// 检查是否还能容纳下一个胶囊
|
|
84
|
+
if (accumulatedWidth + itemWidth <= containerWidth) {
|
|
85
|
+
accumulatedWidth += itemWidth
|
|
86
|
+
visibleSuggestions.value.push(suggestion)
|
|
87
|
+
} else {
|
|
88
|
+
hiddenSuggestions.value.push(suggestion)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 如果有隐藏的胶囊,显示展开按钮
|
|
93
|
+
showExpandButton.value = hiddenSuggestions.value.length > 0
|
|
94
|
+
|
|
95
|
+
// 通知父组件是否需要显示展开按钮
|
|
96
|
+
emit('show-expand-button', showExpandButton.value)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 处理指令点击
|
|
100
|
+
const handleSuggestionClick = (suggestion: SuggestionItem) => {
|
|
101
|
+
emit('suggestion-click', suggestion)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 同步外部展开状态
|
|
105
|
+
watch(
|
|
106
|
+
() => props.isExpanded,
|
|
107
|
+
(val) => {
|
|
108
|
+
isExpandedRef.value = val
|
|
109
|
+
},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
watchEffect(() => {
|
|
113
|
+
if (capsuleRef.value && props.suggestions.length) {
|
|
114
|
+
calculateLayout()
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// 组件挂载时使用ResizeObserver监听大小变化
|
|
119
|
+
onMounted(() => {
|
|
120
|
+
if (capsuleRef.value) {
|
|
121
|
+
resizeObserver.value = new ResizeObserver(() => {
|
|
122
|
+
calculateLayout()
|
|
123
|
+
})
|
|
124
|
+
resizeObserver.value.observe(capsuleRef.value)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
// 组件卸载时移除监听器
|
|
129
|
+
onBeforeUnmount(() => {
|
|
130
|
+
if (resizeObserver.value) {
|
|
131
|
+
resizeObserver.value.disconnect()
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
</script>
|
|
135
|
+
|
|
136
|
+
<template>
|
|
137
|
+
<!-- 胶囊式指令 -->
|
|
138
|
+
<div class="tr-common-suggestions" ref="capsuleRef" :class="{ expanded: isExpandedRef }">
|
|
139
|
+
<div class="tr-common-suggestions_content">
|
|
140
|
+
<!-- 向上展开的隐藏胶囊 -->
|
|
141
|
+
<div class="tr-common-suggestions_expanded-wrapper">
|
|
142
|
+
<div v-show="isExpandedRef && hiddenSuggestions.length > 0" class="tr-common-suggestions_expanded-area">
|
|
143
|
+
<div
|
|
144
|
+
v-for="(row, rowIndex) in arrangedHiddenSuggestions"
|
|
145
|
+
:key="`row-${rowIndex}`"
|
|
146
|
+
class="tr-common-suggestions_row"
|
|
147
|
+
>
|
|
148
|
+
<div
|
|
149
|
+
v-for="(suggestion, index) in row"
|
|
150
|
+
:key="`hidden-${suggestion.id}-${index}`"
|
|
151
|
+
class="tr-common-suggestions_item"
|
|
152
|
+
@click="handleSuggestionClick(suggestion)"
|
|
153
|
+
>
|
|
154
|
+
<div class="tr-common-suggestions_item_icon">
|
|
155
|
+
<IconEdit />
|
|
156
|
+
</div>
|
|
157
|
+
<div class="tr-common-suggestions_item_text">
|
|
158
|
+
{{ suggestion.text }}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<!-- 固定在底部的可见胶囊 -->
|
|
166
|
+
<div class="tr-common-suggestions_container">
|
|
167
|
+
<div
|
|
168
|
+
v-for="(suggestion, index) in visibleSuggestions"
|
|
169
|
+
:key="`visible-${suggestion.id}-${index}`"
|
|
170
|
+
class="tr-common-suggestions_item"
|
|
171
|
+
@click="handleSuggestionClick(suggestion)"
|
|
172
|
+
>
|
|
173
|
+
<div class="tr-common-suggestions_item_icon">
|
|
174
|
+
<IconEdit />
|
|
175
|
+
</div>
|
|
176
|
+
<div class="tr-common-suggestions_item_text">
|
|
177
|
+
{{ suggestion.text }}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
</div>
|
|
183
|
+
</template>
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, PropType } from 'vue'
|
|
3
|
+
import { SuggestionItem, Category } from '../index.type'
|
|
4
|
+
import CategoryNav from './CategoryNav.vue'
|
|
5
|
+
import { useKeyboardNavigation } from '../composables/useKeyboardNavigation'
|
|
6
|
+
import { IconHotQuestion, IconClose } from '@opentiny/tiny-robot-svgs'
|
|
7
|
+
|
|
8
|
+
const props = defineProps({
|
|
9
|
+
items: {
|
|
10
|
+
type: Array as PropType<SuggestionItem[]>,
|
|
11
|
+
required: true,
|
|
12
|
+
},
|
|
13
|
+
categories: {
|
|
14
|
+
type: Array as PropType<Category[]>,
|
|
15
|
+
default: () => [],
|
|
16
|
+
},
|
|
17
|
+
loading: {
|
|
18
|
+
type: Boolean,
|
|
19
|
+
default: false,
|
|
20
|
+
},
|
|
21
|
+
title: {
|
|
22
|
+
type: String,
|
|
23
|
+
default: '指令',
|
|
24
|
+
},
|
|
25
|
+
maxVisibleItems: {
|
|
26
|
+
type: Number,
|
|
27
|
+
default: 5,
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const emit = defineEmits(['close', 'select', 'category-select', 'item-hover'])
|
|
32
|
+
|
|
33
|
+
const items = ref(props.items)
|
|
34
|
+
|
|
35
|
+
const { handleKeyDown, activeIndex } = useKeyboardNavigation(items, {
|
|
36
|
+
initialIndex: 0,
|
|
37
|
+
onClose: () => emit('close'),
|
|
38
|
+
onSelect: (item) => emit('select', item),
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
// 当前激活的分类
|
|
42
|
+
const activeCategory = ref<string>(props.categories.length > 0 ? props.categories[0].id : '')
|
|
43
|
+
|
|
44
|
+
// 计算当前激活分类的项目列表
|
|
45
|
+
const filteredItems = computed(() => {
|
|
46
|
+
// 如果没有分类或没有选中分类,直接显示 items
|
|
47
|
+
if (props.categories.length === 0 || !activeCategory.value) {
|
|
48
|
+
return props.items
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 查找当前激活的分类
|
|
52
|
+
const category = props.categories.find((cat) => cat.id === activeCategory.value)
|
|
53
|
+
return category ? category.items : []
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// 处理分类选择
|
|
57
|
+
const handleCategorySelect = (categoryId: string) => {
|
|
58
|
+
activeCategory.value = categoryId
|
|
59
|
+
|
|
60
|
+
const category = props.categories.find((cat) => cat.id === categoryId)
|
|
61
|
+
if (category) {
|
|
62
|
+
emit('category-select', category)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 处理选择指令项
|
|
67
|
+
const handleSelect = (item: SuggestionItem) => {
|
|
68
|
+
emit('select', item)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 处理关闭面板
|
|
72
|
+
const handleClose = () => {
|
|
73
|
+
emit('close')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 处理鼠标悬停
|
|
77
|
+
const handleItemHover = (index: number) => {
|
|
78
|
+
emit('item-hover', index)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
defineExpose({
|
|
82
|
+
handleKeyDown,
|
|
83
|
+
})
|
|
84
|
+
</script>
|
|
85
|
+
|
|
86
|
+
<template>
|
|
87
|
+
<div class="tr-suggestion-panel">
|
|
88
|
+
<!-- 面板标题 -->
|
|
89
|
+
<div class="tr-suggestion-header">
|
|
90
|
+
<div class="tr-suggestion-header-left">
|
|
91
|
+
<div class="tr-suggestion-header-icon">
|
|
92
|
+
<slot name="title-icon">
|
|
93
|
+
<IconHotQuestion />
|
|
94
|
+
</slot>
|
|
95
|
+
</div>
|
|
96
|
+
<div class="tr-suggestion-header-title">{{ title }}</div>
|
|
97
|
+
</div>
|
|
98
|
+
<span class="tr-suggestion-close-btn" @click="handleClose">
|
|
99
|
+
<span class="close-icon"><IconClose /></span>
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- 分类导航 -->
|
|
104
|
+
<CategoryNav
|
|
105
|
+
v-if="categories.length > 0"
|
|
106
|
+
:categories="categories"
|
|
107
|
+
:active-category="activeCategory"
|
|
108
|
+
@category-select="handleCategorySelect"
|
|
109
|
+
/>
|
|
110
|
+
|
|
111
|
+
<!-- 内容区域 -->
|
|
112
|
+
<div
|
|
113
|
+
class="tr-suggestion-content"
|
|
114
|
+
:style="{ 'max-height': filteredItems.length > maxVisibleItems ? `${maxVisibleItems * 56}px` : 'auto' }"
|
|
115
|
+
>
|
|
116
|
+
<div v-if="loading" class="tr-suggestion-loading">
|
|
117
|
+
<slot name="loading-indicator">
|
|
118
|
+
<div class="tr-suggestion-loading-spinner"></div>
|
|
119
|
+
</slot>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<ul v-else-if="filteredItems.length > 0">
|
|
123
|
+
<li
|
|
124
|
+
v-for="(item, index) in filteredItems"
|
|
125
|
+
:key="item.id"
|
|
126
|
+
class="tr-suggestion-list-item"
|
|
127
|
+
:class="{ 'tr-suggestion-item-active': index === activeIndex }"
|
|
128
|
+
@click="handleSelect(item)"
|
|
129
|
+
@mouseenter="handleItemHover(index)"
|
|
130
|
+
>
|
|
131
|
+
<slot name="item" :item="item" :active="index === activeIndex">
|
|
132
|
+
<div class="item-content">
|
|
133
|
+
<div class="item-label">{{ item.text }}</div>
|
|
134
|
+
<div v-if="item.description" class="item-description">{{ item.description }}</div>
|
|
135
|
+
</div>
|
|
136
|
+
</slot>
|
|
137
|
+
</li>
|
|
138
|
+
</ul>
|
|
139
|
+
|
|
140
|
+
<div v-else class="tr-suggestion-empty">
|
|
141
|
+
<slot name="empty">
|
|
142
|
+
<p>无匹配结果</p>
|
|
143
|
+
</slot>
|
|
144
|
+
</div>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</template>
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { ref, Ref, watch } from 'vue'
|
|
2
|
+
import type { SuggestionItem } from '../index.type'
|
|
3
|
+
|
|
4
|
+
export interface KeyboardNavigationOptions {
|
|
5
|
+
onSelect?: (item: SuggestionItem) => void
|
|
6
|
+
onClose?: () => void
|
|
7
|
+
initialIndex?: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 处理指令列表的键盘导航
|
|
12
|
+
* @param items 当前可见的指令项列表
|
|
13
|
+
* @param options 配置选项
|
|
14
|
+
*/
|
|
15
|
+
export function useKeyboardNavigation(items: Ref<SuggestionItem[]>, options: KeyboardNavigationOptions = {}) {
|
|
16
|
+
const activeIndex = ref(options.initialIndex || -1)
|
|
17
|
+
|
|
18
|
+
// 当列表变化时重置激活项
|
|
19
|
+
watch(items, (newItems) => {
|
|
20
|
+
if (newItems.length > 0 && activeIndex.value === -1) {
|
|
21
|
+
activeIndex.value = 0
|
|
22
|
+
} else if (newItems.length === 0) {
|
|
23
|
+
activeIndex.value = -1
|
|
24
|
+
} else if (activeIndex.value >= newItems.length) {
|
|
25
|
+
activeIndex.value = newItems.length - 1
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 处理键盘按键
|
|
31
|
+
* @param e 键盘事件
|
|
32
|
+
*/
|
|
33
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
34
|
+
if (items.value.length === 0) return
|
|
35
|
+
|
|
36
|
+
switch (e.key) {
|
|
37
|
+
case 'ArrowUp':
|
|
38
|
+
e.preventDefault()
|
|
39
|
+
activeIndex.value = (activeIndex.value - 1 + items.value.length) % items.value.length
|
|
40
|
+
scrollToActive()
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
case 'ArrowDown':
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
activeIndex.value = (activeIndex.value + 1) % items.value.length
|
|
46
|
+
scrollToActive()
|
|
47
|
+
break
|
|
48
|
+
|
|
49
|
+
case 'Enter':
|
|
50
|
+
|
|
51
|
+
case 'Tab':
|
|
52
|
+
e.preventDefault()
|
|
53
|
+
if (activeIndex.value >= 0 && activeIndex.value < items.value.length) {
|
|
54
|
+
options.onSelect?.(items.value[activeIndex.value])
|
|
55
|
+
}
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
case 'Escape':
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
options.onClose?.()
|
|
61
|
+
break
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 滚动到当前激活项
|
|
67
|
+
*/
|
|
68
|
+
const scrollToActive = () => {
|
|
69
|
+
setTimeout(() => {
|
|
70
|
+
const activeElement = document.querySelector(`.tr-suggestion-list-item:nth-child(${activeIndex.value + 1})`)
|
|
71
|
+
const container = activeElement?.closest('.tr-suggestion-content')
|
|
72
|
+
|
|
73
|
+
if (activeElement && container) {
|
|
74
|
+
const containerRect = container.getBoundingClientRect()
|
|
75
|
+
const itemRect = activeElement.getBoundingClientRect()
|
|
76
|
+
|
|
77
|
+
if (itemRect.bottom > containerRect.bottom) {
|
|
78
|
+
container.scrollTop += itemRect.bottom - containerRect.bottom
|
|
79
|
+
} else if (itemRect.top < containerRect.top) {
|
|
80
|
+
container.scrollTop -= containerRect.top - itemRect.top
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}, 0)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 设置激活项索引
|
|
88
|
+
*/
|
|
89
|
+
const setActiveIndex = (index: number) => {
|
|
90
|
+
if (index >= -1 && index < items.value.length) {
|
|
91
|
+
activeIndex.value = index
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
activeIndex,
|
|
97
|
+
handleKeyDown,
|
|
98
|
+
scrollToActive,
|
|
99
|
+
setActiveIndex,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { computed, Ref } from 'vue'
|
|
2
|
+
import type { SuggestionItem } from '../index.type'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 过滤和搜索指令项列表
|
|
6
|
+
* @param items 原始指令项列表
|
|
7
|
+
* @param searchTerm 搜索关键词
|
|
8
|
+
*/
|
|
9
|
+
export function useSuggestionFilter(items: Ref<SuggestionItem[]>, searchTerm: Ref<string>) {
|
|
10
|
+
/**
|
|
11
|
+
* 根据搜索词过滤指令列表
|
|
12
|
+
*/
|
|
13
|
+
const filteredItems = computed(() => {
|
|
14
|
+
const term = searchTerm.value.trim().toLowerCase()
|
|
15
|
+
if (!term) return items.value
|
|
16
|
+
|
|
17
|
+
return items.value.filter((item) => {
|
|
18
|
+
// 搜索文本匹配
|
|
19
|
+
if (item.text.toLowerCase().includes(term)) return true
|
|
20
|
+
|
|
21
|
+
// 搜索描述匹配
|
|
22
|
+
if (item.description?.toLowerCase().includes(term)) return true
|
|
23
|
+
|
|
24
|
+
// 搜索关键词匹配
|
|
25
|
+
if (item.keywords?.some((keyword) => keyword.toLowerCase().includes(term))) return true
|
|
26
|
+
|
|
27
|
+
return false
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
filteredItems,
|
|
33
|
+
}
|
|
34
|
+
}
|