@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.
Files changed (164) hide show
  1. package/dist/action-group/ActionGroup.vue.d.ts +26 -0
  2. package/dist/action-group/ActionGroupItem.vue.d.ts +18 -0
  3. package/dist/action-group/index.d.ts +12 -0
  4. package/dist/action-group/index.type.d.ts +17 -0
  5. package/dist/bubble/index.d.ts +2 -2
  6. package/dist/bubble/index.type.d.ts +16 -18
  7. package/dist/feedback/components/SourceList.vue.d.ts +11 -0
  8. package/dist/feedback/components/index.d.ts +1 -0
  9. package/dist/feedback/index.d.ts +7 -0
  10. package/dist/feedback/index.type.d.ts +25 -0
  11. package/dist/feedback/index.vue.d.ts +13 -0
  12. package/dist/history/components/index.d.ts +2 -0
  13. package/dist/history/components/item-tag.vue.d.ts +5 -0
  14. package/dist/history/components/search-empty.vue.d.ts +7 -0
  15. package/dist/history/composables/index.d.ts +1 -0
  16. package/dist/history/composables/useEditItemTitle.d.ts +12 -0
  17. package/dist/history/index.d.ts +6 -0
  18. package/dist/history/index.type.d.ts +43 -0
  19. package/dist/history/index.vue.d.ts +2 -0
  20. package/dist/icon-button/index.d.ts +7 -0
  21. package/dist/icon-button/index.type.d.ts +6 -0
  22. package/dist/icon-button/index.vue.d.ts +6 -0
  23. package/dist/index.d.ts +10 -1
  24. package/dist/index.js +57 -27
  25. package/dist/node_modules/.pnpm/@opentiny_utils@3.22.0/node_modules/@opentiny/utils/dist/index.es.js +1335 -884
  26. package/dist/node_modules/.pnpm/@opentiny_vue-common@3.22.0/node_modules/@opentiny/vue-common/lib/index.js +660 -144
  27. package/dist/node_modules/.pnpm/@opentiny_vue-hooks@3.22.0/node_modules/@opentiny/vue-hooks/dist/src/vue-popper.js +85 -0
  28. package/dist/node_modules/.pnpm/@opentiny_vue-locale@3.22.0/node_modules/@opentiny/vue-locale/lib/index.js +1783 -0
  29. package/dist/node_modules/.pnpm/@opentiny_vue-renderless@3.22.0/node_modules/@opentiny/vue-renderless/tooltip/index.js +77 -0
  30. package/dist/node_modules/.pnpm/@opentiny_vue-renderless@3.22.0/node_modules/@opentiny/vue-renderless/tooltip/vue.js +90 -0
  31. package/dist/node_modules/.pnpm/@opentiny_vue-tooltip@3.22.0/node_modules/@opentiny/vue-tooltip/lib/index.js +176 -0
  32. package/dist/node_modules/.pnpm/@opentiny_vue-tooltip@3.22.0/node_modules/@opentiny/vue-tooltip/lib/pc.js +248 -0
  33. package/dist/node_modules/.pnpm/@vueuse_core@13.1.0_vue@3.5.13/node_modules/@vueuse/core/index.js +310 -0
  34. package/dist/node_modules/.pnpm/@vueuse_shared@13.1.0_vue@3.5.13/node_modules/@vueuse/shared/index.js +110 -0
  35. package/dist/packages/components/src/action-group/ActionGroup.vue.js +7 -0
  36. package/dist/packages/components/src/action-group/ActionGroup.vue2.js +121 -0
  37. package/dist/packages/components/src/action-group/ActionGroupItem.vue.js +14 -0
  38. package/dist/packages/components/src/action-group/ActionGroupItem.vue2.js +4 -0
  39. package/dist/packages/components/src/action-group/index.js +17 -0
  40. package/dist/packages/components/src/bubble/Bubble.vue.js +7 -0
  41. package/dist/packages/components/src/bubble/Bubble.vue2.js +76 -0
  42. package/dist/packages/components/src/bubble/BubbleList.vue.js +7 -0
  43. package/dist/packages/components/src/bubble/BubbleList.vue2.js +50 -0
  44. package/dist/packages/components/src/bubble/index.js +2 -2
  45. package/dist/packages/components/src/container/index.vue.js +2 -2
  46. package/dist/packages/components/src/container/index.vue2.js +36 -36
  47. package/dist/packages/components/src/feedback/components/SourceList.vue.js +7 -0
  48. package/dist/packages/components/src/feedback/components/SourceList.vue2.js +52 -0
  49. package/dist/packages/components/src/feedback/index.js +9 -0
  50. package/dist/packages/components/src/feedback/index.vue.js +7 -0
  51. package/dist/packages/components/src/feedback/index.vue2.js +143 -0
  52. package/dist/packages/components/src/history/components/item-tag.vue.js +7 -0
  53. package/dist/packages/components/src/history/components/item-tag.vue2.js +21 -0
  54. package/dist/packages/components/src/history/components/search-empty.vue.js +7 -0
  55. package/dist/packages/components/src/history/components/search-empty.vue2.js +20 -0
  56. package/dist/packages/components/src/history/composables/useEditItemTitle.js +43 -0
  57. package/dist/packages/components/src/history/index.js +11 -0
  58. package/dist/packages/components/src/history/index.vue.js +7 -0
  59. package/dist/packages/components/src/history/index.vue2.js +130 -0
  60. package/dist/packages/components/src/icon-button/index.js +9 -0
  61. package/dist/packages/components/src/icon-button/index.vue.js +7 -0
  62. package/dist/packages/components/src/icon-button/index.vue2.js +22 -0
  63. package/dist/packages/components/src/prompts/prompt.vue.js +2 -2
  64. package/dist/packages/components/src/prompts/prompt.vue2.js +17 -15
  65. package/dist/packages/components/src/question/components/HotQuestions.vue.js +23 -23
  66. package/dist/packages/components/src/question/index.vue.js +18 -18
  67. package/dist/packages/components/src/sender/components/TemplateEditor.vue.js +7 -0
  68. package/dist/packages/components/src/sender/components/TemplateEditor.vue2.js +175 -0
  69. package/dist/packages/components/src/sender/index.vue.js +149 -128
  70. package/dist/packages/components/src/suggestion/components/CategoryNav.vue.js +38 -0
  71. package/dist/packages/components/src/suggestion/components/CategoryNav.vue2.js +4 -0
  72. package/dist/packages/components/src/suggestion/components/SuggestionCapsule.vue.js +107 -0
  73. package/dist/packages/components/src/suggestion/components/SuggestionCapsule.vue2.js +4 -0
  74. package/dist/packages/components/src/suggestion/components/SuggestionPanel.vue.js +123 -0
  75. package/dist/packages/components/src/suggestion/components/SuggestionPanel.vue2.js +4 -0
  76. package/dist/packages/components/src/suggestion/composables/useKeyboardNavigation.js +45 -0
  77. package/dist/packages/components/src/suggestion/composables/useTriggerDetection.js +17 -0
  78. package/dist/packages/components/src/suggestion/index.js +9 -0
  79. package/dist/packages/components/src/suggestion/index.vue.js +179 -0
  80. package/dist/packages/components/src/suggestion/index.vue2.js +4 -0
  81. package/dist/packages/components/src/suggestion/utils/dom.js +18 -0
  82. package/dist/packages/svgs/dist/tiny-robot-svgs.js +306 -90
  83. package/dist/question/components/HotQuestions.vue.d.ts +2 -2
  84. package/dist/sender/components/TemplateEditor.vue.d.ts +18 -0
  85. package/dist/sender/index.type.d.ts +47 -0
  86. package/dist/sender/index.vue.d.ts +70 -5
  87. package/dist/style.css +1 -1
  88. package/dist/suggestion/components/CategoryNav.vue.d.ts +45 -0
  89. package/dist/suggestion/components/SuggestionCapsule.vue.d.ts +32 -0
  90. package/dist/suggestion/components/SuggestionPanel.vue.d.ts +84 -0
  91. package/dist/suggestion/composables/useKeyboardNavigation.d.ts +18 -0
  92. package/dist/suggestion/composables/useSuggestionFilter.d.ts +10 -0
  93. package/dist/suggestion/composables/useTriggerDetection.d.ts +11 -0
  94. package/dist/suggestion/index.d.ts +7 -0
  95. package/dist/suggestion/index.type.d.ts +94 -0
  96. package/dist/suggestion/index.vue.d.ts +343 -0
  97. package/dist/suggestion/utils/dom.d.ts +20 -0
  98. package/package.json +4 -3
  99. package/src/action-group/ActionGroup.vue +247 -0
  100. package/src/action-group/ActionGroupItem.vue +9 -0
  101. package/src/action-group/index.ts +25 -0
  102. package/src/action-group/index.type.ts +21 -0
  103. package/src/bubble/Bubble.vue +153 -0
  104. package/src/bubble/BubbleList.vue +55 -0
  105. package/src/bubble/index.ts +2 -2
  106. package/src/bubble/index.type.ts +7 -21
  107. package/src/container/index.vue +10 -36
  108. package/src/feedback/components/SourceList.vue +112 -0
  109. package/src/feedback/components/index.ts +1 -0
  110. package/src/feedback/index.ts +12 -0
  111. package/src/feedback/index.type.ts +27 -0
  112. package/src/feedback/index.vue +166 -0
  113. package/src/history/components/index.ts +2 -0
  114. package/src/history/components/item-tag.vue +49 -0
  115. package/src/history/components/search-empty.vue +38 -0
  116. package/src/history/composables/index.ts +1 -0
  117. package/src/history/composables/useEditItemTitle.ts +75 -0
  118. package/src/history/index.ts +12 -0
  119. package/src/history/index.type.ts +50 -0
  120. package/src/history/index.vue +292 -0
  121. package/src/icon-button/index.ts +12 -0
  122. package/src/icon-button/index.type.ts +7 -0
  123. package/src/icon-button/index.vue +58 -0
  124. package/src/index.ts +33 -1
  125. package/src/prompts/prompt.vue +7 -21
  126. package/src/question/components/HotQuestions.vue +1 -1
  127. package/src/question/index.less +9 -10
  128. package/src/sender/components/TemplateEditor.vue +465 -0
  129. package/src/sender/index.less +17 -7
  130. package/src/sender/index.type.ts +51 -0
  131. package/src/sender/index.vue +56 -8
  132. package/src/sender/vars.less +3 -3
  133. package/src/suggestion/components/CategoryNav.vue +38 -0
  134. package/src/suggestion/components/SuggestionCapsule.vue +183 -0
  135. package/src/suggestion/components/SuggestionPanel.vue +147 -0
  136. package/src/suggestion/composables/useKeyboardNavigation.ts +101 -0
  137. package/src/suggestion/composables/useSuggestionFilter.ts +34 -0
  138. package/src/suggestion/composables/useTriggerDetection.ts +46 -0
  139. package/src/suggestion/index.less +497 -0
  140. package/src/suggestion/index.ts +12 -0
  141. package/src/suggestion/index.type.ts +101 -0
  142. package/src/suggestion/index.vue +338 -0
  143. package/src/suggestion/utils/dom.ts +66 -0
  144. package/src/suggestion/vars.less +141 -0
  145. package/dist/bubble/components/actions/copy.vue.d.ts +0 -10
  146. package/dist/bubble/components/actions/index.d.ts +0 -2
  147. package/dist/bubble/components/actions/refresh.vue.d.ts +0 -2
  148. package/dist/bubble/useScroll.d.ts +0 -4
  149. package/dist/packages/components/src/bubble/bubble-list.vue.js +0 -7
  150. package/dist/packages/components/src/bubble/bubble-list.vue2.js +0 -37
  151. package/dist/packages/components/src/bubble/bubble.vue.js +0 -7
  152. package/dist/packages/components/src/bubble/bubble.vue2.js +0 -118
  153. package/dist/packages/components/src/bubble/components/actions/copy.vue.js +0 -7
  154. package/dist/packages/components/src/bubble/components/actions/copy.vue2.js +0 -35
  155. package/dist/packages/components/src/bubble/components/actions/refresh.vue.js +0 -7
  156. package/dist/packages/components/src/bubble/components/actions/refresh.vue2.js +0 -16
  157. package/dist/packages/components/src/bubble/useScroll.js +0 -13
  158. package/src/bubble/bubble-list.vue +0 -42
  159. package/src/bubble/bubble.vue +0 -247
  160. package/src/bubble/components/actions/copy.vue +0 -54
  161. package/src/bubble/components/actions/index.ts +0 -2
  162. package/src/bubble/components/actions/refresh.vue +0 -31
  163. package/src/bubble/useScroll.ts +0 -14
  164. /package/dist/bubble/{bubble-list.vue.d.ts → BubbleList.vue.d.ts} +0 -0
@@ -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 inputType = computed(() => (props.mode === 'multiple' ? 'textarea' : 'text'))
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 (inputRef.value) {
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
- :type="inputType"
241
+ type="textarea"
194
242
  :readonly="isLoading"
195
- :resize="mode === 'multiple' ? 'none' : undefined"
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="!!inputValue"
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="!!inputValue"
304
+ :has-content="hasContent"
257
305
  :speech-status="speechState"
258
306
  :submit-type="submitType"
259
307
  @clear="clearInput"
@@ -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: 100px;
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 10px 24px;
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="dark"] {
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
+ }