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
Ryan Wang 2025-07-22 21:54:18 +08:00 committed by GitHub
parent e737d8b0f7
commit 395399f078
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 722 additions and 0 deletions

View File

@ -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);

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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,
};
}

View File

@ -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,
};
}

View File

@ -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",

View File

@ -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

View File

@ -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:

View File

@ -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",
}

View File

@ -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

View File

@ -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"

View File

@ -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":

View File

@ -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":