feat: add global search support (halo-dev/console#623)

#### What type of PR is this?

/kind feature
/milestone 2.0

#### What this PR does / why we need it:

添加全局搜索框支持。

#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/1924

#### Screenshots:

<img width="1920" alt="image" src="https://user-images.githubusercontent.com/21301288/192448624-fbef4e58-7c0e-4c24-b29e-4dd6eba9fa4f.png">
<img width="1920" alt="image" src="https://user-images.githubusercontent.com/21301288/192448660-369e19a4-747d-45ad-9056-162f5c8e01be.png">
<img width="1920" alt="image" src="https://user-images.githubusercontent.com/21301288/192449009-6b856d82-e7a6-4e93-b2fa-d0d0c7a58ebf.png">


#### Special notes for your reviewer:

/cc @halo-dev/sig-halo-console 

测试方式:

1. 本地 Console 切换到当前 PR 的分支,需要 `pnpm install && pnpm build:packages`
2. 使用 Ctrl + K(Windows/Linux) 或者 Command + K(macOS)调起搜索框
3. 可以使用键盘上/下键(或者 Ctrl + J / Ctrl + K)选择搜索条目,回车键确认选择。

#### Does this PR introduce a user-facing change?

```release-note
后台添加全局搜索的支持
```
pull/3445/head
Ryan Wang 2022-09-28 14:50:16 +08:00 committed by GitHub
parent d68f1481db
commit bd09cb3815
16 changed files with 556 additions and 39 deletions

View File

@ -48,6 +48,7 @@
"emoji-mart": "^5.2.2", "emoji-mart": "^5.2.2",
"filepond": "^4.30.4", "filepond": "^4.30.4",
"floating-vue": "2.0.0-beta.20", "floating-vue": "2.0.0-beta.20",
"fuse.js": "^6.6.2",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
@ -55,7 +56,6 @@
"pinia": "^2.0.22", "pinia": "^2.0.22",
"pretty-bytes": "^6.0.0", "pretty-bytes": "^6.0.0",
"qs": "^6.11.0", "qs": "^6.11.0",
"unsplash-js": "^7.0.15",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"vue": "^3.2.39", "vue": "^3.2.39",
"vue-filepond": "^7.0.3", "vue-filepond": "^7.0.3",

View File

@ -11,6 +11,7 @@ const props = withDefaults(
fullscreen?: boolean; fullscreen?: boolean;
bodyClass?: string[]; bodyClass?: string[];
mountToBody?: boolean; mountToBody?: boolean;
centered?: boolean;
}>(), }>(),
{ {
visible: false, visible: false,
@ -20,6 +21,7 @@ const props = withDefaults(
fullscreen: false, fullscreen: false,
bodyClass: undefined, bodyClass: undefined,
mountToBody: false, mountToBody: false,
centered: true,
} }
); );
@ -34,6 +36,7 @@ const modelWrapper = ref<HTMLElement>();
const wrapperClasses = computed(() => { const wrapperClasses = computed(() => {
return { return {
"modal-wrapper-fullscreen": props.fullscreen, "modal-wrapper-fullscreen": props.fullscreen,
"modal-wrapper-centered": props.centered,
}; };
}); });
@ -123,14 +126,15 @@ watch(
<style lang="scss"> <style lang="scss">
.modal-wrapper { .modal-wrapper {
@apply fixed @apply fixed
top-0
left-0 left-0
h-full h-full
w-full w-full
flex flex
flex-row flex-row
items-center items-start
justify-center; justify-center
top-0
py-10;
z-index: 999; z-index: 999;
.modal-layer { .modal-layer {
@ -154,7 +158,7 @@ watch(
shadow-xl shadow-xl
rounded-base; rounded-base;
width: calc(100vw - 20px); width: calc(100vw - 20px);
max-height: calc(100vh - 20px); max-height: calc(100vh - 5rem);
.modal-header { .modal-header {
@apply flex @apply flex
@ -204,6 +208,13 @@ watch(
} }
} }
&.modal-wrapper-centered {
@apply py-0 items-center;
.modal-content {
max-height: calc(100vh - 20px) !important;
}
}
&.modal-wrapper-fullscreen { &.modal-wrapper-fullscreen {
.modal-content { .modal-content {
width: 100vw !important; width: 100vw !important;

View File

@ -3,8 +3,6 @@ import {
IconMore, IconMore,
IconSearch, IconSearch,
IconUserSettings, IconUserSettings,
VInput,
VModal,
VRoutesMenu, VRoutesMenu,
VTag, VTag,
VAvatar, VAvatar,
@ -13,7 +11,7 @@ import type { MenuGroupType, MenuItemType } from "../types/menus";
import type { User } from "@halo-dev/api-client"; import type { User } from "@halo-dev/api-client";
import logo from "@/assets/logo.svg"; import logo from "@/assets/logo.svg";
import { RouterView, useRoute, useRouter } from "vue-router"; import { RouterView, useRoute, useRouter } from "vue-router";
import { computed, inject, ref } from "vue"; import { computed, inject, ref, type Ref } from "vue";
const menus = inject<MenuGroupType[]>("menus"); const menus = inject<MenuGroupType[]>("menus");
const minimenus = inject<MenuItemType[]>("minimenus"); const minimenus = inject<MenuItemType[]>("minimenus");
@ -22,7 +20,6 @@ const router = useRouter();
const moreMenuVisible = ref(false); const moreMenuVisible = ref(false);
const moreMenuRootVisible = ref(false); const moreMenuRootVisible = ref(false);
const spotlight = ref(false);
const currentUser = inject<User>("currentUser"); const currentUser = inject<User>("currentUser");
@ -37,6 +34,13 @@ const currentRole = computed(() => {
] || "[]" ] || "[]"
)[0]; )[0];
}); });
const globalSearchVisible = inject<Ref<boolean>>(
"globalSearchVisible",
ref(false)
);
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
</script> </script>
<template> <template>
@ -48,13 +52,15 @@ const currentRole = computed(() => {
<div class="px-3"> <div class="px-3">
<div <div
class="flex cursor-pointer items-center rounded bg-gray-100 p-2 text-gray-400 transition-all hover:text-gray-900" class="flex cursor-pointer items-center rounded bg-gray-100 p-2 text-gray-400 transition-all hover:text-gray-900"
@click="spotlight = true" @click="globalSearchVisible = true"
> >
<span class="mr-3"> <span class="mr-3">
<IconSearch /> <IconSearch />
</span> </span>
<span class="flex-1 select-none text-base font-normal">搜索</span> <span class="flex-1 select-none text-base font-normal">搜索</span>
<div class="text-sm">+K</div> <div class="text-sm">
{{ `${isMac ? "⌘" : "Ctrl"}+K` }}
</div>
</div> </div>
</div> </div>
<VRoutesMenu :menus="menus" /> <VRoutesMenu :menus="menus" />
@ -181,12 +187,6 @@ const currentRole = computed(() => {
</Teleport> </Teleport>
</div> </div>
</div> </div>
<VModal v-model:visible="spotlight" :width="600">
<template #header>
<VInput placeholder="全局搜索" size="lg"></VInput>
</template>
</VModal>
</template> </template>
<style lang="scss"> <style lang="scss">

View File

@ -54,6 +54,7 @@ importers:
eslint-plugin-vue: ^9.5.1 eslint-plugin-vue: ^9.5.1
filepond: ^4.30.4 filepond: ^4.30.4
floating-vue: 2.0.0-beta.20 floating-vue: 2.0.0-beta.20
fuse.js: ^6.6.2
husky: ^8.0.1 husky: ^8.0.1
jsdom: ^20.0.0 jsdom: ^20.0.0
lodash.clonedeep: ^4.5.0 lodash.clonedeep: ^4.5.0
@ -74,7 +75,6 @@ importers:
tailwindcss-themer: ^2.0.2 tailwindcss-themer: ^2.0.2
typescript: ~4.7.4 typescript: ~4.7.4
unplugin-icons: ^0.14.10 unplugin-icons: ^0.14.10
unsplash-js: ^7.0.15
uuid: ^8.3.2 uuid: ^8.3.2
vite: ^3.1.3 vite: ^3.1.3
vite-compression-plugin: ^0.0.4 vite-compression-plugin: ^0.0.4
@ -115,6 +115,7 @@ importers:
emoji-mart: 5.2.2 emoji-mart: 5.2.2
filepond: 4.30.4 filepond: 4.30.4
floating-vue: 2.0.0-beta.20_vue@3.2.39 floating-vue: 2.0.0-beta.20_vue@3.2.39
fuse.js: 6.6.2
lodash.clonedeep: 4.5.0 lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0 lodash.isequal: 4.5.0
lodash.merge: 4.6.2 lodash.merge: 4.6.2
@ -122,7 +123,6 @@ importers:
pinia: 2.0.22_uxrvejtcwrakwzzo6hlouuo2vq pinia: 2.0.22_uxrvejtcwrakwzzo6hlouuo2vq
pretty-bytes: 6.0.0 pretty-bytes: 6.0.0
qs: 6.11.0 qs: 6.11.0
unsplash-js: 7.0.15
uuid: 8.3.2 uuid: 8.3.2
vue: 3.2.39 vue: 3.2.39
vue-filepond: 7.0.3_filepond@4.30.4+vue@3.2.39 vue-filepond: 7.0.3_filepond@4.30.4+vue@3.2.39
@ -2921,10 +2921,6 @@ packages:
'@types/node': 17.0.45 '@types/node': 17.0.45
dev: true dev: true
/@types/content-type/1.1.5:
resolution: {integrity: sha512-dgMN+syt1xb7Hk8LU6AODOfPlvz5z1CbXpPuJE5ZrX9STfBOIXF09pEB8N7a97WT9dbngt3ksDCm6GW6yMrxfQ==}
dev: false
/@types/estree/0.0.39: /@types/estree/0.0.39:
resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==} resolution: {integrity: sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==}
dev: true dev: true
@ -4245,11 +4241,6 @@ packages:
resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==} resolution: {integrity: sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==}
dev: true dev: true
/content-type/1.0.4:
resolution: {integrity: sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA==}
engines: {node: '>= 0.6'}
dev: false
/convert-source-map/1.8.0: /convert-source-map/1.8.0:
resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==} resolution: {integrity: sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==}
dependencies: dependencies:
@ -5550,6 +5541,11 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true dev: true
/fuse.js/6.6.2:
resolution: {integrity: sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==}
engines: {node: '>=10'}
dev: false
/gensync/1.0.0-beta.2: /gensync/1.0.0-beta.2:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@ -8616,14 +8612,6 @@ packages:
webpack-virtual-modules: 0.4.4 webpack-virtual-modules: 0.4.4
dev: true dev: true
/unsplash-js/7.0.15:
resolution: {integrity: sha512-WGqKp9wl2m2tAUPyw2eMZs/KICR+A52tCaRapzVXWxkA4pjHqsaGwiJXTEW7hBy4Pu0QmP6KxTt2jST3tluawA==}
engines: {node: '>=10'}
dependencies:
'@types/content-type': 1.1.5
content-type: 1.0.4
dev: false
/untildify/4.0.0: /untildify/4.0.0:
resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==}
engines: {node: '>=8'} engines: {node: '>=8'}

View File

@ -1,8 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import { RouterView, useRoute } from "vue-router"; import { RouterView, useRoute } from "vue-router";
import { VDialogProvider } from "@halo-dev/components"; import { VDialogProvider } from "@halo-dev/components";
import { watch } from "vue"; import { onMounted, provide, ref, watch, type Ref } from "vue";
import { useTitle } from "@vueuse/core"; import { useTitle } from "@vueuse/core";
import GlobalSearchModal from "@/components/global-search/GlobalSearchModal.vue";
const AppName = "Halo"; const AppName = "Halo";
const route = useRoute(); const route = useRoute();
@ -19,11 +20,30 @@ watch(
title.value = AppName; title.value = AppName;
} }
); );
const globalSearchVisible = ref(false);
provide<Ref<boolean>>("globalSearchVisible", globalSearchVisible);
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
const handleKeybinding = (e: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = e;
if (key === "k" && ((ctrlKey && !isMac) || metaKey)) {
globalSearchVisible.value = true;
e.preventDefault();
}
};
onMounted(() => {
document.addEventListener("keydown", handleKeybinding);
});
</script> </script>
<template> <template>
<VDialogProvider> <VDialogProvider>
<RouterView /> <RouterView />
<GlobalSearchModal v-model:visible="globalSearchVisible" />
</VDialogProvider> </VDialogProvider>
</template> </template>

View File

@ -0,0 +1,456 @@
<script lang="ts" setup>
import { useRoute, useRouter, type RouteLocationRaw } from "vue-router";
import {
VModal,
VEntity,
VEntityField,
IconLink,
IconBookRead,
IconFolder,
IconSettings,
IconPalette,
IconPages,
IconUserSettings,
} from "@halo-dev/components";
import { computed, markRaw, onMounted, ref, watch, type Component } from "vue";
import Fuse from "fuse.js";
import { apiClient } from "@/utils/api-client";
const router = useRouter();
const route = useRoute();
const props = withDefaults(
defineProps<{
visible: boolean;
}>(),
{
visible: false,
}
);
const emit = defineEmits<{
(e: "update:visible", visible: boolean): void;
}>();
const globalSearchInput = ref<HTMLInputElement | null>(null);
const keyword = ref("");
interface SearchableItem {
title: string;
icon: { component: Component } | { src: string };
group?: string;
route: RouteLocationRaw;
}
const searchableItem: SearchableItem[] = [];
const selectedIndex = ref(0);
const fuse = new Fuse(searchableItem, {
keys: ["title", "group", "route.path", "route.name"],
useExtendedSearch: true,
});
const searchResults = computed((): SearchableItem[] => {
return fuse
.search(keyword.value, {
limit: 20,
})
.map((result) => result.item);
});
const handleBuildSearchIndex = () => {
const routes = router.getRoutes().filter((route) => {
return !!route.meta?.title && route.meta?.searchable;
});
routes.forEach((route) => {
fuse.add({
title: route.meta?.title as string,
icon: {
component: markRaw(IconLink),
},
group: "后台页面",
route,
});
});
apiClient.extension.user.listv1alpha1User().then((response) => {
response.data.items.forEach((user) => {
fuse.add({
title: user.spec.displayName,
icon: {
component: markRaw(IconUserSettings),
},
group: "用户",
route: {
name: "UserDetail",
params: {
name: user.metadata.name,
},
},
});
});
});
apiClient.extension.plugin
.listpluginHaloRunV1alpha1Plugin()
.then((response) => {
response.data.items.forEach((plugin) => {
fuse.add({
title: plugin.spec.displayName as string,
icon: {
src: plugin.spec.logo as string,
},
group: "插件",
route: {
name: "PluginDetail",
params: {
name: plugin.metadata.name,
},
},
});
});
});
apiClient.extension.post.listcontentHaloRunV1alpha1Post().then((response) => {
response.data.items.forEach((post) => {
fuse.add({
title: post.spec.title,
icon: {
component: markRaw(IconBookRead),
},
group: "文章",
route: {
name: "PostEditor",
query: {
name: post.metadata.name,
},
},
});
});
});
apiClient.extension.category
.listcontentHaloRunV1alpha1Category()
.then((response) => {
response.data.items.forEach((category) => {
fuse.add({
title: category.spec.displayName,
icon: {
component: markRaw(IconBookRead),
},
group: "分类",
route: {
name: "Categories",
query: {
name: category.metadata.name,
},
},
});
});
});
apiClient.extension.tag.listcontentHaloRunV1alpha1Tag().then((response) => {
response.data.items.forEach((tag) => {
fuse.add({
title: tag.spec.displayName,
icon: {
component: markRaw(IconBookRead),
},
group: "标签",
route: {
name: "Tags",
query: {
name: tag.metadata.name,
},
},
});
});
});
apiClient.extension.singlePage
.listcontentHaloRunV1alpha1SinglePage()
.then((response) => {
response.data.items.forEach((singlePage) => {
fuse.add({
title: singlePage.spec.title,
icon: {
component: markRaw(IconPages),
},
group: "自定义页面",
route: {
name: "SinglePageEditor",
query: {
name: singlePage.metadata.name,
},
},
});
});
});
apiClient.extension.storage.attachment
.liststorageHaloRunV1alpha1Attachment()
.then((response) => {
response.data.items.forEach((attachment) => {
fuse.add({
title: attachment.spec.displayName as string,
icon: {
component: markRaw(IconFolder),
},
group: "附件",
route: {
name: "Attachments",
query: {
name: attachment.metadata.name,
},
},
});
});
});
apiClient.extension.setting
.getv1alpha1Setting({ name: "system" })
.then((response) => {
response.data.spec.forms.forEach((form) => {
fuse.add({
title: form.label as string,
icon: {
component: markRaw(IconSettings),
},
group: "设置",
route: {
name: "SystemSetting",
params: {
group: form.group,
},
},
});
});
});
// get theme settings
apiClient.extension.configMap
.getv1alpha1ConfigMap({
name: "system",
})
.then(({ data: systemConfigMap }) => {
if (systemConfigMap.data?.theme) {
const themeConfig = JSON.parse(systemConfigMap.data.theme);
apiClient.extension.theme
.getthemeHaloRunV1alpha1Theme({
name: themeConfig.active,
})
.then(({ data: theme }) => {
if (theme && theme.spec.settingName) {
apiClient.extension.setting
.getv1alpha1Setting({
name: theme.spec.settingName,
})
.then(({ data: themeSettings }) => {
themeSettings.spec.forms.forEach((form) => {
fuse.add({
title: `${theme.spec.displayName} / ${form.label}`,
icon: {
component: markRaw(IconPalette),
},
group: "主题设置",
route: {
name: "ThemeSetting",
params: {
group: form.group,
},
},
});
});
});
}
});
}
});
};
const handleKeydown = (e: KeyboardEvent) => {
if (!props.visible) {
return;
}
const { key, ctrlKey } = e;
if (key === "ArrowUp" || (key === "k" && ctrlKey)) {
selectedIndex.value = Math.max(0, selectedIndex.value - 1);
e.preventDefault();
}
if (key === "ArrowDown" || (key === "j" && ctrlKey)) {
selectedIndex.value = Math.min(
searchResults.value.length - 1,
selectedIndex.value + 1
);
e.preventDefault();
}
if (key === "Enter") {
const searchResult = searchResults.value[selectedIndex.value];
handleRoute(searchResult);
}
if (key === "Escape") {
onVisibleChange(false);
e.preventDefault();
}
};
const handleRoute = async (item: SearchableItem) => {
// if route has query params, need router.go(0)
if (typeof item.route !== "string" && "query" in item.route) {
if ("name" in item.route && route.name === item.route.name) {
await router.push(item.route);
router.go(0);
return;
}
}
router.push(item.route);
emit("update:visible", false);
};
watch(
() => selectedIndex.value,
(index) => {
if (index > 0) {
document.getElementById(`search-item-${index}`)?.scrollIntoView();
return;
}
document.getElementById("search-input")?.scrollIntoView();
}
);
watch(
() => keyword.value,
() => {
selectedIndex.value = 0;
}
);
watch(
() => props.visible,
(visible) => {
if (visible) {
setTimeout(() => {
globalSearchInput.value?.focus();
}, 100);
document.addEventListener("keydown", handleKeydown);
} else {
document.removeEventListener("keydown", handleKeydown);
keyword.value = "";
selectedIndex.value = 0;
}
}
);
onMounted(() => {
handleBuildSearchIndex();
});
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
};
</script>
<template>
<VModal
:visible="visible"
:body-class="['!p-0']"
:mount-to-body="true"
class="items-start"
:width="650"
:centered="false"
@update:visible="onVisibleChange"
>
<div id="search-input" class="border-b border-gray-100 px-4 py-2.5">
<input
ref="globalSearchInput"
v-model="keyword"
placeholder="输入关键词以搜索"
class="w-full py-1 text-base outline-none"
autocomplete="off"
autocorrect="off"
spellcheck="false"
/>
</div>
<div class="px-2 py-2.5">
<div
v-if="!searchResults.length"
class="flex items-center justify-center text-sm text-gray-500"
>
<span>没有搜索结果</span>
</div>
<ul
v-if="searchResults.length > 0"
class="box-border flex h-full w-full flex-col gap-0.5"
role="list"
>
<li
v-for="(item, itemIndex) in searchResults"
:id="`search-item-${itemIndex}`"
:key="itemIndex"
@click="handleRoute(item)"
>
<VEntity
class="rounded-md px-2 py-2.5 hover:bg-gray-100"
:class="{ 'bg-gray-100': selectedIndex === itemIndex }"
>
<template #start>
<VEntityField>
<template #description>
<div class="h-5 w-5 rounded border p-0.5">
<component
:is="item.icon.component"
v-if="'component' in item.icon"
class="h-full w-full"
/>
<img
v-if="'src' in item.icon"
:src="item.icon.src"
class="h-full w-full object-cover"
/>
</div>
</template>
</VEntityField>
<VEntityField :title="item.title"></VEntityField>
</template>
<template #end>
<VEntityField :description="item.group"></VEntityField>
</template>
</VEntity>
</li>
</ul>
</div>
<div class="border-t border-gray-100 px-4 py-2.5">
<div class="flex items-center justify-end">
<span class="mr-1 text-xs text-gray-600">选择</span>
<kbd
class="mr-1 w-5 rounded border p-0.5 text-center text-[10px] text-gray-600 shadow-sm"
>
</kbd>
<kbd
class="mr-5 w-5 rounded border p-0.5 text-center text-[10px] text-gray-600 shadow-sm"
>
</kbd>
<span class="mr-1 text-xs text-gray-600">确认</span>
<kbd
class="mr-5 rounded border p-0.5 text-[10px] text-gray-600 shadow-sm"
>
Enter
</kbd>
<span class="mr-1 text-xs text-gray-600">关闭</span>
<kbd class="rounded border p-0.5 text-[10px] text-gray-600 shadow-sm">
Esc
</kbd>
</div>
</div>
</VModal>
</template>

View File

@ -26,7 +26,7 @@ import AttachmentDetailModal from "./components/AttachmentDetailModal.vue";
import AttachmentUploadModal from "./components/AttachmentUploadModal.vue"; import AttachmentUploadModal from "./components/AttachmentUploadModal.vue";
import AttachmentPoliciesModal from "./components/AttachmentPoliciesModal.vue"; import AttachmentPoliciesModal from "./components/AttachmentPoliciesModal.vue";
import AttachmentGroupList from "./components/AttachmentGroupList.vue"; import AttachmentGroupList from "./components/AttachmentGroupList.vue";
import { onMounted, ref } from "vue"; import { onMounted, ref, watch } from "vue";
import type { Attachment, Group, Policy, User } from "@halo-dev/api-client"; import type { Attachment, Group, Policy, User } from "@halo-dev/api-client";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
@ -134,6 +134,8 @@ const handleCheckAllChange = (e: Event) => {
const onDetailModalClose = () => { const onDetailModalClose = () => {
selectedAttachment.value = undefined; selectedAttachment.value = undefined;
nameQuery.value = undefined;
nameQueryAttachment.value = undefined;
handleFetchAttachments(); handleFetchAttachments();
}; };
@ -177,11 +179,37 @@ onMounted(() => {
uploadVisible.value = true; uploadVisible.value = true;
} }
}); });
const nameQuery = useRouteQuery<string | undefined>("name");
const nameQueryAttachment = ref<Attachment>();
watch(
() => selectedAttachment.value,
() => {
if (selectedAttachment.value) {
nameQuery.value = selectedAttachment.value.metadata.name;
}
}
);
onMounted(() => {
if (!nameQuery.value) {
return;
}
apiClient.extension.storage.attachment
.getstorageHaloRunV1alpha1Attachment({
name: nameQuery.value,
})
.then((response) => {
nameQueryAttachment.value = response.data;
detailVisible.value = true;
});
});
</script> </script>
<template> <template>
<AttachmentDetailModal <AttachmentDetailModal
v-model:visible="detailVisible" v-model:visible="detailVisible"
:attachment="selectedAttachment" :attachment="selectedAttachment || nameQueryAttachment"
@close="onDetailModalClose" @close="onDetailModalClose"
> >
<template #actions> <template #actions>

View File

@ -16,6 +16,7 @@ export default definePlugin({
component: CommentList, component: CommentList,
meta: { meta: {
title: "评论", title: "评论",
searchable: true,
}, },
}, },
], ],

View File

@ -27,6 +27,7 @@ export default definePlugin({
component: FunctionalPageList, component: FunctionalPageList,
meta: { meta: {
title: "功能页面", title: "功能页面",
searchable: true,
}, },
}, },
], ],
@ -45,6 +46,7 @@ export default definePlugin({
component: SinglePageList, component: SinglePageList,
meta: { meta: {
title: "自定义页面", title: "自定义页面",
searchable: true,
}, },
}, },
], ],
@ -59,6 +61,7 @@ export default definePlugin({
component: SinglePageEditor, component: SinglePageEditor,
meta: { meta: {
title: "页面编辑", title: "页面编辑",
searchable: true,
}, },
}, },
], ],

View File

@ -19,6 +19,7 @@ export default definePlugin({
component: PostList, component: PostList,
meta: { meta: {
title: "文章", title: "文章",
searchable: true,
}, },
}, },
{ {
@ -27,6 +28,7 @@ export default definePlugin({
component: PostEditor, component: PostEditor,
meta: { meta: {
title: "文章编辑", title: "文章编辑",
searchable: true,
}, },
}, },
{ {
@ -39,6 +41,7 @@ export default definePlugin({
component: CategoryList, component: CategoryList,
meta: { meta: {
title: "文章分类", title: "文章分类",
searchable: true,
}, },
}, },
], ],
@ -53,6 +56,7 @@ export default definePlugin({
component: TagList, component: TagList,
meta: { meta: {
title: "文章标签", title: "文章标签",
searchable: true,
}, },
}, },
], ],

View File

@ -33,6 +33,7 @@ export default definePlugin({
component: Dashboard, component: Dashboard,
meta: { meta: {
title: "仪表盘", title: "仪表盘",
searchable: true,
}, },
}, },
], ],

View File

@ -16,6 +16,7 @@ export default definePlugin({
component: Menus, component: Menus,
meta: { meta: {
title: "菜单", title: "菜单",
searchable: true,
}, },
}, },
], ],

View File

@ -19,6 +19,7 @@ export default definePlugin({
component: ThemeDetail, component: ThemeDetail,
meta: { meta: {
title: "主题", title: "主题",
searchable: true,
}, },
}, },
{ {

View File

@ -23,6 +23,7 @@ export default definePlugin({
component: PluginList, component: PluginList,
meta: { meta: {
title: "插件", title: "插件",
searchable: true,
permissions: ["system:plugins:view"], permissions: ["system:plugins:view"],
}, },
}, },

View File

@ -16,6 +16,7 @@ export default definePlugin({
component: RoleList, component: RoleList,
meta: { meta: {
title: "角色", title: "角色",
searchable: true,
permissions: ["system:roles:view"], permissions: ["system:roles:view"],
}, },
}, },

View File

@ -29,6 +29,7 @@ export default definePlugin({
component: UserList, component: UserList,
meta: { meta: {
title: "用户", title: "用户",
searchable: true,
permissions: ["system:users:view"], permissions: ["system:users:view"],
}, },
}, },