feat: add support for setting an owner for posts (#6178)

#### What type of PR is this?

/area ui
/kind feature
/milestone 2.17.x

#### What this PR does / why we need it:

支持手动为文章设置作者。

<img width="734" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/b91b1754-4f50-4333-8478-6735d5846847">


#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/5720

#### Special notes for your reviewer:

需要测试:

1. 打开任意文章的设置,在高级中设置作者并保存,观察是否成功。
2. 选择一批文章,点击批量设置,设置一个作者,观察是否设置成功。

#### Does this PR introduce a user-facing change?

```release-note
支持手动为文章设置作者。
```
pull/6185/head
Ryan Wang 2024-06-27 18:10:55 +08:00 committed by GitHub
parent 9d478eecf9
commit bb0a3bc467
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 134 additions and 24 deletions

View File

@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client"; import type { ListedSinglePage, SinglePage } from "@halo-dev/api-client";
@ -25,7 +26,6 @@ import { useQuery } from "@tanstack/vue-query";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import ContributorList from "../_components/ContributorList.vue";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
@ -327,7 +327,10 @@ watch(
<template #end> <template #end>
<VEntityField> <VEntityField>
<template #description> <template #description>
<ContributorList :contributors="singlePage.contributors" /> <PostContributorList
:owner="singlePage.owner"
:contributors="singlePage.contributors"
/>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField v-if="!singlePage?.page?.spec.deleted"> <VEntityField v-if="!singlePage?.page?.spec.deleted">

View File

@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import { singlePageLabels } from "@/constants/labels"; import { singlePageLabels } from "@/constants/labels";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
@ -23,7 +24,6 @@ import type { Ref } from "vue";
import { computed, inject, ref } from "vue"; import { computed, inject, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { RouterLink } from "vue-router"; import { RouterLink } from "vue-router";
import ContributorList from "../../_components/ContributorList.vue";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const { t } = useI18n(); const { t } = useI18n();
@ -184,7 +184,10 @@ const handleDelete = async () => {
<template #end> <template #end>
<VEntityField> <VEntityField>
<template #description> <template #description>
<ContributorList :contributors="singlePage.contributors" /> <PostContributorList
:owner="singlePage.owner"
:contributors="singlePage.contributors"
/>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField :description="publishStatus"> <VEntityField :description="publishStatus">

View File

@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import PostContributorList from "@/components/user/PostContributorList.vue";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import type { ListedPost, Post } from "@halo-dev/api-client"; import type { ListedPost, Post } from "@halo-dev/api-client";
@ -25,7 +26,6 @@ import { useQuery } from "@tanstack/vue-query";
import { cloneDeep } from "lodash-es"; import { cloneDeep } from "lodash-es";
import { ref, watch } from "vue"; import { ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import ContributorList from "../_components/ContributorList.vue";
import PostTag from "./tags/components/PostTag.vue"; import PostTag from "./tags/components/PostTag.vue";
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
@ -351,7 +351,10 @@ watch(
<template #end> <template #end>
<VEntityField> <VEntityField>
<template #description> <template #description>
<ContributorList :contributors="post.contributors" /> <PostContributorList
:owner="post.owner"
:contributors="post.contributors"
/>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField v-if="!post?.post?.spec.deleted"> <VEntityField v-if="!post?.post?.spec.deleted">

View File

@ -22,6 +22,10 @@ interface FormData {
names?: string[]; names?: string[];
op: ArrayPatchOp; op: ArrayPatchOp;
}; };
owner: {
enabled: boolean;
value: string;
};
visible: { visible: {
enabled: boolean; enabled: boolean;
value: "PUBLIC" | "PRIVATE"; value: "PUBLIC" | "PRIVATE";
@ -73,6 +77,14 @@ const { mutate, isLoading } = useMutation({
}); });
} }
if (data.owner.enabled) {
jsonPatchInner.push({
op: "add",
path: "/spec/owner",
value: data.owner.value,
});
}
if (data.visible.enabled) { if (data.visible.enabled) {
jsonPatchInner.push({ jsonPatchInner.push({
op: "add", op: "add",
@ -235,6 +247,25 @@ function onSubmit(data: FormData) {
validation="required" validation="required"
></FormKit> ></FormKit>
</FormKit> </FormKit>
<FormKit
v-slot="{ value }"
type="group"
name="owner"
:label="$t('core.post.batch_setting_modal.fields.owner_group')"
>
<FormKit
:value="false"
:label="$t('core.post.batch_setting_modal.fields.common.enabled')"
type="checkbox"
name="enabled"
></FormKit>
<FormKit
v-if="value?.enabled"
:label="$t('core.post.batch_setting_modal.fields.owner_value')"
name="value"
type="userSelect"
></FormKit>
</FormKit>
<FormKit <FormKit
v-slot="{ value }" v-slot="{ value }"
type="group" type="group"

View File

@ -391,6 +391,11 @@ const showCancelPublishButton = computed(() => {
</div> </div>
</div> </div>
<div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0"> <div class="mt-5 divide-y divide-gray-100 md:col-span-3 md:mt-0">
<FormKit
v-model="formState.spec.owner"
:label="$t('core.post.settings.fields.owner.label')"
type="userSelect"
></FormKit>
<FormKit <FormKit
v-model="formState.spec.allowComment" v-model="formState.spec.allowComment"
:options="[ :options="[

View File

@ -1,5 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import ContributorList from "@console/modules/contents/_components/ContributorList.vue"; import PostContributorList from "@/components/user/PostContributorList.vue";
import type { ListedPost } from "@halo-dev/api-client"; import type { ListedPost } from "@halo-dev/api-client";
import { VEntityField } from "@halo-dev/components"; import { VEntityField } from "@halo-dev/components";
@ -14,7 +14,10 @@ withDefaults(
<template> <template>
<VEntityField> <VEntityField>
<template #description> <template #description>
<ContributorList :contributors="post.contributors" /> <PostContributorList
:owner="post.owner"
:contributors="post.contributors"
/>
</template> </template>
</VEntityField> </VEntityField>
</template> </template>

7
ui/env.d.ts vendored
View File

@ -3,8 +3,8 @@
export {}; export {};
import type { CoreMenuGroupId } from "@halo-dev/console-shared";
import type { FormKitInputs } from "@formkit/inputs"; import type { FormKitInputs } from "@formkit/inputs";
import type { CoreMenuGroupId } from "@halo-dev/console-shared";
import "vue-router"; import "vue-router";
@ -124,5 +124,10 @@ declare module "@formkit/inputs" {
type: "code"; type: "code";
value?: string; value?: string;
}; };
userSelect: {
type: "userSelect";
value?: string;
};
} }
} }

View File

@ -2,20 +2,35 @@
import { usePermission } from "@/utils/permission"; import { usePermission } from "@/utils/permission";
import type { Contributor } from "@halo-dev/api-client"; import type { Contributor } from "@halo-dev/api-client";
import { VAvatar, VAvatarGroup } from "@halo-dev/components"; import { VAvatar, VAvatarGroup } from "@halo-dev/components";
import { computed } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
allowViewUserDetail?: boolean;
owner?: Contributor;
contributors: Contributor[]; contributors: Contributor[];
}>(), }>(),
{} {
allowViewUserDetail: true,
owner: undefined,
}
); );
const router = useRouter(); const router = useRouter();
const { currentUserHasPermission } = usePermission(); const { currentUserHasPermission } = usePermission();
const contributorsWithoutOwner = computed(() =>
props.contributors.filter(
(contributor) => contributor.name !== props.owner?.name
)
);
function handleRouteToUserDetail(contributor: Contributor) { function handleRouteToUserDetail(contributor: Contributor) {
if (!currentUserHasPermission(["system:users:view"])) { if (
!currentUserHasPermission(["system:users:view"]) ||
!props.allowViewUserDetail
) {
return; return;
} }
router.push({ router.push({
@ -28,7 +43,14 @@ function handleRouteToUserDetail(contributor: Contributor) {
<template> <template>
<VAvatarGroup size="xs" circle> <VAvatarGroup size="xs" circle>
<VAvatar <VAvatar
v-for="contributor in contributors" v-if="owner"
v-tooltip="owner?.displayName"
:src="owner.avatar"
:alt="owner.displayName"
@click="handleRouteToUserDetail(owner)"
/>
<VAvatar
v-for="contributor in contributorsWithoutOwner"
:key="contributor.name" :key="contributor.name"
v-tooltip="contributor.displayName" v-tooltip="contributor.displayName"
:src="contributor.avatar" :src="contributor.avatar"

View File

@ -25,6 +25,7 @@ import { tagSelect } from "./inputs/tag-select";
import { verificationForm } from "./inputs/verify-form"; import { verificationForm } from "./inputs/verify-form";
import theme from "./theme"; import theme from "./theme";
import { userSelect } from "./inputs/user-select";
import autoScrollToErrors from "./plugins/auto-scroll-to-errors"; import autoScrollToErrors from "./plugins/auto-scroll-to-errors";
import passwordPreventAutocomplete from "./plugins/password-prevent-autocomplete"; import passwordPreventAutocomplete from "./plugins/password-prevent-autocomplete";
import radioAlt from "./plugins/radio-alt"; import radioAlt from "./plugins/radio-alt";
@ -65,6 +66,7 @@ const config: DefaultConfigOptions = {
tagCheckbox, tagCheckbox,
tagSelect, tagSelect,
verificationForm, verificationForm,
userSelect,
}, },
locales: { zh, en }, locales: { zh, en },
locale: "zh", locale: "zh",

View File

@ -0,0 +1,26 @@
// TODO: This is a temporary approach.
// We will provide searchable user selection components in the future.
import type { FormKitNode, FormKitTypeDefinition } from "@formkit/core";
import { defaultIcon, select, selects } from "@formkit/inputs";
import { coreApiClient } from "@halo-dev/api-client";
function optionsHandler(node: FormKitNode) {
node.on("created", async () => {
const { data } = await coreApiClient.user.listUser();
node.props.options = data.items.map((user) => {
return {
value: user.metadata.name,
label: user.spec.displayName,
};
});
});
}
export const userSelect: FormKitTypeDefinition = {
...select,
props: ["placeholder"],
forceTypeProp: "select",
features: [optionsHandler, selects, defaultIcon("select", "select")],
};

View File

@ -279,6 +279,8 @@ core:
label: Template label: Template
cover: cover:
label: Cover label: Cover
owner:
label: Owner
tag: tag:
filters: filters:
sort: sort:
@ -306,6 +308,8 @@ core:
visible_value: "Select visible option " visible_value: "Select visible option "
allow_comment_group: " Allow comment" allow_comment_group: " Allow comment"
allow_comment_value: Choose whether to allow comments allow_comment_value: Choose whether to allow comments
owner_group: Owner
owner_value: Select owner
deleted_post: deleted_post:
title: Deleted Posts title: Deleted Posts
empty: empty:

View File

@ -267,6 +267,8 @@ core:
label: 自定义模板 label: 自定义模板
cover: cover:
label: 封面图 label: 封面图
owner:
label: 作者
tag: tag:
filters: filters:
sort: sort:
@ -294,6 +296,8 @@ core:
visible_value: 选择可见性 visible_value: 选择可见性
allow_comment_group: 允许评论 allow_comment_group: 允许评论
allow_comment_value: 选择是否允许评论 allow_comment_value: 选择是否允许评论
owner_group: 作者
owner_value: 设置作者
deleted_post: deleted_post:
title: 文章回收站 title: 文章回收站
empty: empty:

View File

@ -267,6 +267,8 @@ core:
label: 自定義模板 label: 自定義模板
cover: cover:
label: 封面圖 label: 封面圖
owner:
label: 作者
batch_setting_modal: batch_setting_modal:
title: 文章批量設置 title: 文章批量設置
fields: fields:
@ -286,6 +288,8 @@ core:
visible_value: 選擇可見性 visible_value: 選擇可見性
allow_comment_group: 允許評論 allow_comment_group: 允許評論
allow_comment_value: 選擇是否允許評論 allow_comment_value: 選擇是否允許評論
owner_group: 作者
owner_value: 設置作者
deleted_post: deleted_post:
title: 文章回收站 title: 文章回收站
empty: empty:

View File

@ -1,6 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import StatusDotField from "@/components/entity-fields/StatusDotField.vue"; import StatusDotField from "@/components/entity-fields/StatusDotField.vue";
import HasPermission from "@/components/permission/HasPermission.vue"; import HasPermission from "@/components/permission/HasPermission.vue";
import PostContributorList from "@/components/user/PostContributorList.vue";
import { postLabels } from "@/constants/labels"; import { postLabels } from "@/constants/labels";
import { formatDatetime } from "@/utils/date"; import { formatDatetime } from "@/utils/date";
import PostTag from "@console/modules/contents/posts/tags/components/PostTag.vue"; import PostTag from "@console/modules/contents/posts/tags/components/PostTag.vue";
@ -13,8 +14,6 @@ import {
IconEyeOff, IconEyeOff,
IconTimerLine, IconTimerLine,
Toast, Toast,
VAvatar,
VAvatarGroup,
VDropdownDivider, VDropdownDivider,
VDropdownItem, VDropdownItem,
VEntity, VEntity,
@ -180,15 +179,11 @@ function handleUnpublish() {
<template #end> <template #end>
<VEntityField> <VEntityField>
<template #description> <template #description>
<VAvatarGroup size="xs" circle> <PostContributorList
<VAvatar :owner="post.owner"
v-for="{ name, avatar, displayName } in post.contributors" :contributors="post.contributors"
:key="name" :allow-view-user-detail="false"
v-tooltip="displayName" />
:src="avatar"
:alt="displayName"
></VAvatar>
</VAvatarGroup>
</template> </template>
</VEntityField> </VEntityField>
<VEntityField :description="publishStatus"> <VEntityField :description="publishStatus">