halo/src/modules/contents/posts/PostList.vue

834 lines
30 KiB
Vue

<script lang="ts" setup>
import {
IconAddCircle,
IconArrowDown,
IconArrowLeft,
IconArrowRight,
IconBookRead,
IconEye,
IconEyeOff,
IconSettings,
IconTeam,
useDialog,
VButton,
VCard,
VEmpty,
VPageHeader,
VPagination,
VSpace,
VTag,
} from "@halo-dev/components";
import PostSettingModal from "./components/PostSettingModal.vue";
import PostTag from "../posts/tags/components/PostTag.vue";
import { onMounted, ref, watch, watchEffect } from "vue";
import type { ListedPostList, Post, PostRequest } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
import { formatDatetime } from "@/utils/date";
import { useUserFetch } from "@/modules/system/users/composables/use-user";
import { usePostCategory } from "@/modules/contents/posts/categories/composables/use-post-category";
import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
import cloneDeep from "lodash.clonedeep";
import { postLabels } from "@/constants/labels";
enum PostPhase {
DRAFT = "未发布",
PENDING_APPROVAL = "待审核",
PUBLISHED = "已发布",
}
const posts = ref<ListedPostList>({
page: 1,
size: 20,
total: 0,
items: [],
first: true,
last: false,
hasNext: false,
hasPrevious: false,
});
const loading = ref(false);
const settingModal = ref(false);
const selectedPost = ref<Post | null>(null);
const selectedPostWithContent = ref<PostRequest | null>(null);
const checkedAll = ref(false);
const selectedPostNames = ref<string[]>([]);
const { users } = useUserFetch();
const { categories } = usePostCategory();
const { tags } = usePostTag();
const dialog = useDialog();
const handleFetchPosts = async () => {
try {
loading.value = true;
const labelSelector: string[] = [];
if (selectedVisibleFilterItem.value.value) {
labelSelector.push(
`${postLabels.VISIBLE}=${selectedVisibleFilterItem.value.value}`
);
}
if (selectedPhaseFilterItem.value.value) {
labelSelector.push(
`${postLabels.PHASE}=${selectedPhaseFilterItem.value.value}`
);
}
const { data } = await apiClient.post.listPosts({
page: posts.value.page,
size: posts.value.size,
labelSelector,
});
posts.value = data;
} catch (e) {
console.error("Failed to fetch posts", e);
} finally {
loading.value = false;
}
};
const handlePaginationChange = ({
page,
size,
}: {
page: number;
size: number;
}) => {
posts.value.page = page;
posts.value.size = size;
handleFetchPosts();
};
const handleOpenSettingModal = async (post: Post) => {
const { data } = await apiClient.extension.post.getcontentHaloRunV1alpha1Post(
{
name: post.metadata.name,
}
);
selectedPost.value = data;
settingModal.value = true;
};
const onSettingModalClose = () => {
selectedPost.value = null;
selectedPostWithContent.value = null;
handleFetchPosts();
};
const handleSelectPrevious = async () => {
const { items, hasPrevious } = posts.value;
const index = items.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index > 0) {
const { data } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: items[index - 1].post.metadata.name,
});
selectedPost.value = data;
return;
}
if (index === 0 && hasPrevious) {
posts.value.page--;
await handleFetchPosts();
selectedPost.value = posts.value.items[posts.value.items.length - 1].post;
}
};
const handleSelectNext = async () => {
const { items, hasNext } = posts.value;
const index = items.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (index < items.length - 1) {
const { data } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post({
name: items[index + 1].post.metadata.name,
});
selectedPost.value = data;
return;
}
if (index === items.length - 1 && hasNext) {
posts.value.page++;
await handleFetchPosts();
selectedPost.value = posts.value.items[0].post;
}
};
const checkSelection = (post: Post) => {
return (
post.metadata.name === selectedPost.value?.metadata.name ||
selectedPostNames.value.includes(post.metadata.name)
);
};
const finalStatus = (post: Post) => {
if (post.status?.phase) {
return PostPhase[post.status.phase];
}
return "";
};
const handleCheckAllChange = (e: Event) => {
const { checked } = e.target as HTMLInputElement;
if (checked) {
selectedPostNames.value =
posts.value.items.map((post) => {
return post.post.metadata.name;
}) || [];
} else {
selectedPostNames.value.length = 0;
}
};
const handleDelete = async (post: Post) => {
dialog.warning({
title: "是否确认删除该文章?",
confirmType: "danger",
onConfirm: async () => {
const postToUpdate = cloneDeep(post);
postToUpdate.spec.deleted = true;
await apiClient.extension.post.updatecontentHaloRunV1alpha1Post({
name: postToUpdate.metadata.name,
post: postToUpdate,
});
await handleFetchPosts();
},
});
};
watch(selectedPostNames, (newValue) => {
checkedAll.value = newValue.length === posts.value.items?.length;
});
watchEffect(async () => {
if (!selectedPost.value || !selectedPost.value.spec.headSnapshot) {
return;
}
const { data: content } = await apiClient.content.obtainSnapshotContent({
snapshotName: selectedPost.value.spec.headSnapshot,
});
selectedPostWithContent.value = {
post: selectedPost.value,
content: content,
};
});
onMounted(() => {
handleFetchPosts();
});
interface FilterItem {
label: string;
value: string | undefined;
}
const VisibleFilterItems: FilterItem[] = [
{
label: "全部",
value: "",
},
{
label: "公开",
value: "PUBLIC",
},
{
label: "内部成员可访问",
value: "INTERNAL",
},
{
label: "私有",
value: "PRIVATE",
},
];
const PhaseFilterItems: FilterItem[] = [
{
label: "全部",
value: "",
},
{
label: "已发布",
value: "PUBLISHED",
},
{
label: "未发布",
value: "DRAFT",
},
{
label: "待审核",
value: "PENDING_APPROVAL",
},
];
const selectedVisibleFilterItem = ref<FilterItem>(VisibleFilterItems[0]);
const selectedPhaseFilterItem = ref<FilterItem>(PhaseFilterItems[0]);
function handleVisibleFilterItemChange(filterItem: FilterItem) {
selectedVisibleFilterItem.value = filterItem;
handleFetchPosts();
}
function handlePhaseFilterItemChange(filterItem: FilterItem) {
selectedPhaseFilterItem.value = filterItem;
handleFetchPosts();
}
</script>
<template>
<PostSettingModal
v-model:visible="settingModal"
:post="selectedPostWithContent"
@close="onSettingModalClose"
>
<template #actions>
<div class="modal-header-action" @click="handleSelectPrevious">
<IconArrowLeft />
</div>
<div class="modal-header-action" @click="handleSelectNext">
<IconArrowRight />
</div>
</template>
</PostSettingModal>
<VPageHeader title="文章">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
<VButton :route="{ name: 'PostEditor' }" type="secondary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<div class="block w-full bg-gray-50 px-4 py-3">
<div
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div class="mr-4 hidden items-center sm:flex">
<input
v-model="checkedAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 sm:w-auto">
<div v-if="!selectedPostNames.length">
<FormKit placeholder="输入关键词搜索" type="text"></FormKit>
</div>
<VSpace v-else>
<VButton type="default">设置</VButton>
<VButton type="danger">删除</VButton>
</VSpace>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace spacing="lg">
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">状态</span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(filterItem, index) in PhaseFilterItems"
:key="index"
v-close-popper
:class="{
'bg-gray-100':
selectedPhaseFilterItem.value ===
filterItem.value,
}"
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handlePhaseFilterItemChange(filterItem)"
>
<span class="truncate">{{ filterItem.label }}</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5"> 可见性 </span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
v-for="(filterItem, index) in VisibleFilterItems"
:key="index"
v-close-popper
:class="{
'bg-gray-100':
selectedVisibleFilterItem.value ===
filterItem.value,
}"
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
@click="handleVisibleFilterItemChange(filterItem)"
>
<span class="truncate">
{{ filterItem.label }}
</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">分类</span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="h-96 w-80">
<div class="bg-white p-4">
<FormKit
placeholder="输入关键词搜索"
type="text"
></FormKit>
</div>
<div class="mt-2">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
>
<li
v-for="(category, index) in categories"
:key="index"
v-close-popper
>
<div
class="group relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div class="relative flex flex-row items-center">
<div class="flex-1">
<div class="flex flex-col sm:flex-row">
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
>
{{ category.spec.displayName }}
</span>
<VSpace class="mt-1 sm:mt-0"></VSpace>
</div>
<div class="mt-1 flex">
<span class="text-xs text-gray-500">
/categories/{{ category.spec.slug }}
</span>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<div
class="cursor-pointer text-sm text-gray-500 hover:text-gray-900"
>
20 篇文章
</div>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
</FloatingDropdown>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">标签</span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="h-96 w-80">
<div class="bg-white p-4">
<FormKit
placeholder="输入关键词搜索"
type="text"
></FormKit>
</div>
<div class="mt-2">
<ul
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li
v-for="(tag, index) in tags"
:key="index"
v-close-popper
>
<div
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div class="relative flex flex-row items-center">
<div class="flex-1">
<div class="flex flex-col sm:flex-row">
<PostTag :tag="tag" />
</div>
<div class="mt-1 flex">
<span class="text-xs text-gray-500">
/tags/{{ tag.spec.slug }}
</span>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<div
class="cursor-pointer text-sm text-gray-500 hover:text-gray-900"
>
20 篇文章
</div>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
</FloatingDropdown>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">作者</span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="h-96 w-80">
<div class="bg-white p-4">
<!--TODO: Auto Focus-->
<FormKit
placeholder="输入关键词搜索"
type="text"
></FormKit>
</div>
<div class="mt-2">
<ul class="divide-y divide-gray-200" role="list">
<li
v-for="(user, index) in users"
:key="index"
v-close-popper
class="cursor-pointer hover:bg-gray-50"
>
<div class="flex items-center space-x-4 px-4 py-3">
<div class="flex-shrink-0">
<img
:alt="user.spec.displayName"
:src="user.spec.avatar"
class="h-10 w-10 rounded"
/>
</div>
<div class="min-w-0 flex-1">
<p
class="truncate text-sm font-medium text-gray-900"
>
{{ user.spec.displayName }}
</p>
<p class="truncate text-sm text-gray-500">
@{{ user.metadata.name }}
</p>
</div>
<div>
<VTag>{{ index + 1 }} 篇</VTag>
</div>
</div>
</li>
</ul>
</div>
</div>
</template>
</FloatingDropdown>
<FloatingDropdown>
<div
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
>
<span class="mr-0.5">排序</span>
<span>
<IconArrowDown />
</span>
</div>
<template #popper>
<div class="w-72 p-4">
<ul class="space-y-1">
<li
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">较近发布</span>
</li>
<li
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">较晚发布</span>
</li>
<li
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">浏览量最多</span>
</li>
<li
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">浏览量最少</span>
</li>
<li
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">评论量最多</span>
</li>
<li
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
>
<span class="truncate">评论量最少</span>
</li>
</ul>
</div>
</template>
</FloatingDropdown>
</VSpace>
</div>
</div>
</div>
</template>
<VEmpty
v-if="!posts.items.length && !loading"
message="你可以尝试刷新或者新建文章"
title="当前没有文章"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchPosts">刷新</VButton>
<VButton :route="{ name: 'PostEditor' }" type="primary">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建文章
</VButton>
</VSpace>
</template>
</VEmpty>
<ul
v-else
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(post, index) in posts.items" :key="index">
<div
:class="{
'bg-gray-100': checkSelection(post.post),
}"
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div
v-show="checkSelection(post.post)"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="relative flex flex-row items-center">
<div class="mr-4 hidden items-center sm:flex">
<input
v-model="selectedPostNames"
:value="post.post.metadata.name"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
name="post-checkbox"
type="checkbox"
/>
</div>
<div class="flex-1">
<div class="flex flex-col items-center sm:flex-row">
<RouterLink
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
>
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
>
{{ post.post.spec.title }}
</span>
</RouterLink>
<VSpace class="mt-1 sm:mt-0">
<FloatingTooltip
v-if="post.post.status?.inProgress"
class="hidden items-center sm:flex"
>
<RouterLink
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
class="flex items-center"
>
<div
class="inline-flex h-1.5 w-1.5 rounded-full bg-orange-600"
>
<span
class="inline-block h-1.5 w-1.5 animate-ping rounded-full bg-orange-600"
></span>
</div>
</RouterLink>
<template #popper>
当前有内容已保存,但还未发布。
</template>
</FloatingTooltip>
<PostTag
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:tag="tag"
route
></PostTag>
</VSpace>
</div>
<div class="mt-1 flex">
<VSpace>
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
分类:<span
v-for="(category, categoryIndex) in post.categories"
:key="categoryIndex"
class="cursor-pointer hover:text-gray-900"
>
{{ category.spec.displayName }}
</span>
</p>
<span class="text-xs text-gray-500">访问量 0</span>
<span class="text-xs text-gray-500"> 评论 0 </span>
</VSpace>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<RouterLink
v-for="(contributor, contributorIndex) in post.contributors"
:key="contributorIndex"
:to="{
name: 'UserDetail',
params: { name: contributor.name },
}"
>
<img
v-tooltip="contributor.displayName"
:alt="contributor.name"
:src="contributor.avatar"
:title="contributor.displayName"
class="hidden h-6 w-6 rounded-full ring-2 ring-white sm:inline-block"
/>
</RouterLink>
<span class="text-sm text-gray-500">
{{ finalStatus(post.post) }}
</span>
<span>
<IconEye
v-if="post.post.spec.visible === 'PUBLIC'"
v-tooltip="`公开访问`"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<IconEyeOff
v-if="post.post.spec.visible === 'PRIVATE'"
v-tooltip="`私有访问`"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
<IconTeam
v-if="post.post.spec.visible === 'INTERNAL'"
v-tooltip="`内部成员可访问`"
class="cursor-pointer text-sm transition-all hover:text-blue-600"
/>
</span>
<time class="text-sm text-gray-500">
{{ formatDatetime(post.post.metadata.creationTimestamp) }}
</time>
<span>
<FloatingDropdown>
<IconSettings
class="cursor-pointer transition-all hover:text-blue-600"
/>
<template #popper>
<div class="w-48 p-2">
<VSpace class="w-full" direction="column">
<VButton
v-close-popper
block
type="secondary"
@click="handleOpenSettingModal(post.post)"
>
设置
</VButton>
<VButton
v-close-popper
block
type="danger"
@click="handleDelete(post.post)"
>
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</span>
</div>
</div>
</div>
</div>
</li>
</ul>
<template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination
:page="posts.page"
:size="posts.size"
:total="posts.total"
@change="handlePaginationChange"
/>
</div>
</template>
</VCard>
</div>
</template>