feat: add filter options for attachment selector component (#6505)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.19.0

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

为附件选择组件添加更多筛选项支持。


<img width="1277" alt="image" src="https://github.com/user-attachments/assets/6c61a8cf-ae5d-4496-9a87-9403c5d4fc28">

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

Fixes https://github.com/halo-dev/halo/issues/4605
Fixes https://github.com/halo-dev/halo/issues/6352

#### Special notes for your reviewer:

需要测试附件选择组件的各项功能是否符合预期。

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

```release-note
为附件选择组件添加更多筛选项支持。
```
pull/6512/head
Ryan Wang 2024-08-25 23:05:11 +08:00 committed by GitHub
parent 487b0d343c
commit c92bbd754a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 351 additions and 70 deletions

View File

@ -9,19 +9,29 @@ import {
IconCheckboxCircle,
IconCheckboxFill,
IconEye,
IconGrid,
IconList,
IconRefreshLine,
IconUpload,
VButton,
VCard,
VEmpty,
VLoading,
VPagination,
VSpace,
} from "@halo-dev/components";
import type { AttachmentLike } from "@halo-dev/console-shared";
import { computed, ref, watchEffect } from "vue";
import { useLocalStorage } from "@vueuse/core";
import { computed, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { useAttachmentControl } from "../../composables/use-attachment";
import { useFetchAttachmentPolicy } from "../../composables/use-attachment-policy";
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
import AttachmentGroupList from "../AttachmentGroupList.vue";
import AttachmentUploadModal from "../AttachmentUploadModal.vue";
import AttachmentSelectorListItem from "./components/AttachmentSelectorListItem.vue";
const { t } = useI18n();
const props = withDefaults(
defineProps<{
@ -43,7 +53,12 @@ const emit = defineEmits<{
(event: "change-provider", providerId: string): void;
}>();
const { policies } = useFetchAttachmentPolicy();
const keyword = ref("");
const selectedGroup = ref();
const selectedPolicy = ref();
const selectedSort = ref();
const page = ref(1);
const size = ref(20);
@ -59,15 +74,35 @@ const {
handleSelectNext,
handleReset,
isChecked,
isFetching,
} = useAttachmentControl({
groupName: selectedGroup,
policyName: selectedPolicy,
sort: selectedSort,
accepts: computed(() => {
return props.accepts;
}),
page,
size,
keyword,
});
watch(
() => [selectedPolicy.value, selectedSort.value, keyword.value],
() => {
page.value = 1;
}
);
const hasFilters = computed(() => {
return selectedPolicy.value || selectedSort.value;
});
function handleClearFilters() {
selectedPolicy.value = undefined;
selectedSort.value = undefined;
}
const uploadVisible = ref(false);
const detailVisible = ref(false);
@ -106,9 +141,119 @@ function onGroupSelect(group: Group) {
selectedGroup.value = group.metadata.name;
handleReset();
}
// View type
const viewTypes = [
{
name: "list",
tooltip: t("core.attachment.filters.view_type.items.list"),
icon: IconList,
},
{
name: "grid",
tooltip: t("core.attachment.filters.view_type.items.grid"),
icon: IconGrid,
},
];
const viewType = useLocalStorage("attachment-selector-view-type", "grid");
</script>
<template>
<div class="mb-3 block w-full rounded bg-gray-50 px-3 py-2">
<div class="relative flex flex-col items-start sm:flex-row sm:items-center">
<div class="flex w-full flex-1 items-center sm:w-auto">
<SearchInput v-model="keyword" />
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg">
<FilterCleanButton v-if="hasFilters" @click="handleClearFilters" />
<FilterDropdown
v-model="selectedPolicy"
:label="$t('core.attachment.filters.storage_policy.label')"
:items="[
{
label: t('core.common.filters.item_labels.all'),
},
...(policies?.map((policy) => {
return {
label: policy.spec.displayName,
value: policy.metadata.name,
};
}) || []),
]"
/>
<FilterDropdown
v-model="selectedSort"
:label="$t('core.common.filters.labels.sort')"
:items="[
{
label: t('core.common.filters.item_labels.default'),
},
{
label: t('core.attachment.filters.sort.items.create_time_desc'),
value: 'metadata.creationTimestamp,desc',
},
{
label: t('core.attachment.filters.sort.items.create_time_asc'),
value: 'metadata.creationTimestamp,asc',
},
{
label: t(
'core.attachment.filters.sort.items.display_name_desc'
),
value: 'spec.displayName,desc',
},
{
label: t('core.attachment.filters.sort.items.display_name_asc'),
value: 'spec.displayName,asc',
},
{
label: t('core.attachment.filters.sort.items.size_desc'),
value: 'spec.size,desc',
},
{
label: t('core.attachment.filters.sort.items.size_asc'),
value: 'spec.size,asc',
},
]"
/>
<div class="flex flex-row gap-2">
<div
v-for="(item, index) in viewTypes"
:key="index"
v-tooltip="`${item.tooltip}`"
:class="{
'bg-gray-200 font-bold text-black': viewType === item.name,
}"
class="cursor-pointer rounded p-1 hover:bg-gray-200"
@click="viewType = item.name"
>
<component :is="item.icon" class="h-4 w-4" />
</div>
</div>
<div class="flex flex-row gap-2">
<div
class="group cursor-pointer rounded p-1 hover:bg-gray-200"
@click="handleFetchAttachments()"
>
<IconRefreshLine
v-tooltip="$t('core.common.buttons.refresh')"
:class="{ 'animate-spin text-gray-900': isFetching }"
class="h-4 w-4 text-gray-600 group-hover:text-gray-900"
/>
</div>
</div>
</VSpace>
</div>
</div>
</div>
<AttachmentGroupList readonly @select="onGroupSelect" />
<div v-if="attachments?.length" class="mb-5">
<VButton @click="uploadVisible = true">
<template #icon>
@ -117,8 +262,11 @@ function onGroupSelect(group: Group) {
{{ $t("core.common.buttons.upload") }}
</VButton>
</div>
<VLoading v-if="isLoading" />
<VEmpty
v-if="!attachments?.length && !isLoading"
v-else-if="!attachments?.length"
:message="$t('core.attachment.empty.message')"
:title="$t('core.attachment.empty.title')"
>
@ -136,78 +284,111 @@ function onGroupSelect(group: Group) {
</VSpace>
</template>
</VEmpty>
<div
v-else
class="mt-2 grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-5"
role="list"
>
<VCard
v-for="(attachment, index) in attachments"
:key="index"
:body-class="['!p-0']"
:class="{
'ring-1 ring-primary': isChecked(attachment),
'pointer-events-none !cursor-not-allowed opacity-50':
isDisabled(attachment),
}"
class="hover:shadow"
@click.stop="handleSelect(attachment)"
>
<div class="group relative bg-white">
<div
class="aspect-h-8 aspect-w-10 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<LazyImage
v-if="isImage(attachment.spec.mediaType)"
:key="attachment.metadata.name"
:alt="attachment.spec.displayName"
:src="attachment.status?.permalink"
classes="pointer-events-none object-cover group-hover:opacity-75 transform-gpu"
>
<template #loading>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div class="flex h-full items-center justify-center object-cover">
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
<AttachmentFileTypeIcon
v-else
:file-name="attachment.spec.displayName"
/>
</div>
<p
class="pointer-events-none block truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.spec.displayName }}
</p>
<div
:class="{ '!flex': selectedAttachments.has(attachment) }"
class="absolute left-0 top-0 hidden h-1/3 w-full justify-end bg-gradient-to-b from-gray-300 to-transparent ease-in-out group-hover:flex"
<div v-else>
<Transition v-if="viewType === 'grid'" appear name="fade">
<div
class="mt-2 grid grid-cols-3 gap-x-2 gap-y-3 sm:grid-cols-3 md:grid-cols-5"
role="list"
>
<VCard
v-for="(attachment, index) in attachments"
:key="index"
:body-class="['!p-0']"
:class="{
'ring-1 ring-primary': isChecked(attachment),
'pointer-events-none !cursor-not-allowed opacity-50':
isDisabled(attachment),
}"
class="hover:shadow"
@click.stop="handleSelect(attachment)"
>
<IconEye
class="mr-1 mt-1 hidden h-6 w-6 cursor-pointer text-white transition-all hover:text-primary group-hover:block"
@click.stop="handleOpenDetail(attachment)"
/>
<IconCheckboxFill
:class="{
'!text-primary': selectedAttachments.has(attachment),
}"
class="mr-1 mt-1 h-6 w-6 cursor-pointer text-white transition-all hover:text-primary"
/>
</div>
<div class="group relative bg-white">
<div
class="aspect-h-8 aspect-w-10 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
>
<LazyImage
v-if="isImage(attachment.spec.mediaType)"
:key="attachment.metadata.name"
:alt="attachment.spec.displayName"
:src="attachment.status?.permalink"
classes="pointer-events-none object-cover group-hover:opacity-75 transform-gpu"
>
<template #loading>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</div>
</template>
<template #error>
<div
class="flex h-full items-center justify-center object-cover"
>
<span class="text-xs text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</div>
</template>
</LazyImage>
<AttachmentFileTypeIcon
v-else
:file-name="attachment.spec.displayName"
/>
</div>
<p
class="pointer-events-none block truncate px-2 py-1 text-center text-xs font-medium text-gray-700"
>
{{ attachment.spec.displayName }}
</p>
<div
:class="{ '!flex': selectedAttachments.has(attachment) }"
class="absolute left-0 top-0 hidden h-1/3 w-full justify-end bg-gradient-to-b from-gray-300 to-transparent ease-in-out group-hover:flex"
>
<IconEye
class="mr-1 mt-1 hidden h-6 w-6 cursor-pointer text-white transition-all hover:text-primary group-hover:block"
@click.stop="handleOpenDetail(attachment)"
/>
<IconCheckboxFill
:class="{
'!text-primary': selectedAttachments.has(attachment),
}"
class="mr-1 mt-1 h-6 w-6 cursor-pointer text-white transition-all hover:text-primary"
/>
</div>
</div>
</VCard>
</div>
</VCard>
</Transition>
<Transition v-if="viewType === 'list'" appear name="fade">
<ul
class="box-border h-full w-full divide-y divide-gray-100 overflow-hidden rounded-base border"
role="list"
>
<li v-for="attachment in attachments" :key="attachment.metadata.name">
<AttachmentSelectorListItem
:attachment="attachment"
:is-selected="isChecked(attachment)"
@select="handleSelect"
@open-detail="handleOpenDetail"
>
<template #checkbox>
<input
:checked="isChecked(attachment)"
:disabled="isDisabled(attachment)"
type="checkbox"
@click="handleSelect(attachment)"
/>
</template>
</AttachmentSelectorListItem>
</li>
</ul>
</Transition>
</div>
<div class="mt-4">
<VPagination
v-model:page="page"

View File

@ -0,0 +1,100 @@
<script lang="ts" setup>
import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission";
import type { Attachment } from "@halo-dev/api-client";
import {
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import prettyBytes from "pretty-bytes";
import { computed, toRefs } from "vue";
import { useFetchAttachmentPolicy } from "../../../composables/use-attachment-policy";
const { currentUserHasPermission } = usePermission();
const props = withDefaults(
defineProps<{
attachment: Attachment;
isSelected?: boolean;
}>(),
{ isSelected: false }
);
const { attachment } = toRefs(props);
const { policies } = useFetchAttachmentPolicy();
const emit = defineEmits<{
(event: "select", attachment?: Attachment): void;
(event: "open-detail", attachment: Attachment): void;
}>();
const policyDisplayName = computed(() => {
const policy = policies.value?.find(
(p) => p.metadata.name === props.attachment.spec.policyName
);
return policy?.spec.displayName;
});
</script>
<template>
<VEntity :is-selected="isSelected">
<template
v-if="currentUserHasPermission(['system:attachments:manage'])"
#checkbox
>
<slot name="checkbox" />
</template>
<template #start>
<VEntityField>
<template #description>
<div class="h-10 w-10 rounded border bg-white p-1 hover:shadow-sm">
<AttachmentFileTypeIcon
:display-ext="false"
:file-name="attachment.spec.displayName"
:width="8"
:height="8"
/>
</div>
</template>
</VEntityField>
<VEntityField
:title="attachment.spec.displayName"
@click="emit('open-detail', attachment)"
>
<template #description>
<VSpace>
<span class="text-xs text-gray-500">
{{ attachment.spec.mediaType }}
</span>
<span class="text-xs text-gray-500">
{{ prettyBytes(attachment.spec.size || 0) }}
</span>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField :description="policyDisplayName" />
<VEntityField :description="attachment.spec.ownerName" />
<VEntityField v-if="attachment.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(attachment.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
</VEntity>
</template>