Skip to content

Commit 573a3cc

Browse files
committed
feat: add image/tag autocomplete and normalize image names
- Add Docker Hub search and tag endpoints plus shared response types - Debounce search with lodash and improve dropdown UX - Normalize image names server-side to support library images - Clean up download filename handling to avoid slash issues
1 parent 506fe78 commit 573a3cc

File tree

13 files changed

+561
-32
lines changed

13 files changed

+561
-32
lines changed

components/PlatformSelector.vue

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,18 @@
66
enter-to-class="opacity-100 translate-y-0"
77
>
88
<div class="flex gap-3">
9-
<USelect
9+
<USelectMenu
1010
v-model="selectedArch"
1111
:options="availableArchitectures"
1212
placeholder="选择架构"
13-
class="flex-1 focus:border-gray-400 border-gray-200 !ring-0"
13+
class="flex-1"
1414
@update:model-value="handleChange"
1515
/>
16-
<USelect
16+
<USelectMenu
1717
v-model="selectedOS"
1818
:options="availableOS"
1919
placeholder="选择操作系统"
20-
class="flex-1 focus:border-gray-400 border-gray-200 !ring-0"
20+
class="flex-1"
2121
@update:model-value="handleChange"
2222
/>
2323
</div>
@@ -58,4 +58,4 @@ const handleChange = () => {
5858
});
5959
}
6060
};
61-
</script>
61+
</script>

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"axios": "^1.7.9",
1515
"docker-pull-in-web": "file:",
1616
"https-proxy-agent": "^7.0.6",
17+
"lodash": "^4.17.21",
1718
"nuxt": "^3.14.1592",
1819
"nuxt-app": "file:",
1920
"tar": "^7.4.3",

pages/index.vue

Lines changed: 295 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,102 @@
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
@@ -135,13 +219,18 @@
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";
139224
import 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
// 事件处理
181294
const 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调用
186463
const 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

Comments
 (0)