mirror of https://github.com/halo-dev/halo
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
parent
487b0d343c
commit
c92bbd754a
|
@ -9,19 +9,29 @@ import {
|
||||||
IconCheckboxCircle,
|
IconCheckboxCircle,
|
||||||
IconCheckboxFill,
|
IconCheckboxFill,
|
||||||
IconEye,
|
IconEye,
|
||||||
|
IconGrid,
|
||||||
|
IconList,
|
||||||
|
IconRefreshLine,
|
||||||
IconUpload,
|
IconUpload,
|
||||||
VButton,
|
VButton,
|
||||||
VCard,
|
VCard,
|
||||||
VEmpty,
|
VEmpty,
|
||||||
|
VLoading,
|
||||||
VPagination,
|
VPagination,
|
||||||
VSpace,
|
VSpace,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import type { AttachmentLike } from "@halo-dev/console-shared";
|
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 { useAttachmentControl } from "../../composables/use-attachment";
|
||||||
|
import { useFetchAttachmentPolicy } from "../../composables/use-attachment-policy";
|
||||||
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
|
import AttachmentDetailModal from "../AttachmentDetailModal.vue";
|
||||||
import AttachmentGroupList from "../AttachmentGroupList.vue";
|
import AttachmentGroupList from "../AttachmentGroupList.vue";
|
||||||
import AttachmentUploadModal from "../AttachmentUploadModal.vue";
|
import AttachmentUploadModal from "../AttachmentUploadModal.vue";
|
||||||
|
import AttachmentSelectorListItem from "./components/AttachmentSelectorListItem.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -43,7 +53,12 @@ const emit = defineEmits<{
|
||||||
(event: "change-provider", providerId: string): void;
|
(event: "change-provider", providerId: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { policies } = useFetchAttachmentPolicy();
|
||||||
|
|
||||||
|
const keyword = ref("");
|
||||||
const selectedGroup = ref();
|
const selectedGroup = ref();
|
||||||
|
const selectedPolicy = ref();
|
||||||
|
const selectedSort = ref();
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const size = ref(20);
|
const size = ref(20);
|
||||||
|
|
||||||
|
@ -59,15 +74,35 @@ const {
|
||||||
handleSelectNext,
|
handleSelectNext,
|
||||||
handleReset,
|
handleReset,
|
||||||
isChecked,
|
isChecked,
|
||||||
|
isFetching,
|
||||||
} = useAttachmentControl({
|
} = useAttachmentControl({
|
||||||
groupName: selectedGroup,
|
groupName: selectedGroup,
|
||||||
|
policyName: selectedPolicy,
|
||||||
|
sort: selectedSort,
|
||||||
accepts: computed(() => {
|
accepts: computed(() => {
|
||||||
return props.accepts;
|
return props.accepts;
|
||||||
}),
|
}),
|
||||||
page,
|
page,
|
||||||
size,
|
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 uploadVisible = ref(false);
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|
||||||
|
@ -106,9 +141,119 @@ function onGroupSelect(group: Group) {
|
||||||
selectedGroup.value = group.metadata.name;
|
selectedGroup.value = group.metadata.name;
|
||||||
handleReset();
|
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>
|
</script>
|
||||||
<template>
|
<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" />
|
<AttachmentGroupList readonly @select="onGroupSelect" />
|
||||||
|
|
||||||
<div v-if="attachments?.length" class="mb-5">
|
<div v-if="attachments?.length" class="mb-5">
|
||||||
<VButton @click="uploadVisible = true">
|
<VButton @click="uploadVisible = true">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
|
@ -117,8 +262,11 @@ function onGroupSelect(group: Group) {
|
||||||
{{ $t("core.common.buttons.upload") }}
|
{{ $t("core.common.buttons.upload") }}
|
||||||
</VButton>
|
</VButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VLoading v-if="isLoading" />
|
||||||
|
|
||||||
<VEmpty
|
<VEmpty
|
||||||
v-if="!attachments?.length && !isLoading"
|
v-else-if="!attachments?.length"
|
||||||
:message="$t('core.attachment.empty.message')"
|
:message="$t('core.attachment.empty.message')"
|
||||||
:title="$t('core.attachment.empty.title')"
|
:title="$t('core.attachment.empty.title')"
|
||||||
>
|
>
|
||||||
|
@ -136,78 +284,111 @@ function onGroupSelect(group: Group) {
|
||||||
</VSpace>
|
</VSpace>
|
||||||
</template>
|
</template>
|
||||||
</VEmpty>
|
</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
|
<div v-else>
|
||||||
:class="{ '!flex': selectedAttachments.has(attachment) }"
|
<Transition v-if="viewType === 'grid'" appear name="fade">
|
||||||
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
|
||||||
|
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
|
<div class="group relative bg-white">
|
||||||
class="mr-1 mt-1 hidden h-6 w-6 cursor-pointer text-white transition-all hover:text-primary group-hover:block"
|
<div
|
||||||
@click.stop="handleOpenDetail(attachment)"
|
class="aspect-h-8 aspect-w-10 block h-full w-full cursor-pointer overflow-hidden bg-gray-100"
|
||||||
/>
|
>
|
||||||
<IconCheckboxFill
|
<LazyImage
|
||||||
:class="{
|
v-if="isImage(attachment.spec.mediaType)"
|
||||||
'!text-primary': selectedAttachments.has(attachment),
|
:key="attachment.metadata.name"
|
||||||
}"
|
:alt="attachment.spec.displayName"
|
||||||
class="mr-1 mt-1 h-6 w-6 cursor-pointer text-white transition-all hover:text-primary"
|
:src="attachment.status?.permalink"
|
||||||
/>
|
classes="pointer-events-none object-cover group-hover:opacity-75 transform-gpu"
|
||||||
</div>
|
>
|
||||||
|
<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>
|
</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>
|
||||||
|
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<VPagination
|
<VPagination
|
||||||
v-model:page="page"
|
v-model:page="page"
|
||||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue