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 = () => {
selectedAttachment.value = undefined;
nameQuery.value = undefined;
nameQueryAttachment.value = undefined;
detailVisible.value = false;
handleFetchAttachments();
};
@ -209,16 +208,15 @@ const viewType = useLocalStorage("attachment-view-type", "list");
const routeQueryAction = useRouteQuery<string | undefined>("action");
onMounted(() => {
if (!routeQueryAction.value) {
return;
}
if (routeQueryAction.value === "upload") {
uploadVisible.value = true;
}
if (nameQuery.value) {
detailVisible.value = true;
}
});
const nameQuery = useRouteQuery<string | undefined>("name");
const nameQueryAttachment = ref<Attachment>();
watch(
() => 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>
<template>
<AttachmentDetailModal
v-if="detailVisible"
:attachment="selectedAttachment || nameQueryAttachment"
:name="selectedAttachment?.metadata.name || nameQuery"
@close="onDetailModalClose"
>
<template #actions>

View File

@ -2,28 +2,29 @@
import LazyImage from "@/components/image/LazyImage.vue";
import { formatDatetime } from "@/utils/date";
import { isImage } from "@/utils/image";
import type { Attachment } from "@halo-dev/api-client";
import { coreApiClient } from "@halo-dev/api-client";
import {
IconRiPencilFill,
VButton,
VDescription,
VDescriptionItem,
VLoading,
VModal,
VSpace,
} from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import prettyBytes from "pretty-bytes";
import { computed, ref } from "vue";
import { useFetchAttachmentGroup } from "../composables/use-attachment-group";
import { computed, ref, toRefs } from "vue";
import AttachmentPermalinkList from "./AttachmentPermalinkList.vue";
import DisplayNameEditForm from "./DisplayNameEditForm.vue";
const props = withDefaults(
defineProps<{
attachment: Attachment | undefined;
name?: string;
mountToBody?: boolean;
}>(),
{
attachment: undefined,
name: undefined,
mountToBody: false,
}
);
@ -32,16 +33,31 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const { groups } = useFetchAttachmentGroup();
const { name } = toRefs(props);
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(() => {
return props.attachment?.spec.policyName;
return attachment.value?.spec.policyName;
});
const groupName = computed(() => {
return attachment.value?.spec.groupName;
});
const { data: policy } = useQuery({
queryKey: ["attachment-policy", policyName],
queryKey: ["core:attachment-policy-by-name", policyName],
queryFn: async () => {
if (!policyName.value) {
return;
@ -56,10 +72,23 @@ const { data: policy } = useQuery({
enabled: computed(() => !!policyName.value),
});
const getGroupName = (name: string | undefined) => {
const group = groups.value?.find((group) => group.metadata.name === name);
return group?.spec.displayName || name;
};
const { data: group } = useQuery({
queryKey: ["core:attachment-group-by-name", groupName],
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>
<template>
<VModal
@ -78,108 +107,129 @@ const getGroupName = (name: string | undefined) => {
<template #actions>
<slot name="actions"></slot>
</template>
<div class="overflow-hidden bg-white">
<div
v-if="onlyPreview && isImage(attachment?.spec.mediaType)"
class="flex justify-center p-4"
>
<img
v-tooltip.bottom="
$t('core.attachment.detail_modal.preview.click_to_exit')
"
:alt="attachment?.spec.displayName"
: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')
<div>
<VLoading v-if="isLoading" />
<div v-else class="overflow-hidden bg-white">
<div
v-if="onlyPreview && isImage(attachment?.spec.mediaType)"
class="flex justify-center p-4"
>
<img
v-tooltip.bottom="
$t('core.attachment.detail_modal.preview.click_to_exit')
"
:alt="attachment?.spec.displayName"
:src="attachment?.status?.permalink"
class="w-auto transform-gpu cursor-pointer rounded"
@click="onlyPreview = !onlyPreview"
/>
<VDescriptionItem
:label="$t('core.attachment.detail_modal.fields.display_name')"
:content="attachment?.spec.displayName"
/>
<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 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="
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>
<template #footer>
<VSpace>
<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;
}
function onDetailModalClose() {
detailVisible.value = false;
selectedAttachment.value = undefined;
}
function onGroupSelect(group: Group) {
selectedGroup.value = group.metadata.name;
handleReset();
@ -404,10 +409,10 @@ const viewType = useLocalStorage("attachment-selector-view-type", "grid");
</div>
<AttachmentUploadModal v-if="uploadVisible" @close="onUploadModalClose" />
<AttachmentDetailModal
v-model:visible="detailVisible"
v-if="detailVisible"
:mount-to-body="true"
:attachment="selectedAttachment"
@close="selectedAttachment = undefined"
:name="selectedAttachment?.metadata.name"
@close="onDetailModalClose"
>
<template #actions>
<span