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
Ryan Wang 2023-10-08 17:58:37 +08:00 committed by GitHub
parent 815f6b82c5
commit da021658c8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 234 additions and 161 deletions

View File

@ -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>[]>`
示例:

View File

@ -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[]>;

View File

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

View File

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

View File

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