2121 >
2222 <div class =" bg-gray-50 p-6 rounded-xl transition-shadow duration-300 hover:shadow-md" >
2323 <div class =" grid grid-cols-2 gap-4 mb-4" >
24- <UInput
25- v-model =" state.imageName"
26- placeholder =" Image Name (e.g., nginx)"
27- class =" w-full"
28- @input =" handleImageChange"
29- />
30- <UInput
31- v-model =" state.tag"
32- placeholder =" Tag (e.g., latest)"
33- class =" w-full focus:border-gray-400 border-gray-200 !ring-0"
34- @input =" handleImageChange"
35- />
24+ <div class =" relative" >
25+ <UInput
26+ v-model =" state.imageName"
27+ placeholder =" 镜像名称(支持搜索)"
28+ class =" w-full"
29+ @input =" handleImageInput"
30+ @blur =" handleSearchBlur"
31+ @focus =" handleSearchFocus"
32+ />
33+ <div
34+ v-if =" state.searchLoading || state.searchError || state.searchResults.length || state.searchLastQuery"
35+ class =" absolute z-10 mt-1 w-full max-h-60 overflow-auto rounded-lg bg-white shadow-lg ring-1 ring-gray-200"
36+ >
37+ <div
38+ v-if =" state.searchLoading"
39+ class =" px-3 py-2 text-sm text-gray-500"
40+ >
41+ 搜索中...
42+ </div >
43+ <div
44+ v-else-if =" state.searchError"
45+ class =" px-3 py-2 text-sm text-red-500"
46+ >
47+ {{ state.searchError }}
48+ </div >
49+ <template v-else >
50+ <button
51+ v-for =" item in state.searchResults"
52+ :key =" item.fullName"
53+ class =" w-full text-left px-3 py-2 text-sm hover:bg-gray-50"
54+ @click =" selectSearchResult(item)"
55+ type =" button"
56+ >
57+ <div class =" text-sm text-gray-900 font-medium" >
58+ {{ item.fullName }}
59+ <span v-if =" item.is_official" class =" text-xs text-green-600" >官方</span >
60+ </div >
61+ <div v-if =" item.description" class =" text-xs text-gray-500 truncate" >
62+ {{ item.description }}
63+ </div >
64+ </button >
65+ <div
66+ v-if =" state.searchLastQuery && state.searchResults.length === 0"
67+ class =" px-3 py-2 text-sm text-gray-500"
68+ >
69+ 未找到匹配的镜像
70+ </div >
71+ </template >
72+ </div >
73+ </div >
74+ <div class =" relative" >
75+ <UInput
76+ v-model =" state.tag"
77+ placeholder =" Tag(支持搜索)"
78+ class =" w-full focus:border-gray-400 border-gray-200 !ring-0"
79+ @input =" handleTagInput"
80+ @blur =" handleTagBlur"
81+ @focus =" handleTagFocus"
82+ />
83+ <div
84+ v-if =" state.tagSearchLoading || state.tagSearchError || state.tagSearchResults.length || state.tagSearchLastQuery"
85+ class =" absolute z-10 mt-1 w-full max-h-60 overflow-auto rounded-lg bg-white shadow-lg ring-1 ring-gray-200"
86+ >
87+ <div
88+ v-if =" state.tagSearchLoading"
89+ class =" px-3 py-2 text-sm text-gray-500"
90+ >
91+ 搜索中...
92+ </div >
93+ <div
94+ v-else-if =" state.tagSearchError"
95+ class =" px-3 py-2 text-sm text-red-500"
96+ >
97+ {{ state.tagSearchError }}
98+ </div >
99+ <template v-else >
100+ <button
101+ v-for =" item in state.tagSearchResults"
102+ :key =" item.name"
103+ class =" w-full text-left px-3 py-2 text-sm hover:bg-gray-50"
104+ @click =" selectTagResult(item)"
105+ type =" button"
106+ >
107+ <div class =" text-sm text-gray-900 font-medium" >
108+ {{ item.name }}
109+ </div >
110+ </button >
111+ <div
112+ v-if =" state.tagSearchLastQuery && state.tagSearchResults.length === 0"
113+ class =" px-3 py-2 text-sm text-gray-500"
114+ >
115+ 未找到匹配的标签
116+ </div >
117+ </template >
118+ </div >
119+ </div >
36120 </div >
37121
38122 <UButton
135219</template >
136220
137221<script setup lang="ts">
138- import { reactive , computed } from " vue" ;
222+ import { reactive , computed , ref , onBeforeUnmount } from " vue" ;
223+ import debounce from " lodash/debounce" ;
139224import type {
140225 TokenResponse ,
141226 ManifestResponse ,
142227 ManifestDetailResponse ,
143228 DockerPlatform ,
144229 DownloadProgress ,
230+ DockerSearchResponse ,
231+ DockerSearchResult ,
232+ DockerTagResponse ,
233+ DockerTagResult ,
145234} from " ~/types/docker" ;
146235
147236// 状态处理
@@ -157,6 +246,16 @@ const state = reactive({
157246 currentToken: " " ,
158247 currentManifest: null as any ,
159248 downloadComplete: false ,
249+ searchLoading: false ,
250+ searchError: " " ,
251+ searchResults: [] as DockerSearchResult [],
252+ searchLastQuery: " " ,
253+ skipNextSearch: false ,
254+ tagSearchLoading: false ,
255+ tagSearchError: " " ,
256+ tagSearchResults: [] as DockerTagResult [],
257+ tagSearchLastQuery: " " ,
258+ skipNextTagSearch: false ,
160259 downloadSummary: null as {
161260 total: number ;
162261 skipped: number ;
@@ -177,11 +276,189 @@ const canFetchPlatforms = computed(() =>
177276 state .imageName && state .tag && ! state .loading
178277);
179278
279+ const imageBlurTimer = ref <ReturnType <typeof setTimeout > | null >(null );
280+ const tagBlurTimer = ref <ReturnType <typeof setTimeout > | null >(null );
281+ const debouncedSearch = debounce ((query : string ) => {
282+ fetchSearch (query );
283+ }, 300 );
284+ const debouncedTagSearch = debounce ((query : string ) => {
285+ fetchTagSearch (query );
286+ }, 300 );
287+
288+ onBeforeUnmount (() => {
289+ debouncedSearch .cancel ();
290+ debouncedTagSearch .cancel ();
291+ });
292+
180293// 事件处理
181294const handlePlatformSelect = (platform : DockerPlatform ) => {
182295 state .selectedPlatform = platform ;
183296};
184297
298+ const fetchSearch = async (query : string ) => {
299+ state .searchLoading = true ;
300+ state .searchError = " " ;
301+ state .searchLastQuery = query ;
302+ try {
303+ const response = await $fetch <DockerSearchResponse >(" /api/docker/search" , {
304+ params: {
305+ query ,
306+ pageSize: 10 ,
307+ },
308+ });
309+ state .searchResults = response .results ;
310+ } catch (e ) {
311+ console .error (" 搜索镜像失败:" , e );
312+ state .searchError = " 搜索失败,请稍后重试" ;
313+ state .searchResults = [];
314+ } finally {
315+ state .searchLoading = false ;
316+ }
317+ };
318+
319+ const scheduleSearch = (query : string ) => {
320+ const trimmed = query .trim ();
321+ if (trimmed .length < 2 ) {
322+ debouncedSearch .cancel ();
323+ state .searchResults = [];
324+ state .searchError = " " ;
325+ state .searchLastQuery = " " ;
326+ state .searchLoading = false ;
327+ return ;
328+ }
329+ debouncedSearch (trimmed );
330+ };
331+
332+ const fetchTagSearch = async (query : string , pageSize = 10 ) => {
333+ if (! state .imageName .trim ()) {
334+ state .tagSearchResults = [];
335+ state .tagSearchError = " 请先输入镜像名称" ;
336+ state .tagSearchLoading = false ;
337+ return ;
338+ }
339+ state .tagSearchLoading = true ;
340+ state .tagSearchError = " " ;
341+ state .tagSearchLastQuery = query ;
342+ try {
343+ const response = await $fetch <DockerTagResponse >(" /api/docker/tags" , {
344+ params: {
345+ imageName: state .imageName ,
346+ query ,
347+ pageSize ,
348+ },
349+ });
350+ state .tagSearchResults = response .results ;
351+ } catch (e ) {
352+ console .error (" 搜索标签失败:" , e );
353+ state .tagSearchError = " 搜索失败,请稍后重试" ;
354+ state .tagSearchResults = [];
355+ } finally {
356+ state .tagSearchLoading = false ;
357+ }
358+ };
359+
360+ const scheduleTagSearch = (query : string ) => {
361+ const trimmed = query .trim ();
362+ if (! state .imageName .trim ()) {
363+ debouncedTagSearch .cancel ();
364+ state .tagSearchResults = [];
365+ state .tagSearchError = " 请先输入镜像名称" ;
366+ state .tagSearchLastQuery = " " ;
367+ state .tagSearchLoading = false ;
368+ return ;
369+ }
370+ if (trimmed .length < 1 ) {
371+ debouncedTagSearch .cancel ();
372+ state .tagSearchResults = [];
373+ state .tagSearchError = " " ;
374+ state .tagSearchLastQuery = " " ;
375+ state .tagSearchLoading = false ;
376+ return ;
377+ }
378+ debouncedTagSearch (trimmed );
379+ };
380+
381+ const handleSearchBlur = () => {
382+ if (imageBlurTimer .value ) {
383+ clearTimeout (imageBlurTimer .value );
384+ }
385+ imageBlurTimer .value = setTimeout (() => {
386+ state .searchResults = [];
387+ state .searchLastQuery = " " ;
388+ }, 200 );
389+ };
390+
391+ const handleSearchFocus = () => {
392+ if (imageBlurTimer .value ) {
393+ clearTimeout (imageBlurTimer .value );
394+ }
395+ };
396+
397+ const handleTagBlur = () => {
398+ if (tagBlurTimer .value ) {
399+ clearTimeout (tagBlurTimer .value );
400+ }
401+ tagBlurTimer .value = setTimeout (() => {
402+ state .tagSearchResults = [];
403+ state .tagSearchLastQuery = " " ;
404+ }, 200 );
405+ };
406+
407+ const handleTagFocus = () => {
408+ if (tagBlurTimer .value ) {
409+ clearTimeout (tagBlurTimer .value );
410+ }
411+ if (! state .imageName .trim ()) {
412+ state .tagSearchResults = [];
413+ state .tagSearchError = " 请先输入镜像名称" ;
414+ state .tagSearchLastQuery = " " ;
415+ state .tagSearchLoading = false ;
416+ return ;
417+ }
418+ debouncedTagSearch .cancel ();
419+ fetchTagSearch (" " , 50 );
420+ };
421+
422+ const handleImageInput = () => {
423+ handleImageChange ();
424+ if (state .skipNextSearch ) {
425+ state .skipNextSearch = false ;
426+ debouncedSearch .cancel ();
427+ return ;
428+ }
429+ scheduleSearch (state .imageName );
430+ };
431+
432+ const handleTagInput = () => {
433+ handleImageChange ();
434+ if (state .skipNextTagSearch ) {
435+ state .skipNextTagSearch = false ;
436+ debouncedTagSearch .cancel ();
437+ return ;
438+ }
439+ scheduleTagSearch (state .tag );
440+ };
441+
442+ const selectSearchResult = (item : DockerSearchResult ) => {
443+ state .skipNextSearch = true ;
444+ state .imageName = item .fullName ;
445+ debouncedSearch .cancel ();
446+ state .searchResults = [];
447+ state .searchError = " " ;
448+ state .searchLastQuery = " " ;
449+ handleImageChange ();
450+ };
451+
452+ const selectTagResult = (item : DockerTagResult ) => {
453+ state .skipNextTagSearch = true ;
454+ state .tag = item .name ;
455+ debouncedTagSearch .cancel ();
456+ state .tagSearchResults = [];
457+ state .tagSearchError = " " ;
458+ state .tagSearchLastQuery = " " ;
459+ handleImageChange ();
460+ };
461+
185462// API调用
186463const fetchToken = async () => {
187464 const response = await $fetch <TokenResponse >(" /api/docker/token" , {
@@ -300,7 +577,8 @@ const assembleImage = async () => {
300577 const url = window .URL .createObjectURL (blob );
301578 const a = document .createElement (" a" );
302579 a .href = url ;
303- a .download = ` ${state .imageName }-${state .tag }.tar ` ;
580+ const safeName = state .imageName .replaceAll (" /" , " _" );
581+ a .download = ` ${safeName }-${state .tag }.tar ` ;
304582 document .body .appendChild (a );
305583 a .click ();
306584 window .URL .revokeObjectURL (url );
@@ -380,6 +658,9 @@ const handleImageChange = () => {
380658 state .downloadProgress = {};
381659 state .downloadComplete = false ;
382660 state .downloadSummary = null ;
661+ state .tagSearchResults = [];
662+ state .tagSearchError = " " ;
663+ state .tagSearchLastQuery = " " ;
383664};
384665 </script >
385666
0 commit comments