@huyooo/ai-chat-frontend-vue 0.1.6 → 0.1.8

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 (159) hide show
  1. package/README.md +367 -0
  2. package/dist/adapter.d.ts +7 -7
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/components/ChatPanel.vue.d.ts +120 -9
  5. package/dist/components/ChatPanel.vue.d.ts.map +1 -1
  6. package/dist/components/common/ConfirmDialog.vue.d.ts +30 -0
  7. package/dist/components/common/ConfirmDialog.vue.d.ts.map +1 -0
  8. package/dist/components/common/CopyButton.vue.d.ts +18 -0
  9. package/dist/components/common/CopyButton.vue.d.ts.map +1 -0
  10. package/dist/components/common/IndexingSettings.vue.d.ts +3 -0
  11. package/dist/components/common/IndexingSettings.vue.d.ts.map +1 -0
  12. package/dist/components/common/SettingsPanel.vue.d.ts +16 -0
  13. package/dist/components/common/SettingsPanel.vue.d.ts.map +1 -0
  14. package/dist/components/common/Toast.vue.d.ts +18 -0
  15. package/dist/components/common/Toast.vue.d.ts.map +1 -0
  16. package/dist/components/common/ToggleSwitch.vue.d.ts +10 -0
  17. package/dist/components/common/ToggleSwitch.vue.d.ts.map +1 -0
  18. package/dist/components/{chat/ui → header}/ChatHeader.vue.d.ts +5 -3
  19. package/dist/components/header/ChatHeader.vue.d.ts.map +1 -0
  20. package/dist/components/input/AtFilePicker.vue.d.ts +21 -0
  21. package/dist/components/input/AtFilePicker.vue.d.ts.map +1 -0
  22. package/dist/components/{ChatInput.vue.d.ts → input/ChatInput.vue.d.ts} +16 -14
  23. package/dist/components/input/ChatInput.vue.d.ts.map +1 -0
  24. package/dist/components/input/DropdownSelector.vue.d.ts +42 -0
  25. package/dist/components/input/DropdownSelector.vue.d.ts.map +1 -0
  26. package/dist/components/input/ImagePreviewModal.vue.d.ts +17 -0
  27. package/dist/components/input/ImagePreviewModal.vue.d.ts.map +1 -0
  28. package/dist/components/input/at-views/AtBranchView.vue.d.ts +18 -0
  29. package/dist/components/input/at-views/AtBranchView.vue.d.ts.map +1 -0
  30. package/dist/components/input/at-views/AtBrowserView.vue.d.ts +18 -0
  31. package/dist/components/input/at-views/AtBrowserView.vue.d.ts.map +1 -0
  32. package/dist/components/input/at-views/AtChatsView.vue.d.ts +18 -0
  33. package/dist/components/input/at-views/AtChatsView.vue.d.ts.map +1 -0
  34. package/dist/components/input/at-views/AtDocsView.vue.d.ts +18 -0
  35. package/dist/components/input/at-views/AtDocsView.vue.d.ts.map +1 -0
  36. package/dist/components/input/at-views/AtFilesView.vue.d.ts +23 -0
  37. package/dist/components/input/at-views/AtFilesView.vue.d.ts.map +1 -0
  38. package/dist/components/input/at-views/AtTerminalsView.vue.d.ts +18 -0
  39. package/dist/components/input/at-views/AtTerminalsView.vue.d.ts.map +1 -0
  40. package/dist/components/message/MessageBubble.vue.d.ts +45 -0
  41. package/dist/components/message/MessageBubble.vue.d.ts.map +1 -0
  42. package/dist/components/message/PartsRenderer.vue.d.ts +15 -0
  43. package/dist/components/message/PartsRenderer.vue.d.ts.map +1 -0
  44. package/dist/components/message/WelcomeMessage.vue.d.ts +14 -0
  45. package/dist/components/message/WelcomeMessage.vue.d.ts.map +1 -0
  46. package/dist/components/message/blocks/CodeBlock.vue.d.ts +11 -0
  47. package/dist/components/message/blocks/CodeBlock.vue.d.ts.map +1 -0
  48. package/dist/components/{chat/SearchResultBlock.vue.d.ts → message/blocks/TextBlock.vue.d.ts} +3 -4
  49. package/dist/components/message/blocks/TextBlock.vue.d.ts.map +1 -0
  50. package/dist/components/message/blocks/index.d.ts +6 -0
  51. package/dist/components/message/blocks/index.d.ts.map +1 -0
  52. package/dist/components/message/parts/CollapsibleCard.vue.d.ts +45 -0
  53. package/dist/components/message/parts/CollapsibleCard.vue.d.ts.map +1 -0
  54. package/dist/components/{chat/ToolCallBlock.vue.d.ts → message/parts/ErrorPart.vue.d.ts} +4 -5
  55. package/dist/components/message/parts/ErrorPart.vue.d.ts.map +1 -0
  56. package/dist/components/{chat/ThinkingBlock.vue.d.ts → message/parts/ImagePart.vue.d.ts} +3 -3
  57. package/dist/components/message/parts/ImagePart.vue.d.ts.map +1 -0
  58. package/dist/components/message/parts/SearchPart.vue.d.ts +12 -0
  59. package/dist/components/message/parts/SearchPart.vue.d.ts.map +1 -0
  60. package/dist/components/{chat/messages/ExecutionSteps.vue.d.ts → message/parts/TextPart.vue.d.ts} +2 -9
  61. package/dist/components/message/parts/TextPart.vue.d.ts.map +1 -0
  62. package/dist/components/message/parts/ThinkingPart.vue.d.ts +12 -0
  63. package/dist/components/message/parts/ThinkingPart.vue.d.ts.map +1 -0
  64. package/dist/components/message/parts/ToolCallPart.vue.d.ts +19 -0
  65. package/dist/components/message/parts/ToolCallPart.vue.d.ts.map +1 -0
  66. package/dist/components/message/parts/ToolResultPart.vue.d.ts +14 -0
  67. package/dist/components/message/parts/ToolResultPart.vue.d.ts.map +1 -0
  68. package/dist/components/message/parts/index.d.ts +12 -0
  69. package/dist/components/message/parts/index.d.ts.map +1 -0
  70. package/dist/components/message/tool-results/DefaultToolResult.vue.d.ts +4 -0
  71. package/dist/components/message/tool-results/DefaultToolResult.vue.d.ts.map +1 -0
  72. package/dist/components/message/tool-results/SearchResults.vue.d.ts +4 -0
  73. package/dist/components/message/tool-results/SearchResults.vue.d.ts.map +1 -0
  74. package/dist/components/message/tool-results/WeatherCard.vue.d.ts +4 -0
  75. package/dist/components/message/tool-results/WeatherCard.vue.d.ts.map +1 -0
  76. package/dist/components/message/tool-results/index.d.ts +7 -0
  77. package/dist/components/message/tool-results/index.d.ts.map +1 -0
  78. package/dist/components/message/welcome-types.d.ts +28 -0
  79. package/dist/components/message/welcome-types.d.ts.map +1 -0
  80. package/dist/composables/useChat.d.ts +99 -44
  81. package/dist/composables/useChat.d.ts.map +1 -1
  82. package/dist/composables/useImageUpload.d.ts +55 -0
  83. package/dist/composables/useImageUpload.d.ts.map +1 -0
  84. package/dist/index.d.ts +25 -26
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js +55871 -1252
  87. package/dist/style.css +1 -1
  88. package/dist/types/index.d.ts +113 -53
  89. package/dist/types/index.d.ts.map +1 -1
  90. package/dist/utils/fileIcon.d.ts +13 -0
  91. package/dist/utils/fileIcon.d.ts.map +1 -0
  92. package/package.json +12 -6
  93. package/src/adapter.ts +12 -70
  94. package/src/components/ChatPanel.vue +329 -110
  95. package/src/components/common/ConfirmDialog.vue +208 -0
  96. package/src/components/common/CopyButton.vue +71 -0
  97. package/src/components/common/IndexingSettings.vue +580 -0
  98. package/src/components/common/SettingsPanel.vue +293 -0
  99. package/src/components/common/Toast.vue +90 -0
  100. package/src/components/common/ToggleSwitch.vue +75 -0
  101. package/src/components/{chat/ui → header}/ChatHeader.vue +170 -93
  102. package/src/components/input/AtFilePicker.vue +657 -0
  103. package/src/components/input/ChatInput.vue +653 -0
  104. package/src/components/input/DropdownSelector.vue +322 -0
  105. package/src/components/input/ImagePreviewModal.vue +238 -0
  106. package/src/components/input/at-views/AtBranchView.vue +63 -0
  107. package/src/components/input/at-views/AtBrowserView.vue +63 -0
  108. package/src/components/input/at-views/AtChatsView.vue +63 -0
  109. package/src/components/input/at-views/AtDocsView.vue +63 -0
  110. package/src/components/input/at-views/AtFilesView.vue +255 -0
  111. package/src/components/input/at-views/AtTerminalsView.vue +63 -0
  112. package/src/components/message/ContentRenderer.vue +61 -0
  113. package/src/components/message/MessageBubble.vue +411 -0
  114. package/src/components/message/PartsRenderer.vue +101 -0
  115. package/src/components/message/ToolResultRenderer.vue +27 -0
  116. package/src/components/message/WelcomeMessage.vue +308 -0
  117. package/src/components/message/blocks/CodeBlock.vue +113 -0
  118. package/src/components/message/blocks/TextBlock.vue +21 -0
  119. package/src/components/message/blocks/index.ts +6 -0
  120. package/src/components/message/parts/CollapsibleCard.vue +135 -0
  121. package/src/components/message/parts/ErrorPart.vue +51 -0
  122. package/src/components/message/parts/ImagePart.vue +98 -0
  123. package/src/components/message/parts/SearchPart.vue +101 -0
  124. package/src/components/message/parts/TextPart.vue +28 -0
  125. package/src/components/message/parts/ThinkingPart.vue +54 -0
  126. package/src/components/message/parts/ToolCallPart.vue +460 -0
  127. package/src/components/message/parts/ToolResultPart.vue +78 -0
  128. package/src/components/message/parts/index.ts +13 -0
  129. package/src/components/message/tool-results/DefaultToolResult.vue +43 -0
  130. package/src/components/message/tool-results/SearchResults.vue +133 -0
  131. package/src/components/message/tool-results/WeatherCard.vue +139 -0
  132. package/src/components/message/tool-results/index.ts +7 -0
  133. package/src/components/message/welcome-types.ts +47 -0
  134. package/src/composables/useChat.ts +807 -155
  135. package/src/composables/useImageUpload.ts +228 -0
  136. package/src/index.ts +93 -46
  137. package/src/styles.css +47 -0
  138. package/src/types/index.ts +146 -98
  139. package/src/utils/fileIcon.ts +49 -0
  140. package/dist/components/ChatInput.vue.d.ts.map +0 -1
  141. package/dist/components/chat/SearchResultBlock.vue.d.ts.map +0 -1
  142. package/dist/components/chat/ThinkingBlock.vue.d.ts.map +0 -1
  143. package/dist/components/chat/ToolCallBlock.vue.d.ts.map +0 -1
  144. package/dist/components/chat/messages/ExecutionSteps.vue.d.ts.map +0 -1
  145. package/dist/components/chat/messages/MessageBubble.vue.d.ts +0 -28
  146. package/dist/components/chat/messages/MessageBubble.vue.d.ts.map +0 -1
  147. package/dist/components/chat/ui/ChatHeader.vue.d.ts.map +0 -1
  148. package/dist/components/chat/ui/WelcomeMessage.vue.d.ts +0 -7
  149. package/dist/components/chat/ui/WelcomeMessage.vue.d.ts.map +0 -1
  150. package/dist/preload/preload.d.ts +0 -6
  151. package/dist/preload/preload.d.ts.map +0 -1
  152. package/src/components/ChatInput.vue +0 -649
  153. package/src/components/chat/SearchResultBlock.vue +0 -155
  154. package/src/components/chat/ThinkingBlock.vue +0 -109
  155. package/src/components/chat/ToolCallBlock.vue +0 -213
  156. package/src/components/chat/messages/ExecutionSteps.vue +0 -281
  157. package/src/components/chat/messages/MessageBubble.vue +0 -272
  158. package/src/components/chat/ui/WelcomeMessage.vue +0 -135
  159. package/src/preload/preload.ts +0 -79
@@ -0,0 +1,657 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <div
4
+ v-if="visible"
5
+ class="at-picker-dropdown"
6
+ :style="dropdownStyle"
7
+ @click.stop
8
+ >
9
+ <!-- 头部:搜索框 -->
10
+ <div class="at-picker-header">
11
+ <button v-if="currentView !== 'categories'" class="at-picker-back" @click="goBackToCategories">
12
+ <Icon icon="lucide:arrow-left" width="14" />
13
+ </button>
14
+ <Icon v-else icon="lucide:search" width="14" class="at-picker-search-icon" />
15
+ <input
16
+ ref="searchRef"
17
+ v-model="query"
18
+ class="at-picker-search"
19
+ type="text"
20
+ :placeholder="currentView === 'categories' ? '添加文件、文件夹、文档...' : getSearchPlaceholder()"
21
+ spellcheck="false"
22
+ autocorrect="off"
23
+ autocomplete="off"
24
+ autocapitalize="off"
25
+ @keydown.down.prevent="move(1)"
26
+ @keydown.up.prevent="move(-1)"
27
+ @keydown.enter.prevent="confirmActive"
28
+ @keydown.esc.prevent="handleEsc"
29
+ />
30
+ </div>
31
+
32
+ <div ref="bodyRef" :class="['at-picker-body', 'chat-scrollbar', { 'is-scrolling': isScrolling }]" @scroll="handleScroll">
33
+ <!-- 分类视图 -->
34
+ <template v-if="currentView === 'categories'">
35
+ <!-- 最近文件 -->
36
+ <div v-if="filteredRecent.length > 0" class="at-picker-section at-picker-recent">
37
+ <div class="at-picker-list">
38
+ <button
39
+ v-for="(p, i) in filteredRecent"
40
+ :key="p"
41
+ :class="['at-picker-item', { active: activeKey === `recent:${i}` }]"
42
+ @mouseenter="setActive(`recent:${i}`)"
43
+ @click="selectPath(p)"
44
+ >
45
+ <Icon :icon="getFileIcon(p)" width="14" class="at-picker-item-icon" />
46
+ <span class="at-picker-item-name">{{ basename(p) }}</span>
47
+ <span class="at-picker-item-path">{{ dirname(p) }}</span>
48
+ </button>
49
+ </div>
50
+ </div>
51
+
52
+ <!-- 分类列表 -->
53
+ <div class="at-picker-section">
54
+ <div class="at-picker-list">
55
+ <button
56
+ v-for="(cat, i) in categories"
57
+ :key="cat.id"
58
+ :class="['at-picker-item at-picker-category', { active: activeKey === `cat:${i}` }]"
59
+ @mouseenter="setActive(`cat:${i}`)"
60
+ @click="handleCategoryClick(cat)"
61
+ >
62
+ <Icon :icon="cat.icon" width="14" class="at-picker-item-icon" />
63
+ <span class="at-picker-item-name">{{ cat.label }}</span>
64
+ <Icon icon="lucide:chevron-right" width="12" class="at-picker-chevron" />
65
+ </button>
66
+ </div>
67
+ </div>
68
+ </template>
69
+
70
+ <!-- 子视图组件 -->
71
+ <template v-else>
72
+ <component
73
+ :is="viewComponents[currentView]"
74
+ ref="viewRef"
75
+ :adapter="adapter"
76
+ :initial-dir="initialDir"
77
+ :query="query"
78
+ :active-index="viewActiveIndex"
79
+ @select="selectPath"
80
+ @set-active="setViewActiveIndex"
81
+ @update-count="handleViewCountUpdate"
82
+ />
83
+ </template>
84
+ </div>
85
+
86
+ <div class="at-picker-footer">
87
+ <span class="at-picker-hint">↑↓ 导航 · Enter 选择 · Esc {{ currentView === 'categories' ? '关闭' : '返回' }}</span>
88
+ </div>
89
+ </div>
90
+ </Teleport>
91
+ </template>
92
+
93
+ <script setup lang="ts">
94
+ import { computed, nextTick, onMounted, onUnmounted, ref, watch, type Component, type CSSProperties } from 'vue';
95
+ import { Icon } from '@iconify/vue';
96
+ import type { ChatAdapter } from '../../adapter';
97
+ import { getFileIcon, basename, dirname } from '../../utils/fileIcon';
98
+
99
+ // 子视图组件
100
+ import AtFilesView from './at-views/AtFilesView.vue';
101
+ import AtDocsView from './at-views/AtDocsView.vue';
102
+ import AtTerminalsView from './at-views/AtTerminalsView.vue';
103
+ import AtChatsView from './at-views/AtChatsView.vue';
104
+ import AtBranchView from './at-views/AtBranchView.vue';
105
+ import AtBrowserView from './at-views/AtBrowserView.vue';
106
+
107
+ const props = defineProps<{
108
+ visible: boolean;
109
+ adapter: ChatAdapter;
110
+ initialDir?: string;
111
+ /** 锚点元素(用于定位下拉面板) */
112
+ anchorEl?: HTMLElement | null;
113
+ }>();
114
+
115
+ const emit = defineEmits<{
116
+ close: [];
117
+ select: [path: string];
118
+ }>();
119
+
120
+ // ============ 常量 ============
121
+ const RECENT_KEY = 'ai-chat.at.recentPaths';
122
+ const MAX_RECENT = 6;
123
+
124
+ // 分类定义
125
+ type CategoryId = 'files' | 'docs' | 'terminals' | 'chats' | 'branch' | 'browser';
126
+
127
+ interface Category {
128
+ id: CategoryId;
129
+ label: string;
130
+ icon: string;
131
+ placeholder: string;
132
+ }
133
+
134
+ const categories: Category[] = [
135
+ { id: 'files', label: '文件和文件夹', icon: 'lucide:folder-open', placeholder: '搜索文件和文件夹...' },
136
+ { id: 'docs', label: '文档', icon: 'lucide:book-open', placeholder: '搜索文档...' },
137
+ { id: 'terminals', label: '终端', icon: 'lucide:terminal', placeholder: '搜索终端...' },
138
+ { id: 'chats', label: '历史对话', icon: 'lucide:message-square', placeholder: '搜索历史对话...' },
139
+ { id: 'branch', label: '分支差异', icon: 'lucide:git-branch', placeholder: '搜索分支差异...' },
140
+ { id: 'browser', label: '网页', icon: 'lucide:globe', placeholder: '搜索网页...' },
141
+ ];
142
+
143
+ // 视图组件映射
144
+ const viewComponents: Record<CategoryId, Component> = {
145
+ files: AtFilesView,
146
+ docs: AtDocsView,
147
+ terminals: AtTerminalsView,
148
+ chats: AtChatsView,
149
+ branch: AtBranchView,
150
+ browser: AtBrowserView,
151
+ };
152
+
153
+ // ============ 状态 ============
154
+ type ViewType = 'categories' | CategoryId;
155
+
156
+ const searchRef = ref<HTMLInputElement | null>(null);
157
+ const bodyRef = ref<HTMLDivElement | null>(null);
158
+ const viewRef = ref<{ getActivePath: () => string | null; confirmActive: () => void } | null>(null);
159
+ const query = ref('');
160
+ const currentView = ref<ViewType>('categories');
161
+ const activeKey = ref<string>('');
162
+ const recentList = ref<string[]>([]);
163
+
164
+ // 子视图的活动索引和条目数
165
+ const viewActiveIndex = ref(-1);
166
+ const viewItemCount = ref(0);
167
+
168
+ // 滚动状态标志(防止滚动时鼠标悬停干扰)
169
+ const isScrolling = ref(false);
170
+ let scrollTimer: ReturnType<typeof setTimeout> | null = null;
171
+
172
+ // ============ 下拉面板定位 ============
173
+ const DROPDOWN_WIDTH = 332;
174
+ const DROPDOWN_MIN_HEIGHT = 200; // 最小高度
175
+ const DROPDOWN_MAX_HEIGHT = 600; // 最大高度(大屏幕时充分利用)
176
+
177
+ const dropdownStyle = ref<CSSProperties>({});
178
+
179
+ function updateDropdownPosition() {
180
+ if (!props.anchorEl) {
181
+ dropdownStyle.value = {};
182
+ return;
183
+ }
184
+
185
+ const rect = props.anchorEl.getBoundingClientRect();
186
+ const viewportHeight = window.innerHeight;
187
+ const viewportWidth = window.innerWidth;
188
+
189
+ // 计算上方和下方的可用空间(预留 16px 边距)
190
+ const spaceAbove = rect.top - 16;
191
+ const spaceBelow = viewportHeight - rect.bottom - 16;
192
+
193
+ // 决定向上还是向下展开
194
+ const openUp = spaceAbove > spaceBelow;
195
+
196
+ // 计算可用高度
197
+ const availableHeight = openUp ? spaceAbove : spaceBelow;
198
+
199
+ // 计算最大高度:
200
+ // 1. 优先使用可用空间(确保不超出窗口)
201
+ // 2. 如果可用空间很大,限制在全局最大高度(大屏幕时充分利用但不浪费)
202
+ // 3. 同时不能超过视口高度的 80%
203
+ const viewportMaxHeight = viewportHeight * 0.8;
204
+ const maxHeight = Math.min(
205
+ availableHeight, // 使用可用空间(确保不超出窗口)
206
+ DROPDOWN_MAX_HEIGHT, // 但不超过全局最大高度(大屏幕时限制在 600px)
207
+ viewportMaxHeight // 也不能超过视口的 80%
208
+ );
209
+
210
+ // 计算水平位置,确保不超出右边界
211
+ let left = rect.right - DROPDOWN_WIDTH;
212
+ if (left < 8) left = 8;
213
+ if (left + DROPDOWN_WIDTH > viewportWidth - 8) {
214
+ left = viewportWidth - DROPDOWN_WIDTH - 8;
215
+ }
216
+
217
+ if (openUp) {
218
+ dropdownStyle.value = {
219
+ position: 'fixed',
220
+ left: `${left}px`,
221
+ bottom: `${viewportHeight - rect.top + 6}px`,
222
+ top: 'auto',
223
+ maxHeight: `${maxHeight}px`,
224
+ };
225
+ } else {
226
+ dropdownStyle.value = {
227
+ position: 'fixed',
228
+ left: `${left}px`,
229
+ top: `${rect.bottom + 6}px`,
230
+ bottom: 'auto',
231
+ maxHeight: `${maxHeight}px`,
232
+ };
233
+ }
234
+ }
235
+
236
+ // ============ 工具函数 ============
237
+ function loadRecent() {
238
+ try {
239
+ const raw = localStorage.getItem(RECENT_KEY);
240
+ if (!raw) {
241
+ recentList.value = [];
242
+ return;
243
+ }
244
+ const list = JSON.parse(raw) as unknown;
245
+ if (Array.isArray(list)) {
246
+ recentList.value = list.filter((x) => typeof x === 'string').slice(0, MAX_RECENT);
247
+ } else {
248
+ recentList.value = [];
249
+ }
250
+ } catch {
251
+ recentList.value = [];
252
+ }
253
+ }
254
+
255
+ function pushRecent(path: string) {
256
+ const next = [path, ...recentList.value.filter((p) => p !== path)].slice(0, MAX_RECENT);
257
+ recentList.value = next;
258
+ try {
259
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next));
260
+ } catch {
261
+ // ignore
262
+ }
263
+ }
264
+
265
+ function getSearchPlaceholder(): string {
266
+ if (currentView.value === 'categories') return 'Add files, folders, docs...';
267
+ const cat = categories.find((c) => c.id === currentView.value);
268
+ return cat?.placeholder || 'Search...';
269
+ }
270
+
271
+ // ============ 过滤 ============
272
+ const filteredRecent = computed(() => {
273
+ if (currentView.value !== 'categories') return [];
274
+ const q = query.value.trim().toLowerCase();
275
+ if (!q) return recentList.value;
276
+ return recentList.value.filter((p) => p.toLowerCase().includes(q));
277
+ });
278
+
279
+ // ============ 视图切换 ============
280
+ function goBackToCategories() {
281
+ currentView.value = 'categories';
282
+ query.value = '';
283
+ activeKey.value = '';
284
+ viewActiveIndex.value = -1;
285
+ nextTick(() => searchRef.value?.focus());
286
+ }
287
+
288
+ function handleCategoryClick(cat: Category) {
289
+ currentView.value = cat.id;
290
+ query.value = '';
291
+ activeKey.value = '';
292
+ viewActiveIndex.value = -1;
293
+ nextTick(() => searchRef.value?.focus());
294
+ }
295
+
296
+ // ============ 选择与关闭 ============
297
+ function handleEsc() {
298
+ if (currentView.value !== 'categories') {
299
+ goBackToCategories();
300
+ } else {
301
+ emit('close');
302
+ }
303
+ }
304
+
305
+ // 处理滚动事件
306
+ function handleScroll() {
307
+ // 标记正在滚动
308
+ isScrolling.value = true;
309
+
310
+ // 清除之前的定时器
311
+ if (scrollTimer) {
312
+ clearTimeout(scrollTimer);
313
+ }
314
+
315
+ // 滚动停止后 150ms 才允许鼠标事件(确保滚动完全停止)
316
+ scrollTimer = setTimeout(() => {
317
+ isScrolling.value = false;
318
+ scrollTimer = null;
319
+ }, 150);
320
+ }
321
+
322
+ function setActive(key: string) {
323
+ // 如果正在滚动,完全忽略鼠标悬停事件
324
+ // 因为滚动时鼠标位置不变,但元素在移动,会不断触发 mouseenter
325
+ if (isScrolling.value) {
326
+ return;
327
+ }
328
+ // 清除之前的激活状态,确保只有一个激活项
329
+ activeKey.value = key;
330
+ }
331
+
332
+ function setViewActiveIndex(index: number) {
333
+ // 如果正在滚动,完全忽略鼠标悬停事件
334
+ // 因为滚动时鼠标位置不变,但元素在移动,会不断触发 mouseenter
335
+ if (isScrolling.value) {
336
+ return;
337
+ }
338
+ // 直接设置,Vue 会自动更新
339
+ viewActiveIndex.value = index;
340
+ }
341
+
342
+ function handleViewCountUpdate(count: number) {
343
+ viewItemCount.value = count;
344
+ }
345
+
346
+ function selectPath(path: string) {
347
+ pushRecent(path);
348
+ emit('select', path);
349
+ }
350
+
351
+ // ============ 键盘导航 ============
352
+ // 滚动到选中的元素
353
+ function scrollToActive() {
354
+ nextTick(() => {
355
+ if (!bodyRef.value) return;
356
+
357
+ let activeElement: HTMLElement | null = null;
358
+
359
+ if (currentView.value === 'categories') {
360
+ // 分类视图:找到对应 activeKey 的按钮
361
+ if (activeKey.value.startsWith('recent:')) {
362
+ const idx = Number(activeKey.value.split(':')[1] || -1);
363
+ // 在最近文件列表中查找
364
+ const recentSection = bodyRef.value.querySelector('.at-picker-recent');
365
+ if (recentSection) {
366
+ const buttons = recentSection.querySelectorAll('.at-picker-item');
367
+ if (idx >= 0 && idx < buttons.length) {
368
+ activeElement = buttons[idx] as HTMLElement;
369
+ }
370
+ }
371
+ } else if (activeKey.value.startsWith('cat:')) {
372
+ const idx = Number(activeKey.value.split(':')[1] || -1);
373
+ // 在分类列表中查找
374
+ const categorySection = bodyRef.value.querySelector('.at-picker-section:not(.at-picker-recent)');
375
+ if (categorySection) {
376
+ const buttons = categorySection.querySelectorAll('.at-picker-item');
377
+ if (idx >= 0 && idx < buttons.length) {
378
+ activeElement = buttons[idx] as HTMLElement;
379
+ }
380
+ }
381
+ }
382
+ } else {
383
+ // 子视图:找到对应 activeIndex 的元素
384
+ const activeItems = bodyRef.value.querySelectorAll('.at-view-item.active');
385
+ if (activeItems.length > 0) {
386
+ activeElement = activeItems[0] as HTMLElement;
387
+ }
388
+ }
389
+
390
+ // 滚动到视窗内
391
+ if (activeElement) {
392
+ // 使用 requestAnimationFrame 确保 DOM 已更新
393
+ requestAnimationFrame(() => {
394
+ activeElement?.scrollIntoView({
395
+ behavior: 'smooth',
396
+ block: 'nearest',
397
+ inline: 'nearest',
398
+ });
399
+ // 滚动事件会通过 handleScroll 自动处理 isScrolling 状态
400
+ });
401
+ }
402
+ });
403
+ }
404
+
405
+ function move(delta: number) {
406
+ if (currentView.value === 'categories') {
407
+ // 分类视图的导航
408
+ const recentCount = filteredRecent.value.length;
409
+ const catCount = categories.length;
410
+ const total = recentCount + catCount;
411
+ if (total === 0) return;
412
+
413
+ let pos = -1;
414
+ if (activeKey.value.startsWith('recent:')) {
415
+ pos = Number(activeKey.value.split(':')[1] || -1);
416
+ } else if (activeKey.value.startsWith('cat:')) {
417
+ pos = recentCount + Number(activeKey.value.split(':')[1] || -1);
418
+ }
419
+
420
+ const next = pos < 0 ? 0 : (pos + delta + total) % total;
421
+ // 直接更新 activeKey,确保状态立即更新
422
+ if (next < recentCount) {
423
+ activeKey.value = `recent:${next}`;
424
+ } else {
425
+ activeKey.value = `cat:${next - recentCount}`;
426
+ }
427
+ // 立即滚动,滚动事件会自动处理 isScrolling 状态
428
+ scrollToActive();
429
+ } else {
430
+ // 子视图的导航
431
+ const total = viewItemCount.value;
432
+ if (total === 0) return;
433
+
434
+ const current = viewActiveIndex.value;
435
+ const next = current < 0 ? 0 : (current + delta + total) % total;
436
+ // 直接更新 viewActiveIndex,确保状态立即更新
437
+ viewActiveIndex.value = next;
438
+ // 立即滚动,滚动事件会自动处理 isScrolling 状态
439
+ scrollToActive();
440
+ }
441
+ }
442
+
443
+ function confirmActive() {
444
+ if (currentView.value === 'categories') {
445
+ if (activeKey.value.startsWith('recent:')) {
446
+ const idx = Number(activeKey.value.split(':')[1]);
447
+ const p = filteredRecent.value[idx];
448
+ if (p) selectPath(p);
449
+ } else if (activeKey.value.startsWith('cat:')) {
450
+ const idx = Number(activeKey.value.split(':')[1]);
451
+ const cat = categories[idx];
452
+ if (cat) handleCategoryClick(cat);
453
+ }
454
+ } else {
455
+ // 调用子视图的确认方法
456
+ viewRef.value?.confirmActive();
457
+ }
458
+ }
459
+
460
+ // ============ 生命周期 ============
461
+ watch(
462
+ () => props.visible,
463
+ async (v) => {
464
+ if (!v) return;
465
+ loadRecent();
466
+ query.value = '';
467
+ activeKey.value = '';
468
+ currentView.value = 'categories';
469
+ viewActiveIndex.value = -1;
470
+ updateDropdownPosition();
471
+ await nextTick();
472
+ searchRef.value?.focus();
473
+ }
474
+ );
475
+
476
+ watch(
477
+ () => props.anchorEl,
478
+ () => {
479
+ if (props.visible) {
480
+ updateDropdownPosition();
481
+ }
482
+ }
483
+ );
484
+
485
+ // 窗口大小变化时更新位置
486
+ function handleResize() {
487
+ if (props.visible) {
488
+ updateDropdownPosition();
489
+ }
490
+ }
491
+
492
+ onMounted(() => {
493
+ loadRecent();
494
+ window.addEventListener('resize', handleResize);
495
+ window.addEventListener('scroll', handleResize, true);
496
+ });
497
+
498
+ onUnmounted(() => {
499
+ window.removeEventListener('resize', handleResize);
500
+ window.removeEventListener('scroll', handleResize, true);
501
+ // 清理滚动定时器
502
+ if (scrollTimer) {
503
+ clearTimeout(scrollTimer);
504
+ scrollTimer = null;
505
+ }
506
+ });
507
+ </script>
508
+
509
+ <style scoped>
510
+ .at-picker-dropdown {
511
+ position: fixed;
512
+ width: 332px;
513
+ background: var(--chat-dropdown-bg, #252526);
514
+ border: 1px solid rgba(255, 255, 255, 0.1);
515
+ border-radius: 10px;
516
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
517
+ z-index: 99999;
518
+ display: flex;
519
+ flex-direction: column;
520
+ overflow: hidden;
521
+ }
522
+
523
+ .at-picker-header {
524
+ display: flex;
525
+ align-items: center;
526
+ gap: 8px;
527
+ padding: 10px 12px;
528
+ border-bottom: 1px solid rgba(255, 255, 255, 0.06);
529
+ }
530
+
531
+ .at-picker-back {
532
+ width: 24px;
533
+ height: 24px;
534
+ display: flex;
535
+ align-items: center;
536
+ justify-content: center;
537
+ background: transparent;
538
+ border: none;
539
+ border-radius: 4px;
540
+ color: #888;
541
+ cursor: pointer;
542
+ flex-shrink: 0;
543
+ }
544
+
545
+ .at-picker-back:hover {
546
+ background: rgba(255, 255, 255, 0.08);
547
+ color: #ccc;
548
+ }
549
+
550
+ .at-picker-search-icon {
551
+ color: #666;
552
+ flex-shrink: 0;
553
+ }
554
+
555
+ .at-picker-search {
556
+ flex: 1;
557
+ background: transparent;
558
+ border: none;
559
+ outline: none;
560
+ color: #ddd;
561
+ font-size: 13px;
562
+ }
563
+
564
+ .at-picker-body {
565
+ padding: 6px;
566
+ flex: 1;
567
+ min-height: 0;
568
+ overflow-y: auto;
569
+ }
570
+
571
+ /* 滚动期间禁用鼠标悬停事件 */
572
+ .at-picker-body.is-scrolling .at-picker-item,
573
+ .at-picker-body.is-scrolling .at-view-item {
574
+ pointer-events: none;
575
+ }
576
+
577
+ .at-picker-section {
578
+ margin-bottom: 2px;
579
+ }
580
+
581
+ .at-picker-recent {
582
+ padding-bottom: 6px;
583
+ margin-bottom: 6px;
584
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
585
+ }
586
+
587
+ .at-picker-list {
588
+ display: flex;
589
+ flex-direction: column;
590
+ gap: 1px;
591
+ }
592
+
593
+ .at-picker-item {
594
+ display: flex;
595
+ align-items: center;
596
+ gap: 8px;
597
+ text-align: left;
598
+ padding: 7px 10px;
599
+ border-radius: 6px;
600
+ border: 1px solid transparent;
601
+ background: transparent;
602
+ cursor: pointer;
603
+ color: #ccc;
604
+ width: 100%;
605
+ }
606
+
607
+ .at-picker-item:hover {
608
+ background: rgba(255, 255, 255, 0.06);
609
+ }
610
+
611
+ .at-picker-item.active {
612
+ background: rgba(59, 130, 246, 0.15);
613
+ border-color: rgba(59, 130, 246, 0.3);
614
+ }
615
+
616
+ .at-picker-item-icon {
617
+ color: #999;
618
+ flex-shrink: 0;
619
+ }
620
+
621
+ .at-picker-item-name {
622
+ font-size: 13px;
623
+ color: #ddd;
624
+ flex-shrink: 0;
625
+ max-width: 160px;
626
+ overflow: hidden;
627
+ text-overflow: ellipsis;
628
+ white-space: nowrap;
629
+ }
630
+
631
+ .at-picker-item-path {
632
+ font-size: 11px;
633
+ color: #555;
634
+ min-width: 0;
635
+ overflow: hidden;
636
+ text-overflow: ellipsis;
637
+ white-space: nowrap;
638
+ flex: 1;
639
+ }
640
+
641
+ .at-picker-category .at-picker-item-name {
642
+ flex: 1;
643
+ max-width: none;
644
+ }
645
+
646
+ .at-picker-chevron {
647
+ color: #555;
648
+ flex-shrink: 0;
649
+ }
650
+
651
+ .at-picker-footer {
652
+ padding: 8px 12px;
653
+ border-top: 1px solid rgba(255, 255, 255, 0.06);
654
+ color: #555;
655
+ font-size: 11px;
656
+ }
657
+ </style>