Skip to content

Commit 6a715e1

Browse files
committed
refactor: split pull page and enhance search/tag lists
- Extract PullPanel, ImageSearchInput, TagSearchInput components and slim index page - Add @vueuse/core for v-model handling in new components - Sort image search by pull_count and tags by last_updated - Display pull counts in image options and last updated in tag options
1 parent 573a3cc commit 6a715e1

File tree

9 files changed

+4370
-2485
lines changed

9 files changed

+4370
-2485
lines changed

.vscode/settings.json

Lines changed: 0 additions & 5 deletions
This file was deleted.

components/ImageSearchInput.vue

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
<template>
2+
<UInputMenu
3+
:model-value="selectedValue"
4+
:query="query"
5+
:options="options"
6+
:loading="loading"
7+
option-attribute="label"
8+
value-attribute="value"
9+
placeholder="镜像名称(支持搜索)"
10+
class="w-full"
11+
@update:model-value="handleSelect"
12+
@update:query="handleQueryUpdate"
13+
@focus="handleFocus"
14+
>
15+
<template #option="{ option }">
16+
<div class="flex flex-col">
17+
<div class="text-sm text-gray-900 font-medium">
18+
{{ option.label }}
19+
<span v-if="option.isOfficial" class="ml-1 text-xs text-green-600">官方</span>
20+
</div>
21+
<div class="text-xs text-gray-500 truncate">
22+
<span v-if="option.description">{{ option.description }}</span>
23+
<span v-else>暂无描述</span>
24+
<span v-if="typeof option.pulls === 'number'" class="ml-2">
25+
下载量 {{ formatCount(option.pulls) }}
26+
</span>
27+
</div>
28+
</div>
29+
</template>
30+
<template #empty>
31+
<span v-if="error" class="text-red-500">{{ error }}</span>
32+
<span v-else-if="query.trim().length < 2" class="text-gray-500">
33+
请输入至少 2 个字符
34+
</span>
35+
<span v-else class="text-gray-500">未找到匹配的镜像</span>
36+
</template>
37+
</UInputMenu>
38+
</template>
39+
40+
<script setup lang="ts">
41+
import { ref, watch, onBeforeUnmount } from "vue";
42+
import { useVModel } from "@vueuse/core";
43+
import debounce from "lodash/debounce";
44+
import type { DockerSearchResponse } from "~/types/docker";
45+
46+
type SearchOption = {
47+
label: string;
48+
value: string;
49+
description?: string;
50+
isOfficial?: boolean;
51+
pulls?: number;
52+
};
53+
54+
const props = defineProps<{
55+
modelValue: string;
56+
}>();
57+
58+
const emit = defineEmits<{
59+
(e: "update:modelValue", value: string): void;
60+
}>();
61+
62+
const inputValue = useVModel(props, "modelValue", emit);
63+
const query = ref(inputValue.value || "");
64+
const selectedValue = ref<string | null>(null);
65+
const options = ref<SearchOption[]>([]);
66+
const loading = ref(false);
67+
const error = ref("");
68+
const ignoreNextQueryClear = ref(false);
69+
70+
const fetchSearch = async (q: string) => {
71+
const trimmed = q.trim();
72+
if (trimmed.length < 2) {
73+
options.value = [];
74+
error.value = "";
75+
loading.value = false;
76+
return;
77+
}
78+
loading.value = true;
79+
error.value = "";
80+
try {
81+
const response = await $fetch<DockerSearchResponse>("/api/docker/search", {
82+
params: {
83+
query: trimmed,
84+
pageSize: 10,
85+
},
86+
});
87+
options.value = response.results.map((item) => ({
88+
label: item.fullName,
89+
value: item.fullName,
90+
description: item.description,
91+
isOfficial: item.is_official,
92+
pulls: item.pull_count,
93+
}));
94+
} catch (e) {
95+
console.error("搜索镜像失败:", e);
96+
error.value = "搜索失败,请稍后重试";
97+
options.value = [];
98+
} finally {
99+
loading.value = false;
100+
}
101+
};
102+
103+
const debouncedSearch = debounce(fetchSearch, 300);
104+
105+
const formatCount = (value?: number) => {
106+
if (value === undefined || value === null) return "";
107+
return new Intl.NumberFormat("zh-CN").format(value);
108+
};
109+
110+
const handleSelect = (value: string) => {
111+
ignoreNextQueryClear.value = true;
112+
selectedValue.value = value;
113+
inputValue.value = value;
114+
query.value = value;
115+
};
116+
117+
const handleQueryUpdate = (value: string) => {
118+
if (value === "" && ignoreNextQueryClear.value) {
119+
ignoreNextQueryClear.value = false;
120+
return;
121+
}
122+
ignoreNextQueryClear.value = false;
123+
query.value = value;
124+
inputValue.value = value;
125+
if (selectedValue.value && selectedValue.value !== value) {
126+
selectedValue.value = null;
127+
}
128+
debouncedSearch(value);
129+
};
130+
131+
const handleFocus = () => {
132+
if (query.value.trim().length >= 2) {
133+
debouncedSearch(query.value);
134+
}
135+
};
136+
137+
watch(
138+
() => inputValue.value,
139+
(value) => {
140+
if (value !== query.value) {
141+
query.value = value || "";
142+
}
143+
if (selectedValue.value && selectedValue.value !== value) {
144+
selectedValue.value = null;
145+
}
146+
}
147+
);
148+
149+
onBeforeUnmount(() => {
150+
debouncedSearch.cancel();
151+
});
152+
</script>

0 commit comments

Comments
 (0)