feat: support changing attachment display name (#6504)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.19.x

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

支持修改附件的显示名称。

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

<img width="669" alt="image" src="https://github.com/user-attachments/assets/03571048-dfed-4714-ae86-f527ea6f0b08">

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

```release-note
支持修改附件的显示名称。
```
pull/6512/head
Ryan Wang 2024-08-25 23:11:11 +08:00 committed by GitHub
parent c92bbd754a
commit 2b84b41987
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 253 additions and 133 deletions

View File

@ -178,7 +178,6 @@ const handleCheckAllChange = (e: Event) => {
const onDetailModalClose = () => { const onDetailModalClose = () => {
selectedAttachment.value = undefined; selectedAttachment.value = undefined;
nameQuery.value = undefined; nameQuery.value = undefined;
nameQueryAttachment.value = undefined;
detailVisible.value = false; detailVisible.value = false;
handleFetchAttachments(); handleFetchAttachments();
}; };
@ -209,16 +208,15 @@ const viewType = useLocalStorage("attachment-view-type", "list");
const routeQueryAction = useRouteQuery<string | undefined>("action"); const routeQueryAction = useRouteQuery<string | undefined>("action");
onMounted(() => { onMounted(() => {
if (!routeQueryAction.value) {
return;
}
if (routeQueryAction.value === "upload") { if (routeQueryAction.value === "upload") {
uploadVisible.value = true; uploadVisible.value = true;
} }
if (nameQuery.value) {
detailVisible.value = true;
}
}); });
const nameQuery = useRouteQuery<string | undefined>("name"); const nameQuery = useRouteQuery<string | undefined>("name");
const nameQueryAttachment = ref<Attachment>();
watch( watch(
() => selectedAttachment.value, () => selectedAttachment.value,
@ -228,25 +226,11 @@ watch(
} }
} }
); );
onMounted(() => {
if (!nameQuery.value) {
return;
}
coreApiClient.storage.attachment
.getAttachment({
name: nameQuery.value,
})
.then((response) => {
nameQueryAttachment.value = response.data;
detailVisible.value = true;
});
});
</script> </script>
<template> <template>
<AttachmentDetailModal <AttachmentDetailModal
v-if="detailVisible" v-if="detailVisible"
:attachment="selectedAttachment || nameQueryAttachment" :name="selectedAttachment?.metadata.name || nameQuery"
@close="onDetailModalClose" @close="onDetailModalClose"
> >
<template #actions> <template #actions>

View File

@ -2,28 +2,29 @@
import LazyImage from "@/components/image/LazyImage.vue"; import LazyImage from "@/components/image/LazyImage.vue";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { isImage } from "@/utils/image"; import { isImage } from "@/utils/image";
import type { Attachment } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client"; import { coreApiClient } from "@halo-dev/api-client";
import { import {
IconRiPencilFill,
VButton, VButton,
VDescription, VDescription,
VDescriptionItem, VDescriptionItem,
VLoading,
VModal, VModal,
VSpace, VSpace,
} from "@halo-dev/components"; } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query"; import { useQuery } from "@tanstack/vue-query";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import { computed, ref } from "vue"; import { computed, ref, toRefs } from "vue";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
import AttachmentPermalinkList from "./AttachmentPermalinkList.vue"; import AttachmentPermalinkList from "./AttachmentPermalinkList.vue";
import DisplayNameEditForm from "./DisplayNameEditForm.vue";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
attachment: Attachment | undefined; name?: string;
mountToBody?: boolean; mountToBody?: boolean;
}>(), }>(),
{ {
attachment: undefined, name: undefined,
mountToBody: false, mountToBody: false,
} }
); );
@ -32,16 +33,31 @@ const emit = defineEmits<{
(event: "close"): void; (event: "close"): void;
}>(); }>();
const { groups } = useFetchAttachmentGroup(); const { name } = toRefs(props);
const onlyPreview = ref(false); const onlyPreview = ref(false);
const { data: attachment, isLoading } = useQuery({
queryKey: ["core:attachment-by-name", name],
queryFn: async () => {
const { data } = await coreApiClient.storage.attachment.getAttachment({
name: name.value as string,
});
return data;
},
enabled: computed(() => !!name.value),
});
const policyName = computed(() => { const policyName = computed(() => {
return props.attachment?.spec.policyName; return attachment.value?.spec.policyName;
});
const groupName = computed(() => {
return attachment.value?.spec.groupName;
}); });
const { data: policy } = useQuery({ const { data: policy } = useQuery({
queryKey: ["attachment-policy", policyName], queryKey: ["core:attachment-policy-by-name", policyName],
queryFn: async () => { queryFn: async () => {
if (!policyName.value) { if (!policyName.value) {
return; return;
@ -56,10 +72,23 @@ const { data: policy } = useQuery({
enabled: computed(() => !!policyName.value), enabled: computed(() => !!policyName.value),
}); });
const getGroupName = (name: string | undefined) => { const { data: group } = useQuery({
const group = groups.value?.find((group) => group.metadata.name === name); queryKey: ["core:attachment-group-by-name", groupName],
return group?.spec.displayName || name; queryFn: async () => {
}; if (!groupName.value) {
return;
}
const { data } = await coreApiClient.storage.group.getGroup({
name: groupName.value,
});
return data;
},
enabled: computed(() => !!groupName.value),
});
const showDisplayNameForm = ref(false);
</script> </script>
<template> <template>
<VModal <VModal
@ -78,108 +107,129 @@ const getGroupName = (name: string | undefined) => {
<template #actions> <template #actions>
<slot name="actions"></slot> <slot name="actions"></slot>
</template> </template>
<div class="overflow-hidden bg-white"> <div>
<div <VLoading v-if="isLoading" />
v-if="onlyPreview && isImage(attachment?.spec.mediaType)" <div v-else class="overflow-hidden bg-white">
class="flex justify-center p-4" <div
> v-if="onlyPreview && isImage(attachment?.spec.mediaType)"
<img class="flex justify-center p-4"
v-tooltip.bottom=" >
$t('core.attachment.detail_modal.preview.click_to_exit') <img
" v-tooltip.bottom="
:alt="attachment?.spec.displayName" $t('core.attachment.detail_modal.preview.click_to_exit')
:src="attachment?.status?.permalink"
class="w-auto transform-gpu cursor-pointer rounded"
@click="onlyPreview = !onlyPreview"
/>
</div>
<div v-else>
<VDescription>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.preview')"
>
<div
v-if="isImage(attachment?.spec.mediaType)"
@click="onlyPreview = !onlyPreview"
>
<LazyImage
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
classes="max-w-full cursor-pointer rounded sm:max-w-[50%]"
>
<template #loading>
<span class="text-gray-400">
{{ $t("core.common.status.loading") }}...
</span>
</template>
<template #error>
<span class="text-red-400">
{{ $t("core.common.status.loading_error") }}
</span>
</template>
</LazyImage>
</div>
<div v-else-if="attachment?.spec.mediaType?.startsWith('video/')">
<video
:src="attachment.status?.permalink"
controls
class="max-w-full rounded sm:max-w-[50%]"
>
{{
$t("core.attachment.detail_modal.preview.video_not_support")
}}
</video>
</div>
<div v-else-if="attachment?.spec.mediaType?.startsWith('audio/')">
<audio :src="attachment.status?.permalink" controls>
{{
$t("core.attachment.detail_modal.preview.audio_not_support")
}}
</audio>
</div>
<span v-else>
{{ $t("core.attachment.detail_modal.preview.not_support") }}
</span>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.storage_policy')"
:content="policy?.spec.displayName"
></VDescriptionItem>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.group')"
:content="
getGroupName(attachment?.spec.groupName) ||
$t('core.attachment.common.text.ungrouped')
" "
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
class="w-auto transform-gpu cursor-pointer rounded"
@click="onlyPreview = !onlyPreview"
/> />
<VDescriptionItem </div>
:label="$t('core.attachment.detail_modal.fields.display_name')" <div v-else>
:content="attachment?.spec.displayName" <VDescription>
/> <VDescriptionItem
<VDescriptionItem :label="$t('core.attachment.detail_modal.fields.preview')"
:label="$t('core.attachment.detail_modal.fields.media_type')" >
:content="attachment?.spec.mediaType" <div
/> v-if="isImage(attachment?.spec.mediaType)"
<VDescriptionItem @click="onlyPreview = !onlyPreview"
:label="$t('core.attachment.detail_modal.fields.size')" >
:content="prettyBytes(attachment?.spec.size || 0)" <LazyImage
/> :alt="attachment?.spec.displayName"
<VDescriptionItem :src="attachment?.status?.permalink"
:label="$t('core.attachment.detail_modal.fields.owner')" classes="max-w-full cursor-pointer rounded sm:max-w-[50%]"
:content="attachment?.spec.ownerName" >
/> <template #loading>
<VDescriptionItem <span class="text-gray-400">
:label="$t('core.attachment.detail_modal.fields.creation_time')" {{ $t("core.common.status.loading") }}...
:content="formatDatetime(attachment?.metadata.creationTimestamp)" </span>
/> </template>
<VDescriptionItem <template #error>
:label="$t('core.attachment.detail_modal.fields.permalink')" <span class="text-red-400">
> {{ $t("core.common.status.loading_error") }}
<AttachmentPermalinkList :attachment="attachment" /> </span>
</VDescriptionItem> </template>
</VDescription> </LazyImage>
</div>
<div v-else-if="attachment?.spec.mediaType?.startsWith('video/')">
<video
:src="attachment.status?.permalink"
controls
class="max-w-full rounded sm:max-w-[50%]"
>
{{
$t("core.attachment.detail_modal.preview.video_not_support")
}}
</video>
</div>
<div v-else-if="attachment?.spec.mediaType?.startsWith('audio/')">
<audio :src="attachment.status?.permalink" controls>
{{
$t("core.attachment.detail_modal.preview.audio_not_support")
}}
</audio>
</div>
<span v-else>
{{ $t("core.attachment.detail_modal.preview.not_support") }}
</span>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.storage_policy')"
:content="policy?.spec.displayName"
>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.group')"
:content="
group?.spec.displayName ||
$t('core.attachment.common.text.ungrouped')
"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.display_name')"
>
<DisplayNameEditForm
v-if="showDisplayNameForm && attachment"
:attachment="attachment"
@close="showDisplayNameForm = false"
/>
<div v-else class="flex items-center gap-3">
<span>
{{ attachment?.spec.displayName }}
</span>
<HasPermission :permissions="['system:attachments:manage']">
<IconRiPencilFill
class="cursor-pointer text-sm text-gray-600 hover:text-gray-900"
@click="showDisplayNameForm = true"
/>
</HasPermission>
</div>
</VDescriptionItem>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.media_type')"
:content="attachment?.spec.mediaType"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.size')"
:content="prettyBytes(attachment?.spec.size || 0)"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.owner')"
:content="attachment?.spec.ownerName"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.creation_time')"
:content="formatDatetime(attachment?.metadata.creationTimestamp)"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.permalink')"
>
<AttachmentPermalinkList :attachment="attachment" />
</VDescriptionItem>
</VDescription>
</div>
</div> </div>
</div> </div>
<template #footer> <template #footer>
<VSpace> <VSpace>
<VButton type="default" @click="emit('close')"> <VButton type="default" @click="emit('close')">

View File

@ -0,0 +1,81 @@
<script setup lang="ts">
import { setFocus } from "@/formkit/utils/focus";
import { coreApiClient, type Attachment } from "@halo-dev/api-client";
import { Toast, VButton, VSpace } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import { onMounted, ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const queryClient = useQueryClient();
const props = withDefaults(defineProps<{ attachment: Attachment }>(), {});
const emit = defineEmits<{
(event: "close"): void;
}>();
onMounted(() => {
setFocus("displayName");
});
const isSubmitting = ref(false);
async function onSubmit({ displayName }: { displayName: string }) {
try {
isSubmitting.value = true;
await coreApiClient.storage.attachment.patchAttachment({
name: props.attachment.metadata.name,
jsonPatchInner: [
{
op: "add",
path: "/spec/displayName",
value: displayName,
},
],
});
Toast.success(t("core.common.toast.save_success"));
queryClient.invalidateQueries({
queryKey: ["core:attachment-by-name", props.attachment.metadata.name],
});
emit("close");
} catch (error) {
console.error("Failed to update displayName", error);
Toast.error(t("core.common.toast.save_failed_and_retry"));
} finally {
isSubmitting.value = false;
}
}
</script>
<template>
<FormKit
id="attachment-display-name-form"
type="form"
name="attachment-display-name-form"
@submit="onSubmit"
>
<FormKit
id="displayName"
:model-value="attachment.spec.displayName"
type="text"
name="displayName"
validation="required:trim"
:classes="{ outer: '!pb-0' }"
></FormKit>
</FormKit>
<VSpace class="mt-4">
<VButton
type="secondary"
@click="$formkit.submit('attachment-display-name-form')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton @click="emit('close')">
{{ $t("core.common.buttons.cancel") }}
</VButton>
</VSpace>
</template>

View File

@ -137,6 +137,11 @@ function onUploadModalClose() {
uploadVisible.value = false; uploadVisible.value = false;
} }
function onDetailModalClose() {
detailVisible.value = false;
selectedAttachment.value = undefined;
}
function onGroupSelect(group: Group) { function onGroupSelect(group: Group) {
selectedGroup.value = group.metadata.name; selectedGroup.value = group.metadata.name;
handleReset(); handleReset();
@ -404,10 +409,10 @@ const viewType = useLocalStorage("attachment-selector-view-type", "grid");
</div> </div>
<AttachmentUploadModal v-if="uploadVisible" @close="onUploadModalClose" /> <AttachmentUploadModal v-if="uploadVisible" @close="onUploadModalClose" />
<AttachmentDetailModal <AttachmentDetailModal
v-model:visible="detailVisible" v-if="detailVisible"
:mount-to-body="true" :mount-to-body="true"
:attachment="selectedAttachment" :name="selectedAttachment?.metadata.name"
@close="selectedAttachment = undefined" @close="onDetailModalClose"
> >
<template #actions> <template #actions>
<span <span