mirror of https://github.com/halo-dev/halo
feat: make attachment list item operations extendable (#4689)
#### What type of PR is this? /area console /kind feature /milestone 2.10.x #### What this PR does / why we need it: 附件管理列表项的操作按钮支持被插件扩展。 <img width="1669" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/be938c07-2976-4e22-9bf3-cdfaf53896e5"> #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/4667 #### Special notes for your reviewer: 需要测试附件的关于列表的已有功能是否正常。 如果需要测试扩展点是否有效,可以使用此插件测试:[plugin-s3-1.5.0-SNAPSHOT.jar.zip](https://github.com/halo-dev/halo/files/12839986/plugin-s3-1.5.0-SNAPSHOT.jar.zip) ```diff export default definePlugin({ components: {}, routes: [], extensionPoints: { "plugin:self:tabs:create": (): PluginTab[] => { return [ { id: "s3-link", label: "关联S3文件", component: markRaw(HomeView), permissions: [], }, ]; }, + "attachment:list-item:operation:create": (attachment: Ref<Attachment>) => { + return [ + { + priority: 21, + component: markRaw(VDropdownDivider), + }, + { + priority: 22, + component: markRaw(VDropdownItem), + props: { + type: "danger", + }, + label: "解除 S3 关联", + permissions: ["system:attachments:manage"], + action: () => { + console.log(attachment); + }, + }, + ]; + }, }, }); ``` #### Does this PR introduce a user-facing change? ```release-note Console 附件管理列表项的操作按钮支持被插件扩展。 ```pull/4694/head
parent
815f6b82c5
commit
da021658c8
|
@ -12,6 +12,7 @@
|
|||
- 插件:`"plugin:list-item:operation:create"?: (plugin: Ref<Plugin>) => | OperationItem<Plugin>[] | Promise<OperationItem<Plugin>[]>`
|
||||
- 备份:`"backup:list-item:operation:create"?: (backup: Ref<Backup>) => | OperationItem<Backup>[] | Promise<OperationItem<Backup>[]>`
|
||||
- 主题:`"theme:list-item:operation:create"?: (theme: Ref<Theme>) => | OperationItem<Theme>[] | Promise<OperationItem<Theme>[]>`
|
||||
- 附件:`"attachment:list-item:operation:create"?: (attachment: Ref<Attachment>) => | OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]>`
|
||||
|
||||
示例:
|
||||
|
||||
|
|
|
@ -10,7 +10,13 @@ import type { PluginInstallationTab } from "@/states/plugin-installation-tabs";
|
|||
import type { EntityFieldItem } from "@/states/entity";
|
||||
import type { OperationItem } from "@/states/operation";
|
||||
import type { ThemeListTab } from "@/states/theme-list-tabs";
|
||||
import type { Backup, ListedPost, Plugin, Theme } from "@halo-dev/api-client";
|
||||
import type {
|
||||
Attachment,
|
||||
Backup,
|
||||
ListedPost,
|
||||
Plugin,
|
||||
Theme,
|
||||
} from "@halo-dev/api-client";
|
||||
|
||||
export interface RouteRecordAppend {
|
||||
parentName: RouteRecordName;
|
||||
|
@ -53,6 +59,10 @@ export interface ExtensionPoint {
|
|||
backup: Ref<Backup>
|
||||
) => OperationItem<Backup>[] | Promise<OperationItem<Backup>[]>;
|
||||
|
||||
"attachment:list-item:operation:create"?: (
|
||||
attachment: Ref<Attachment>
|
||||
) => OperationItem<Attachment>[] | Promise<OperationItem<Attachment>[]>;
|
||||
|
||||
"plugin:list-item:field:create"?: (
|
||||
plugin: Ref<Plugin>
|
||||
) => EntityFieldItem[] | Promise<EntityFieldItem[]>;
|
||||
|
|
|
@ -15,9 +15,6 @@ import {
|
|||
VSpace,
|
||||
VEmpty,
|
||||
IconFolder,
|
||||
VStatusDot,
|
||||
VEntity,
|
||||
VEntityField,
|
||||
VLoading,
|
||||
Toast,
|
||||
VDropdown,
|
||||
|
@ -30,8 +27,6 @@ import AttachmentPoliciesModal from "./components/AttachmentPoliciesModal.vue";
|
|||
import AttachmentGroupList from "./components/AttachmentGroupList.vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import type { Attachment, Group } from "@halo-dev/api-client";
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { useFetchAttachmentPolicy } from "./composables/use-attachment-policy";
|
||||
import { useAttachmentControl } from "./composables/use-attachment";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
|
@ -39,12 +34,13 @@ import cloneDeep from "lodash.clonedeep";
|
|||
import { isImage } from "@/utils/image";
|
||||
import { useRouteQuery } from "@vueuse/router";
|
||||
import { useFetchAttachmentGroup } from "./composables/use-attachment-group";
|
||||
import { usePermission } from "@/utils/permission";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import UserFilterDropdown from "@/components/filter/UserFilterDropdown.vue";
|
||||
import { provide } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import AttachmentListItem from "./components/AttachmentListItem.vue";
|
||||
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const { t } = useI18n();
|
||||
|
||||
const policyVisible = ref(false);
|
||||
|
@ -101,7 +97,6 @@ const {
|
|||
handleFetchAttachments,
|
||||
handleSelectNext,
|
||||
handleSelectPrevious,
|
||||
handleDelete,
|
||||
handleDeleteInBatch,
|
||||
handleCheckAll,
|
||||
handleSelect,
|
||||
|
@ -121,6 +116,8 @@ const {
|
|||
size: size,
|
||||
});
|
||||
|
||||
provide<Ref<Set<Attachment>>>("selectedAttachments", selectedAttachments);
|
||||
|
||||
const handleMove = async (group: Group) => {
|
||||
try {
|
||||
const promises = Array.from(selectedAttachments.value).map((attachment) => {
|
||||
|
@ -177,11 +174,6 @@ const onUploadModalClose = () => {
|
|||
handleFetchAttachments();
|
||||
};
|
||||
|
||||
const getPolicyName = (name: string | undefined) => {
|
||||
const policy = policies.value?.find((p) => p.metadata.name === name);
|
||||
return policy?.spec.displayName;
|
||||
};
|
||||
|
||||
// View type
|
||||
const viewTypes = [
|
||||
{
|
||||
|
@ -468,8 +460,8 @@ onMounted(() => {
|
|||
role="list"
|
||||
>
|
||||
<VCard
|
||||
v-for="(attachment, index) in attachments"
|
||||
:key="index"
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.metadata.name"
|
||||
:body-class="['!p-0']"
|
||||
:class="{
|
||||
'ring-1 ring-primary': isChecked(attachment),
|
||||
|
@ -552,118 +544,16 @@ onMounted(() => {
|
|||
class="box-border h-full w-full divide-y divide-gray-100"
|
||||
role="list"
|
||||
>
|
||||
<li v-for="(attachment, index) in attachments" :key="index">
|
||||
<VEntity :is-selected="isChecked(attachment)">
|
||||
<template
|
||||
v-if="
|
||||
currentUserHasPermission(['system:attachments:manage'])
|
||||
"
|
||||
#checkbox
|
||||
>
|
||||
<input
|
||||
:checked="selectedAttachments.has(attachment)"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||
type="checkbox"
|
||||
@click="handleSelect(attachment)"
|
||||
/>
|
||||
</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="handleClickItem(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="getPolicyName(attachment.spec.policyName)"
|
||||
/>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: 'UserDetail',
|
||||
params: {
|
||||
name: attachment.spec.ownerName,
|
||||
},
|
||||
}"
|
||||
class="text-xs text-gray-500"
|
||||
:class="{
|
||||
'pointer-events-none': !currentUserHasPermission([
|
||||
'system:users:view',
|
||||
]),
|
||||
}"
|
||||
>
|
||||
{{ attachment.spec.ownerName }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<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>
|
||||
<template #dropdownItems>
|
||||
<VDropdownItem @click="handleClickItem(attachment)">
|
||||
{{ $t("core.common.buttons.detail") }}
|
||||
</VDropdownItem>
|
||||
<VDropdownItem
|
||||
v-if="
|
||||
currentUserHasPermission([
|
||||
'system:attachments:manage',
|
||||
])
|
||||
"
|
||||
type="danger"
|
||||
@click="handleDelete(attachment)"
|
||||
>
|
||||
{{ $t("core.common.buttons.delete") }}
|
||||
</VDropdownItem>
|
||||
</template>
|
||||
</VEntity>
|
||||
<li
|
||||
v-for="attachment in attachments"
|
||||
:key="attachment.metadata.name"
|
||||
>
|
||||
<AttachmentListItem
|
||||
:attachment="attachment"
|
||||
:is-selected="isChecked(attachment)"
|
||||
@select="handleSelect"
|
||||
@open-detail="handleClickItem"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</Transition>
|
||||
|
|
|
@ -0,0 +1,205 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
VSpace,
|
||||
VStatusDot,
|
||||
VEntity,
|
||||
VEntityField,
|
||||
Toast,
|
||||
VDropdownItem,
|
||||
Dialog,
|
||||
} from "@halo-dev/components";
|
||||
import { computed, ref } from "vue";
|
||||
import type { Attachment } from "@halo-dev/api-client";
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import { useFetchAttachmentPolicy } from "../composables/use-attachment-policy";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import { usePermission } from "@/utils/permission";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { inject } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
import { useQueryClient } from "@tanstack/vue-query";
|
||||
import { useOperationItemExtensionPoint } from "@/composables/use-operation-extension-points";
|
||||
import { toRefs } from "vue";
|
||||
import type { OperationItem } from "@halo-dev/console-shared";
|
||||
import EntityDropdownItems from "@/components/entity/EntityDropdownItems.vue";
|
||||
import { markRaw } from "vue";
|
||||
|
||||
const { currentUserHasPermission } = usePermission();
|
||||
const { t } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
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 selectedAttachments = inject<Ref<Set<Attachment>>>(
|
||||
"selectedAttachments",
|
||||
ref<Set<Attachment>>(new Set())
|
||||
);
|
||||
|
||||
const policyName = computed(() => {
|
||||
const policy = policies.value?.find(
|
||||
(p) => p.metadata.name === props.attachment.spec.policyName
|
||||
);
|
||||
return policy?.spec.displayName;
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
Dialog.warning({
|
||||
title: t("core.attachment.operations.delete.title"),
|
||||
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||
confirmType: "danger",
|
||||
confirmText: t("core.common.buttons.confirm"),
|
||||
cancelText: t("core.common.buttons.cancel"),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await apiClient.extension.storage.attachment.deletestorageHaloRunV1alpha1Attachment(
|
||||
{
|
||||
name: props.attachment.metadata.name,
|
||||
}
|
||||
);
|
||||
|
||||
selectedAttachments.value.delete(props.attachment);
|
||||
|
||||
Toast.success(t("core.common.toast.delete_success"));
|
||||
} catch (e) {
|
||||
console.error("Failed to delete attachment", e);
|
||||
} finally {
|
||||
queryClient.invalidateQueries({ queryKey: ["attachments"] });
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const { operationItems } = useOperationItemExtensionPoint<Attachment>(
|
||||
"attachment:list-item:operation:create",
|
||||
attachment,
|
||||
computed((): OperationItem<Attachment>[] => [
|
||||
{
|
||||
priority: 10,
|
||||
component: markRaw(VDropdownItem),
|
||||
label: t("core.common.buttons.detail"),
|
||||
permissions: [],
|
||||
action: () => {
|
||||
emit("open-detail", attachment.value);
|
||||
},
|
||||
},
|
||||
{
|
||||
priority: 20,
|
||||
component: markRaw(VDropdownItem),
|
||||
props: {
|
||||
type: "danger",
|
||||
},
|
||||
label: t("core.common.buttons.delete"),
|
||||
permissions: ["system:attachments:manage"],
|
||||
action: () => {
|
||||
handleDelete();
|
||||
},
|
||||
},
|
||||
])
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VEntity :is-selected="isSelected">
|
||||
<template
|
||||
v-if="currentUserHasPermission(['system:attachments:manage'])"
|
||||
#checkbox
|
||||
>
|
||||
<input
|
||||
:checked="selectedAttachments.has(attachment)"
|
||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||
type="checkbox"
|
||||
@click="emit('select', attachment)"
|
||||
/>
|
||||
</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="policyName" />
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<RouterLink
|
||||
:to="{
|
||||
name: 'UserDetail',
|
||||
params: {
|
||||
name: attachment.spec.ownerName,
|
||||
},
|
||||
}"
|
||||
class="text-xs text-gray-500"
|
||||
:class="{
|
||||
'pointer-events-none': !currentUserHasPermission([
|
||||
'system:users:view',
|
||||
]),
|
||||
}"
|
||||
>
|
||||
{{ attachment.spec.ownerName }}
|
||||
</RouterLink>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<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>
|
||||
<template #dropdownItems>
|
||||
<EntityDropdownItems
|
||||
:dropdown-items="operationItems"
|
||||
:item="attachment"
|
||||
/>
|
||||
</template>
|
||||
</VEntity>
|
||||
</template>
|
|
@ -21,7 +21,6 @@ interface useAttachmentControlReturn {
|
|||
handleFetchAttachments: () => void;
|
||||
handleSelectPrevious: () => void;
|
||||
handleSelectNext: () => void;
|
||||
handleDelete: (attachment: Attachment) => void;
|
||||
handleDeleteInBatch: () => void;
|
||||
handleCheckAll: (checkAll: boolean) => void;
|
||||
handleSelect: (attachment: Attachment | undefined) => void;
|
||||
|
@ -128,37 +127,6 @@ export function useAttachmentControl(filterOptions: {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDelete = (attachment: Attachment) => {
|
||||
Dialog.warning({
|
||||
title: t("core.attachment.operations.delete.title"),
|
||||
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||
confirmType: "danger",
|
||||
confirmText: t("core.common.buttons.confirm"),
|
||||
cancelText: t("core.common.buttons.cancel"),
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await apiClient.extension.storage.attachment.deletestorageHaloRunV1alpha1Attachment(
|
||||
{
|
||||
name: attachment.metadata.name,
|
||||
}
|
||||
);
|
||||
if (
|
||||
selectedAttachment.value?.metadata.name === attachment.metadata.name
|
||||
) {
|
||||
selectedAttachment.value = undefined;
|
||||
}
|
||||
selectedAttachments.value.delete(attachment);
|
||||
|
||||
Toast.success(t("core.common.toast.delete_success"));
|
||||
} catch (e) {
|
||||
console.error("Failed to delete attachment", e);
|
||||
} finally {
|
||||
await refetch();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteInBatch = () => {
|
||||
Dialog.warning({
|
||||
title: t("core.attachment.operations.delete_in_batch.title"),
|
||||
|
@ -243,7 +211,6 @@ export function useAttachmentControl(filterOptions: {
|
|||
handleFetchAttachments: refetch,
|
||||
handleSelectPrevious,
|
||||
handleSelectNext,
|
||||
handleDelete,
|
||||
handleDeleteInBatch,
|
||||
handleCheckAll,
|
||||
handleSelect,
|
||||
|
|
Loading…
Reference in New Issue