mirror of https://github.com/halo-dev/halo
feat: add thumbnail records modal (#7630)
#### What type of PR is this? /area ui /kind feature /milestone 2.21.x #### What this PR does / why we need it: Add thumbnail records viewing feature. The main goal is to keep track of failed thumbnail generations, making it easier to check if thumbnails were generated correctly and retry if needed. <img width="1009" height="859" alt="image" src="https://github.com/user-attachments/assets/d968e416-0b88-45bf-a554-a0c1abcf97a0" /> <img width="1014" height="952" alt="image" src="https://github.com/user-attachments/assets/c5d1a11b-7acc-4ab5-a2da-cf9467cb6b70" /> #### Which issue(s) this PR fixes: Fixes # #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 支持查看附件缩略图记录 ```pull/7640/head
parent
e737d8b0f7
commit
395399f078
|
@ -651,6 +651,16 @@ class SchemeInitializer implements SmartLifecycle {
|
|||
.setIndexFunc(simpleAttribute(LocalThumbnail.class,
|
||||
thumbnail -> thumbnail.getSpec().getThumbSignature())
|
||||
));
|
||||
indexSpec.add(new IndexSpec()
|
||||
.setName("status.phase")
|
||||
.setIndexFunc(
|
||||
simpleAttribute(LocalThumbnail.class,
|
||||
thumbnail -> Optional.of(thumbnail.getStatus())
|
||||
.map(LocalThumbnail.Status::getPhase)
|
||||
.map(LocalThumbnail.Phase::name)
|
||||
.orElse(null))
|
||||
)
|
||||
);
|
||||
});
|
||||
// metrics.halo.run
|
||||
schemeManager.register(Counter.class);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
|
||||
import LazyImage from "@/components/image/LazyImage.vue";
|
||||
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||
import LazyVideo from "@/components/video/LazyVideo.vue";
|
||||
import { isImage } from "@/utils/image";
|
||||
import type { Attachment, Group } from "@halo-dev/api-client";
|
||||
|
@ -32,12 +33,14 @@ import { useRouteQuery } from "@vueuse/router";
|
|||
import type { Ref } from "vue";
|
||||
import { computed, onMounted, provide, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import RiMultiImageLine from "~icons/ri/multi-image-line";
|
||||
import AttachmentDetailModal from "./components/AttachmentDetailModal.vue";
|
||||
import AttachmentError from "./components/AttachmentError.vue";
|
||||
import AttachmentGroupList from "./components/AttachmentGroupList.vue";
|
||||
import AttachmentListItem from "./components/AttachmentListItem.vue";
|
||||
import AttachmentLoading from "./components/AttachmentLoading.vue";
|
||||
import AttachmentPoliciesModal from "./components/AttachmentPoliciesModal.vue";
|
||||
import AttachmentThumbnailsModal from "./components/AttachmentThumbnailsModal.vue";
|
||||
import AttachmentUploadModal from "./components/AttachmentUploadModal.vue";
|
||||
import { useAttachmentControl } from "./composables/use-attachment";
|
||||
import { useFetchAttachmentGroup } from "./composables/use-attachment-group";
|
||||
|
@ -227,6 +230,9 @@ watch(
|
|||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Thumbnails modal
|
||||
const thumbnailsVisible = ref(false);
|
||||
</script>
|
||||
<template>
|
||||
<AttachmentDetailModal
|
||||
|
@ -248,11 +254,23 @@ watch(
|
|||
v-if="policyVisible"
|
||||
@close="policyVisible = false"
|
||||
/>
|
||||
<AttachmentThumbnailsModal
|
||||
v-if="thumbnailsVisible"
|
||||
@close="thumbnailsVisible = false"
|
||||
/>
|
||||
<VPageHeader :title="$t('core.attachment.title')">
|
||||
<template #icon>
|
||||
<IconFolder />
|
||||
</template>
|
||||
<template #actions>
|
||||
<HasPermission :permissions="['*']">
|
||||
<VButton size="sm" @click="thumbnailsVisible = true">
|
||||
<template #icon>
|
||||
<RiMultiImageLine />
|
||||
</template>
|
||||
{{ $t("core.attachment.actions.thumbnails") }}
|
||||
</VButton>
|
||||
</HasPermission>
|
||||
<VButton
|
||||
v-permission="['system:attachments:manage']"
|
||||
size="sm"
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import AttachmentPermalinkList from "@/components/attachment/AttachmentPermalinkList.vue";
|
||||
import LazyImage from "@/components/image/LazyImage.vue";
|
||||
import HasPermission from "@/components/permission/HasPermission.vue";
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import { isImage } from "@/utils/image";
|
||||
import { coreApiClient } from "@halo-dev/api-client";
|
||||
|
@ -16,6 +17,7 @@ import {
|
|||
import { useQuery } from "@tanstack/vue-query";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { computed, ref, toRefs, useTemplateRef } from "vue";
|
||||
import AttachmentSingleThumbnailList from "./AttachmentSingleThumbnailList.vue";
|
||||
import DisplayNameEditForm from "./DisplayNameEditForm.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
|
@ -230,6 +232,16 @@ const showDisplayNameForm = ref(false);
|
|||
>
|
||||
<AttachmentPermalinkList :attachment="attachment" />
|
||||
</VDescriptionItem>
|
||||
<HasPermission
|
||||
v-if="!!attachment?.status?.thumbnails"
|
||||
:permissions="['*']"
|
||||
>
|
||||
<VDescriptionItem
|
||||
:label="$t('core.attachment.detail_modal.fields.thumbnails')"
|
||||
>
|
||||
<AttachmentSingleThumbnailList :attachment="attachment" />
|
||||
</VDescriptionItem>
|
||||
</HasPermission>
|
||||
</VDescription>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,87 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
LocalThumbnailStatusPhaseEnum,
|
||||
type LocalThumbnail,
|
||||
} from "@halo-dev/api-client";
|
||||
import { VButton } from "@halo-dev/components";
|
||||
import { toRefs } from "vue";
|
||||
import { useThumbnailControl } from "../composables/use-thumbnail-control";
|
||||
import {
|
||||
SIZE_MAP,
|
||||
useThumbnailDetail,
|
||||
} from "../composables/use-thumbnail-detail";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
thumbnail: LocalThumbnail;
|
||||
}>(),
|
||||
{}
|
||||
);
|
||||
|
||||
const { thumbnail } = toRefs(props);
|
||||
|
||||
const { phase } = useThumbnailDetail(thumbnail);
|
||||
const { handleRetry } = useThumbnailControl(thumbnail);
|
||||
</script>
|
||||
<template>
|
||||
<li>
|
||||
<div
|
||||
class="flex w-full cursor-pointer items-center justify-between space-x-3 rounded border p-3 hover:border-primary"
|
||||
>
|
||||
<a
|
||||
:href="thumbnail.spec.thumbnailUri"
|
||||
target="_blank"
|
||||
class="block flex-none"
|
||||
>
|
||||
<img
|
||||
v-if="
|
||||
thumbnail.status.phase === LocalThumbnailStatusPhaseEnum.Succeeded
|
||||
"
|
||||
:src="thumbnail.spec.thumbnailUri"
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 items-center justify-center rounded-md border"
|
||||
>
|
||||
<component
|
||||
:is="phase.icon"
|
||||
class="h-4.5 w-4.5"
|
||||
:class="phase.color"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex min-w-0 flex-1 flex-col space-y-2 text-xs text-gray-900">
|
||||
<span class="font-semibold">
|
||||
{{ SIZE_MAP[thumbnail.spec.size] }}
|
||||
</span>
|
||||
<a
|
||||
:href="thumbnail.spec.thumbnailUri"
|
||||
target="_blank"
|
||||
class="line-clamp-1 hover:text-gray-600"
|
||||
>
|
||||
{{ thumbnail.spec.thumbnailUri }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex flex-none items-center gap-2">
|
||||
<component
|
||||
:is="phase.icon"
|
||||
v-tooltip="$t(phase.label)"
|
||||
class="h-4.5 w-4.5"
|
||||
:class="phase.color"
|
||||
/>
|
||||
<VButton
|
||||
v-if="
|
||||
thumbnail.status.phase !== LocalThumbnailStatusPhaseEnum.Succeeded
|
||||
"
|
||||
size="sm"
|
||||
@click="handleRetry"
|
||||
>
|
||||
{{ $t("core.common.buttons.retry") }}
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
|
@ -0,0 +1,75 @@
|
|||
<script lang="ts" setup>
|
||||
import { storageAnnotations } from "@/constants/annotations";
|
||||
import {
|
||||
coreApiClient,
|
||||
LocalThumbnailSpecSizeEnum,
|
||||
LocalThumbnailStatusPhaseEnum,
|
||||
type Attachment,
|
||||
} from "@halo-dev/api-client";
|
||||
import { VLoading } from "@halo-dev/components";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import sha256 from "crypto-js/sha256";
|
||||
import { computed, toRefs } from "vue";
|
||||
import AttachmentSingleThumbnailItem from "./AttachmentSingleThumbnailItem.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
attachment?: Attachment;
|
||||
}>(),
|
||||
{
|
||||
attachment: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const { attachment } = toRefs(props);
|
||||
|
||||
const imageSignature = computed(() => {
|
||||
const uri = attachment.value?.metadata.annotations?.[storageAnnotations.URI];
|
||||
if (!uri) {
|
||||
return undefined;
|
||||
}
|
||||
return sha256(uri);
|
||||
});
|
||||
|
||||
const sizeOrder: Record<LocalThumbnailSpecSizeEnum, number> = {
|
||||
XL: 4,
|
||||
L: 3,
|
||||
M: 2,
|
||||
S: 1,
|
||||
};
|
||||
|
||||
const { data: thumbnails, isLoading } = useQuery({
|
||||
queryKey: ["core:attachments:thumbnails", attachment, imageSignature],
|
||||
queryFn: async () => {
|
||||
const { data } =
|
||||
await coreApiClient.storage.localThumbnail.listLocalThumbnail({
|
||||
fieldSelector: [`spec.imageSignature=${imageSignature.value}`],
|
||||
});
|
||||
|
||||
return data.items.sort((a, b) => {
|
||||
const aSize = a.spec.size as keyof typeof sizeOrder;
|
||||
const bSize = b.spec.size as keyof typeof sizeOrder;
|
||||
return (sizeOrder[bSize] || 0) - (sizeOrder[aSize] || 0);
|
||||
});
|
||||
},
|
||||
enabled: computed(() => !!imageSignature.value),
|
||||
refetchInterval: (data) => {
|
||||
const hasAbnormalData = data?.some(
|
||||
(thumbnail) =>
|
||||
thumbnail.status.phase !== LocalThumbnailStatusPhaseEnum.Succeeded
|
||||
);
|
||||
|
||||
return hasAbnormalData ? 1000 : false;
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<VLoading v-if="isLoading" />
|
||||
<ul v-else class="flex flex-col space-y-2">
|
||||
<AttachmentSingleThumbnailItem
|
||||
v-for="thumbnail in thumbnails"
|
||||
:key="thumbnail.metadata.name"
|
||||
:thumbnail="thumbnail"
|
||||
/>
|
||||
</ul>
|
||||
</template>
|
|
@ -0,0 +1,96 @@
|
|||
<script lang="ts" setup>
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import {
|
||||
LocalThumbnailStatusPhaseEnum,
|
||||
type LocalThumbnail,
|
||||
} from "@halo-dev/api-client";
|
||||
import { VButton, VEntity, VEntityField } from "@halo-dev/components";
|
||||
import { toRefs } from "vue";
|
||||
import { useThumbnailControl } from "../composables/use-thumbnail-control";
|
||||
import {
|
||||
SIZE_MAP,
|
||||
useThumbnailDetail,
|
||||
} from "../composables/use-thumbnail-detail";
|
||||
|
||||
const props = defineProps<{
|
||||
thumbnail: LocalThumbnail;
|
||||
}>();
|
||||
|
||||
const { thumbnail } = toRefs(props);
|
||||
|
||||
const { phase } = useThumbnailDetail(thumbnail);
|
||||
const { handleRetry } = useThumbnailControl(thumbnail);
|
||||
</script>
|
||||
<template>
|
||||
<VEntity>
|
||||
<template #start>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<a
|
||||
:href="thumbnail.spec.thumbnailUri"
|
||||
target="_blank"
|
||||
class="block flex-none"
|
||||
>
|
||||
<img
|
||||
v-if="
|
||||
thumbnail.status.phase ===
|
||||
LocalThumbnailStatusPhaseEnum.Succeeded
|
||||
"
|
||||
:src="thumbnail.spec.thumbnailUri"
|
||||
alt=""
|
||||
class="h-10 w-10 rounded-md object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="flex h-10 w-10 items-center justify-center rounded-md border"
|
||||
>
|
||||
<component
|
||||
:is="phase.icon"
|
||||
class="h-4.5 w-4.5"
|
||||
:class="phase.color"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField :title="SIZE_MAP[thumbnail.spec.size]">
|
||||
<template #description>
|
||||
<a
|
||||
:href="thumbnail.spec.thumbnailUri"
|
||||
target="_blank"
|
||||
class="truncate text-xs text-gray-500 hover:text-gray-900"
|
||||
>
|
||||
{{ thumbnail.spec.thumbnailUri }}
|
||||
</a>
|
||||
</template>
|
||||
</VEntityField>
|
||||
</template>
|
||||
<template #end>
|
||||
<VEntityField
|
||||
:description="formatDatetime(thumbnail.metadata.creationTimestamp)"
|
||||
/>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<component
|
||||
:is="phase.icon"
|
||||
v-tooltip="$t(phase.label)"
|
||||
class="h-4.5 w-4.5"
|
||||
:class="phase.color"
|
||||
/>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField
|
||||
v-if="
|
||||
thumbnail.status.phase !== LocalThumbnailStatusPhaseEnum.Succeeded
|
||||
"
|
||||
>
|
||||
<template #description>
|
||||
<VButton size="sm" @click="handleRetry">
|
||||
{{ $t("core.common.buttons.retry") }}
|
||||
</VButton>
|
||||
</template>
|
||||
</VEntityField>
|
||||
</template>
|
||||
</VEntity>
|
||||
</template>
|
|
@ -0,0 +1,216 @@
|
|||
<script lang="ts" setup>
|
||||
import FilterDropdown from "@/components/filter/FilterDropdown.vue";
|
||||
import { storageAnnotations } from "@/constants/annotations";
|
||||
import {
|
||||
coreApiClient,
|
||||
LocalThumbnailStatusPhaseEnum,
|
||||
} from "@halo-dev/api-client";
|
||||
import {
|
||||
Toast,
|
||||
VButton,
|
||||
VEmpty,
|
||||
VEntityContainer,
|
||||
VLoading,
|
||||
VModal,
|
||||
VPagination,
|
||||
VSpace,
|
||||
} from "@halo-dev/components";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { chunk } from "lodash-es";
|
||||
import { ref, useTemplateRef, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AttachmentThumbnailItem from "./AttachmentThumbnailItem.vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof VModal> | null>("modal");
|
||||
|
||||
const page = ref(1);
|
||||
const size = ref(20);
|
||||
const selectedStatus = ref<LocalThumbnailStatusPhaseEnum | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
watch(
|
||||
() => selectedStatus.value,
|
||||
() => {
|
||||
page.value = 1;
|
||||
}
|
||||
);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const {
|
||||
data: thumbnails,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["core:attachments:thumbnails", page, size, selectedStatus],
|
||||
queryFn: async () => {
|
||||
const fieldSelector: string[] = [];
|
||||
if (selectedStatus.value) {
|
||||
fieldSelector.push(`status.phase=${selectedStatus.value}`);
|
||||
}
|
||||
|
||||
const { data } =
|
||||
await coreApiClient.storage.localThumbnail.listLocalThumbnail({
|
||||
page: page.value,
|
||||
size: size.value,
|
||||
fieldSelector,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
refetchInterval: (data) => {
|
||||
const hasAbnormalData = data?.items?.some(
|
||||
(thumbnail) =>
|
||||
thumbnail.status.phase !== LocalThumbnailStatusPhaseEnum.Succeeded
|
||||
);
|
||||
|
||||
return hasAbnormalData ? 1000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
async function handleRetryAllFailed() {
|
||||
try {
|
||||
const { data } =
|
||||
await coreApiClient.storage.localThumbnail.listLocalThumbnail({
|
||||
fieldSelector: [`status.phase=${LocalThumbnailStatusPhaseEnum.Failed}`],
|
||||
});
|
||||
const failedThumbnails = data.items;
|
||||
|
||||
if (!failedThumbnails.length) {
|
||||
Toast.info(
|
||||
t(
|
||||
"core.attachment.thumbnails_modal.operations.retry_all_failed.tips_empty"
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkedFailedThumbnails = chunk(failedThumbnails, 5);
|
||||
|
||||
for (const chunk of chunkedFailedThumbnails) {
|
||||
await Promise.all(
|
||||
chunk.map(async (thumbnail) => {
|
||||
await coreApiClient.storage.localThumbnail.patchLocalThumbnail({
|
||||
name: thumbnail.metadata.name,
|
||||
jsonPatchInner: [
|
||||
{
|
||||
op: "add",
|
||||
path: "/status/phase",
|
||||
value: LocalThumbnailStatusPhaseEnum.Pending,
|
||||
},
|
||||
{
|
||||
op: "add",
|
||||
path: "/metadata/annotations",
|
||||
value: {
|
||||
[storageAnnotations.RETRY_TIMESTAMP]: Date.now().toString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
Toast.success(
|
||||
t(
|
||||
"core.attachment.thumbnails_modal.operations.retry_all_failed.tips_success"
|
||||
)
|
||||
);
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error("Failed to retry all failed thumbnails", error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<VModal
|
||||
ref="modal"
|
||||
:centered="false"
|
||||
:width="1000"
|
||||
:title="$t('core.attachment.thumbnails_modal.title')"
|
||||
:layer-closable="true"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<VSpace spacing="lg" class="flex-wrap">
|
||||
<FilterDropdown
|
||||
v-model="selectedStatus"
|
||||
:items="[
|
||||
{
|
||||
label: $t('core.common.filters.item_labels.all'),
|
||||
value: undefined,
|
||||
},
|
||||
{
|
||||
label: $t('core.attachment.thumbnails.phase.pending'),
|
||||
value: LocalThumbnailStatusPhaseEnum.Pending,
|
||||
},
|
||||
{
|
||||
label: $t('core.attachment.thumbnails.phase.succeeded'),
|
||||
value: LocalThumbnailStatusPhaseEnum.Succeeded,
|
||||
},
|
||||
{
|
||||
label: $t('core.attachment.thumbnails.phase.failed'),
|
||||
value: LocalThumbnailStatusPhaseEnum.Failed,
|
||||
},
|
||||
]"
|
||||
:label="$t('core.common.filters.labels.status')"
|
||||
/>
|
||||
</VSpace>
|
||||
<VButton size="sm" @click="handleRetryAllFailed">
|
||||
{{
|
||||
$t(
|
||||
"core.attachment.thumbnails_modal.operations.retry_all_failed.button"
|
||||
)
|
||||
}}
|
||||
</VButton>
|
||||
</div>
|
||||
<VLoading v-if="isLoading" />
|
||||
<VEmpty
|
||||
v-else-if="!thumbnails?.items?.length"
|
||||
:title="$t('core.attachment.thumbnails_modal.empty.title')"
|
||||
:message="$t('core.attachment.thumbnails_modal.empty.message')"
|
||||
>
|
||||
<template #actions>
|
||||
<VButton @click="refetch">
|
||||
{{ $t("core.common.buttons.refresh") }}
|
||||
</VButton>
|
||||
</template>
|
||||
</VEmpty>
|
||||
<div v-else class="overflow-hidden rounded-base border">
|
||||
<VEntityContainer>
|
||||
<AttachmentThumbnailItem
|
||||
v-for="thumbnail in thumbnails.items"
|
||||
:key="thumbnail.metadata.name"
|
||||
:thumbnail="thumbnail"
|
||||
/>
|
||||
</VEntityContainer>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<VPagination
|
||||
v-model:page="page"
|
||||
v-model:size="size"
|
||||
:page-label="$t('core.components.pagination.page_label')"
|
||||
:size-label="$t('core.components.pagination.size_label')"
|
||||
:total-label="
|
||||
$t('core.components.pagination.total_label', {
|
||||
total: thumbnails?.total || 0,
|
||||
})
|
||||
"
|
||||
:total="thumbnails?.total || 0"
|
||||
:size-options="[20, 30, 50, 100]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<VButton @click="modal?.close()">
|
||||
{{ $t("core.common.buttons.close") }}
|
||||
</VButton>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
|
@ -0,0 +1,46 @@
|
|||
import { storageAnnotations } from "@/constants/annotations";
|
||||
import {
|
||||
coreApiClient,
|
||||
LocalThumbnailStatusPhaseEnum,
|
||||
type LocalThumbnail,
|
||||
} from "@halo-dev/api-client";
|
||||
import { Toast } from "@halo-dev/components";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import type { Ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
export function useThumbnailControl(thumbnail: Ref<LocalThumbnail>) {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useI18n();
|
||||
|
||||
async function handleRetry() {
|
||||
await coreApiClient.storage.localThumbnail.patchLocalThumbnail({
|
||||
name: thumbnail.value.metadata.name,
|
||||
jsonPatchInner: [
|
||||
{
|
||||
op: "add",
|
||||
path: "/status/phase",
|
||||
value: LocalThumbnailStatusPhaseEnum.Pending,
|
||||
},
|
||||
{
|
||||
op: "add",
|
||||
path: "/metadata/annotations",
|
||||
value: {
|
||||
...thumbnail.value.metadata.annotations,
|
||||
[storageAnnotations.RETRY_TIMESTAMP]: Date.now().toString(),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["core:attachments:thumbnails"],
|
||||
});
|
||||
|
||||
Toast.success(t("core.common.toast.operation_success"));
|
||||
}
|
||||
|
||||
return {
|
||||
handleRetry,
|
||||
};
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import {
|
||||
LocalThumbnailSpecSizeEnum,
|
||||
LocalThumbnailStatusPhaseEnum,
|
||||
type LocalThumbnail,
|
||||
} from "@halo-dev/api-client";
|
||||
import { IconCheckboxCircle, IconErrorWarning } from "@halo-dev/components";
|
||||
import { computed, type Component, type Ref } from "vue";
|
||||
import RiTimeLine from "~icons/ri/time-line";
|
||||
|
||||
export const SIZE_MAP: Record<LocalThumbnailSpecSizeEnum, string> = {
|
||||
XL: "1600w",
|
||||
L: "1200w",
|
||||
M: "800w",
|
||||
S: "400w",
|
||||
};
|
||||
|
||||
export const PHASE_MAP: Record<
|
||||
LocalThumbnailStatusPhaseEnum,
|
||||
{
|
||||
icon: Component;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
> = {
|
||||
PENDING: {
|
||||
icon: RiTimeLine,
|
||||
label: "core.attachment.thumbnails.phase.pending",
|
||||
color: "text-gray-500",
|
||||
},
|
||||
SUCCEEDED: {
|
||||
icon: IconCheckboxCircle,
|
||||
label: "core.attachment.thumbnails.phase.succeeded",
|
||||
color: "text-green-500",
|
||||
},
|
||||
FAILED: {
|
||||
icon: IconErrorWarning,
|
||||
label: "core.attachment.thumbnails.phase.failed",
|
||||
color: "text-red-500",
|
||||
},
|
||||
};
|
||||
export function useThumbnailDetail(thumbnail: Ref<LocalThumbnail>) {
|
||||
const phase = computed(() => {
|
||||
return PHASE_MAP[
|
||||
thumbnail.value.status.phase || LocalThumbnailStatusPhaseEnum.Pending
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
phase,
|
||||
};
|
||||
}
|
|
@ -90,6 +90,7 @@
|
|||
"colorjs.io": "^0.4.3",
|
||||
"core-js": "^3.43.0",
|
||||
"cropperjs": "^1.5.13",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.7",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"floating-vue": "^5.2.2",
|
||||
|
@ -120,6 +121,7 @@
|
|||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tsconfig/node18": "^2.0.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^18.11.19",
|
||||
|
|
|
@ -19,6 +19,7 @@ import {
|
|||
ExtensionPointDefinitionV1alpha1Api,
|
||||
GroupV1alpha1Api,
|
||||
IndicesV1alpha1ConsoleApi,
|
||||
LocalThumbnailV1alpha1Api,
|
||||
MenuItemV1alpha1Api,
|
||||
MenuV1alpha1Api,
|
||||
MenuV1alpha1PublicApi,
|
||||
|
@ -62,6 +63,7 @@ import {
|
|||
TagV1alpha1ConsoleApi,
|
||||
ThemeV1alpha1Api,
|
||||
ThemeV1alpha1ConsoleApi,
|
||||
ThumbnailV1alpha1Api,
|
||||
TwoFactorAuthV1alpha1UcApi,
|
||||
UserConnectionV1alpha1Api,
|
||||
UserPreferenceV1alpha1UcApi,
|
||||
|
@ -163,6 +165,12 @@ function createCoreApiClient(axiosInstance: AxiosInstance) {
|
|||
baseURL,
|
||||
axiosInstance
|
||||
),
|
||||
localThumbnail: new LocalThumbnailV1alpha1Api(
|
||||
undefined,
|
||||
baseURL,
|
||||
axiosInstance
|
||||
),
|
||||
thumbnail: new ThumbnailV1alpha1Api(undefined, baseURL, axiosInstance),
|
||||
},
|
||||
|
||||
// plugin.halo.run
|
||||
|
|
|
@ -156,6 +156,9 @@ importers:
|
|||
cropperjs:
|
||||
specifier: ^1.5.13
|
||||
version: 1.5.13
|
||||
crypto-js:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
dayjs:
|
||||
specifier: ^1.11.7
|
||||
version: 1.11.7
|
||||
|
@ -241,6 +244,9 @@ importers:
|
|||
'@tsconfig/node18':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
'@types/crypto-js':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
'@types/jsdom':
|
||||
specifier: ^21.1.7
|
||||
version: 21.1.7
|
||||
|
@ -4287,6 +4293,9 @@ packages:
|
|||
'@types/cross-spawn@6.0.6':
|
||||
resolution: {integrity: sha512-fXRhhUkG4H3TQk5dBhQ7m/JDdSNHKwR2BBia62lhwEIq9xGiQKLxd6LymNhn47SjXhsUEPmxi+PKw2OkW4LLjA==}
|
||||
|
||||
'@types/crypto-js@4.2.2':
|
||||
resolution: {integrity: sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
|
@ -5816,6 +5825,9 @@ packages:
|
|||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
crypto-js@4.2.0:
|
||||
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||
|
||||
crypto-random-string@2.0.0:
|
||||
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
|
||||
engines: {node: '>=8'}
|
||||
|
@ -15658,6 +15670,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/node': 18.19.34
|
||||
|
||||
'@types/crypto-js@4.2.2': {}
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/detect-port@1.3.5': {}
|
||||
|
@ -17403,6 +17417,8 @@ snapshots:
|
|||
shebang-command: 2.0.0
|
||||
which: 2.0.2
|
||||
|
||||
crypto-js@4.2.0: {}
|
||||
|
||||
crypto-random-string@2.0.0: {}
|
||||
|
||||
crypto-random-string@4.0.0:
|
||||
|
|
|
@ -33,3 +33,11 @@ export enum patAnnotations {
|
|||
export enum secretAnnotations {
|
||||
DESCRIPTION = "secret.halo.run/description",
|
||||
}
|
||||
|
||||
// storage
|
||||
export enum storageAnnotations {
|
||||
URI = "storage.halo.run/uri",
|
||||
|
||||
// Frontend custom annotations, for retry operation
|
||||
RETRY_TIMESTAMP = "storage.halo.run/retry-timestamp",
|
||||
}
|
||||
|
|
|
@ -208,11 +208,16 @@ core:
|
|||
original_comment: Original comment
|
||||
content: Reply content
|
||||
attachment:
|
||||
actions:
|
||||
thumbnails: Thumbnails
|
||||
filters:
|
||||
sort:
|
||||
items:
|
||||
display_name_asc: Ascending order by display name
|
||||
display_name_desc: Descending order by display name
|
||||
detail_modal:
|
||||
fields:
|
||||
thumbnails: Thumbnails
|
||||
permalink_list:
|
||||
relative: Relative path
|
||||
absolute: Absolute path
|
||||
|
@ -232,6 +237,21 @@ core:
|
|||
label: URL
|
||||
toast:
|
||||
success: Downloaded successfully
|
||||
thumbnails_modal:
|
||||
title: Thumbnail records
|
||||
empty:
|
||||
title: No thumbnail records
|
||||
message: There are currently no thumbnail records, thumbnails will be automatically generated after uploading image attachments.
|
||||
operations:
|
||||
retry_all_failed:
|
||||
button: Retry all failed records
|
||||
tips_empty: No failed thumbnail records
|
||||
tips_success: All failed records retried successfully
|
||||
thumbnails:
|
||||
phase:
|
||||
pending: Pending
|
||||
succeeded: Succeeded
|
||||
failed: Failed
|
||||
uc_attachment:
|
||||
empty:
|
||||
title: There are no attachments.
|
||||
|
@ -757,6 +777,7 @@ core:
|
|||
disable: Disable
|
||||
enable: Enable
|
||||
continue: Continue
|
||||
retry: Retry
|
||||
toast:
|
||||
disable_success: Disabled successfully
|
||||
enable_success: Enabled successfully
|
||||
|
|
|
@ -624,6 +624,7 @@ core:
|
|||
ungrouped: Ungrouped
|
||||
actions:
|
||||
storage_policies: Storage Policies
|
||||
thumbnails: Thumbnails
|
||||
empty:
|
||||
title: There are no attachments in the current group.
|
||||
message: >-
|
||||
|
@ -679,6 +680,7 @@ core:
|
|||
owner: Owner
|
||||
creation_time: Creation time
|
||||
permalink: Permalink
|
||||
thumbnails: Thumbnails
|
||||
preview:
|
||||
click_to_exit: Click to exit preview
|
||||
video_not_support: The current browser does not support video playback.
|
||||
|
@ -771,6 +773,23 @@ core:
|
|||
operations:
|
||||
select:
|
||||
result: ({count} items selected)
|
||||
thumbnails_modal:
|
||||
title: Thumbnail records
|
||||
empty:
|
||||
title: No thumbnail records
|
||||
message: >-
|
||||
There are currently no thumbnail records, thumbnails will be
|
||||
automatically generated after uploading image attachments.
|
||||
operations:
|
||||
retry_all_failed:
|
||||
button: Retry all failed records
|
||||
tips_empty: No failed thumbnail records
|
||||
tips_success: All failed records retried successfully
|
||||
thumbnails:
|
||||
phase:
|
||||
pending: Pending
|
||||
succeeded: Succeeded
|
||||
failed: Failed
|
||||
uc_attachment:
|
||||
empty:
|
||||
title: There are no attachments.
|
||||
|
@ -1927,6 +1946,7 @@ core:
|
|||
disable: Disable
|
||||
enable: Enable
|
||||
continue: Continue
|
||||
retry: Retry
|
||||
radio:
|
||||
"yes": "Yes"
|
||||
"no": "No"
|
||||
|
|
|
@ -596,6 +596,7 @@ core:
|
|||
ungrouped: 未分组
|
||||
actions:
|
||||
storage_policies: 存储策略
|
||||
thumbnails: 缩略图
|
||||
empty:
|
||||
title: 当前分组没有附件
|
||||
message: 当前分组没有附件,你可以尝试刷新或者上传附件
|
||||
|
@ -649,6 +650,7 @@ core:
|
|||
owner: 上传者
|
||||
creation_time: 上传时间
|
||||
permalink: 链接
|
||||
thumbnails: 缩略图
|
||||
preview:
|
||||
click_to_exit: 点击退出预览
|
||||
video_not_support: 当前浏览器不支持该视频播放
|
||||
|
@ -731,6 +733,21 @@ core:
|
|||
permalink_list:
|
||||
relative: 相对路径
|
||||
absolute: 完整路径
|
||||
thumbnails_modal:
|
||||
title: 缩略图记录
|
||||
empty:
|
||||
title: 没有缩略图记录
|
||||
message: 当前没有缩略图记录,缩略图会在上传图片附件后自动生成。
|
||||
operations:
|
||||
retry_all_failed:
|
||||
button: 重试所有失败记录
|
||||
tips_empty: 没有失败记录
|
||||
tips_success: 重试所有失败记录成功
|
||||
thumbnails:
|
||||
phase:
|
||||
pending: 等待生成
|
||||
succeeded: 生成成功
|
||||
failed: 生成失败
|
||||
uc_attachment:
|
||||
empty:
|
||||
title: 当前没有附件
|
||||
|
@ -1788,6 +1805,7 @@ core:
|
|||
disable: 禁用
|
||||
enable: 启用
|
||||
continue: 继续
|
||||
retry: 重试
|
||||
radio:
|
||||
"yes": 是
|
||||
"no": 否
|
||||
|
|
|
@ -581,6 +581,7 @@ core:
|
|||
ungrouped: 未分組
|
||||
actions:
|
||||
storage_policies: 存儲策略
|
||||
thumbnails: 縮略圖
|
||||
empty:
|
||||
title: 當前分組沒有附件
|
||||
message: 當前分組沒有附件,你可以嘗試重整或者上傳附件
|
||||
|
@ -634,6 +635,7 @@ core:
|
|||
owner: 上傳者
|
||||
creation_time: 上傳時間
|
||||
permalink: 連結
|
||||
thumbnails: 縮略圖
|
||||
preview:
|
||||
click_to_exit: 點擊離開預覽
|
||||
video_not_support: 當前瀏覽器不支援該影片播放
|
||||
|
@ -716,6 +718,21 @@ core:
|
|||
permalink_list:
|
||||
relative: 相對路徑
|
||||
absolute: 完整路徑
|
||||
thumbnails_modal:
|
||||
title: 縮略圖記錄
|
||||
empty:
|
||||
title: 沒有縮略圖記錄
|
||||
message: 當前沒有縮略圖記錄,縮略圖會在上傳圖片附件後自動生成。
|
||||
operations:
|
||||
retry_all_failed:
|
||||
button: 重試所有失敗記錄
|
||||
tips_empty: 沒有失敗記錄
|
||||
tips_success: 重試所有失敗記錄成功
|
||||
thumbnails:
|
||||
phase:
|
||||
pending: 等待生成
|
||||
succeeded: 生成成功
|
||||
failed: 生成失敗
|
||||
uc_attachment:
|
||||
empty:
|
||||
title: 當前沒有附件
|
||||
|
@ -1773,6 +1790,7 @@ core:
|
|||
disable: 禁用
|
||||
enable: 启用
|
||||
continue: 繼續
|
||||
retry: 重試
|
||||
radio:
|
||||
"yes": 是
|
||||
"no": 否
|
||||
|
|
Loading…
Reference in New Issue