feat: post basic management capability (#599)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?

/kind feature
/milestone 2.0

<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind optimization

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

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

文章管理相关模块。适配 https://github.com/halo-dev/halo/pull/2326

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

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes https://github.com/halo-dev/halo/issues/2322

#### Screenshots:

// pending

<!--
如果此 PR 有 UI 的改动,最好截图说明这个 PR 的改动。
If there are UI changes to this PR, it is best to take a screenshot to illustrate the changes to this PR.

eg.

Before:

![screenshot-before](https://user-images.githubusercontent.com/screenshot.png)

After:

![screenshot-after](https://user-images.githubusercontent.com/screenshot.png)
-->

#### Special notes for your reviewer:

// pending

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

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/603/head
Ryan Wang 2022-08-23 12:08:10 +08:00 committed by GitHub
parent 73d072a8fa
commit 3ee45a117e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1993 additions and 1484 deletions

View File

@ -32,15 +32,16 @@
"@formkit/inputs": "1.0.0-beta.10",
"@formkit/themes": "1.0.0-beta.10",
"@formkit/vue": "1.0.0-beta.10",
"@halo-dev/admin-api": "^1.1.0",
"@halo-dev/admin-shared": "workspace:*",
"@halo-dev/api-client": "^0.0.10",
"@halo-dev/api-client": "^0.0.12",
"@halo-dev/components": "workspace:*",
"@halo-dev/richtext-editor": "0.0.0-alpha.3",
"@vueuse/components": "^8.9.4",
"@vueuse/core": "^8.9.4",
"@vueuse/router": "^9.1.0",
"axios": "^0.27.2",
"colorjs.io": "^0.4.0",
"dayjs": "^1.11.5",
"filepond": "^4.30.4",
"filepond-plugin-image-preview": "^4.6.11",
"floating-vue": "2.0.0-beta.19",

View File

@ -1,4 +1,5 @@
<script lang="ts" setup>
import type { CSSProperties } from "vue";
import { computed } from "vue";
import type { Theme } from "./interface";
@ -6,10 +7,14 @@ const props = withDefaults(
defineProps<{
theme?: Theme;
rounded?: boolean;
styles?: CSSProperties;
}>(),
{
theme: "default",
rounded: false,
styles: () => {
return {};
},
}
);
@ -18,7 +23,7 @@ const classes = computed(() => {
});
</script>
<template>
<div :class="classes" class="tag-wrapper">
<div :class="classes" :style="styles" class="tag-wrapper">
<div v-if="$slots.leftIcon" class="tag-left-icon">
<slot name="leftIcon" />
</div>

View File

@ -14,6 +14,7 @@ import IconUserSettings from "~icons/ri/user-settings-line";
import IconSettings from "~icons/ri/settings-4-line";
import IconPlug from "~icons/ri/plug-2-line";
import IconEye from "~icons/ri/eye-line";
import IconEyeOff from "~icons/ri/eye-off-line";
import IconFolder from "~icons/ri/folder-2-line";
import IconMore from "~icons/ri/more-line";
import IconClose from "~icons/ri/close-line";
@ -41,6 +42,7 @@ import IconStopCircle from "~icons/ri/stop-circle-line";
import IconForbidLine from "~icons/ri/forbid-line";
import IconCodeBoxLine from "~icons/ri/code-box-line";
import IconDatabase2Line from "~icons/ri/database-2-line";
import IconTeam from "~icons/ri/team-fill";
export {
IconDashboard,
@ -57,6 +59,7 @@ export {
IconSettings,
IconPlug,
IconEye,
IconEyeOff,
IconFolder,
IconMore,
IconClose,
@ -86,4 +89,5 @@ export {
IconForbidLine,
IconCodeBoxLine,
IconDatabase2Line,
IconTeam,
};

View File

@ -36,7 +36,7 @@
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
"license": "MIT",
"dependencies": {
"@halo-dev/api-client": "^0.0.10",
"@halo-dev/api-client": "^0.0.12",
"@halo-dev/components": "workspace:*",
"axios": "^0.27.2"
},

View File

@ -1,18 +1,26 @@
import {
ApiHaloRunV1alpha1ContentApi,
ApiHaloRunV1alpha1PluginApi,
ApiHaloRunV1alpha1PostApi,
ApiHaloRunV1alpha1ThemeApi,
ApiHaloRunV1alpha1UserApi,
ContentHaloRunV1alpha1CategoryApi,
ContentHaloRunV1alpha1CommentApi,
ContentHaloRunV1alpha1PostApi,
ContentHaloRunV1alpha1ReplyApi,
ContentHaloRunV1alpha1SnapshotApi,
ContentHaloRunV1alpha1TagApi,
PluginHaloRunV1alpha1PluginApi,
PluginHaloRunV1alpha1ReverseProxyApi,
ThemeHaloRunV1alpha1ThemeApi,
V1alpha1ConfigMapApi,
V1alpha1MenuApi,
V1alpha1MenuItemApi,
V1alpha1PersonalAccessTokenApi,
V1alpha1RoleApi,
V1alpha1RoleBindingApi,
V1alpha1SettingApi,
V1alpha1UserApi,
V1alpha1MenuApi,
V1alpha1MenuItemApi,
ThemeHaloRunV1alpha1ThemeApi,
ApiHaloRunV1alpha1ThemeApi,
} from "@halo-dev/api-client";
import type { AxiosInstance } from "axios";
import axios from "axios";
@ -65,10 +73,19 @@ function setupApiClient(axios: AxiosInstance) {
theme: new ThemeHaloRunV1alpha1ThemeApi(undefined, apiUrl, axios),
menu: new V1alpha1MenuApi(undefined, apiUrl, axios),
menuItem: new V1alpha1MenuItemApi(undefined, apiUrl, axios),
post: new ContentHaloRunV1alpha1PostApi(undefined, apiUrl, axios),
category: new ContentHaloRunV1alpha1CategoryApi(undefined, apiUrl, axios),
tag: new ContentHaloRunV1alpha1TagApi(undefined, apiUrl, axios),
snapshot: new ContentHaloRunV1alpha1SnapshotApi(undefined, apiUrl, axios),
comment: new ContentHaloRunV1alpha1CommentApi(undefined, apiUrl, axios),
reply: new ContentHaloRunV1alpha1ReplyApi(undefined, apiUrl, axios),
},
// custom endpoints
user: new ApiHaloRunV1alpha1UserApi(undefined, apiUrl, axios),
plugin: new ApiHaloRunV1alpha1PluginApi(undefined, apiUrl, axios),
theme: new ApiHaloRunV1alpha1ThemeApi(undefined, apiUrl, axios),
post: new ApiHaloRunV1alpha1PostApi(undefined, apiUrl, axios),
content: new ApiHaloRunV1alpha1ContentApi(undefined, apiUrl, axios),
};
}

View File

@ -12,9 +12,8 @@ importers:
'@formkit/inputs': 1.0.0-beta.10
'@formkit/themes': 1.0.0-beta.10
'@formkit/vue': 1.0.0-beta.10
'@halo-dev/admin-api': ^1.1.0
'@halo-dev/admin-shared': workspace:*
'@halo-dev/api-client': ^0.0.10
'@halo-dev/api-client': ^0.0.12
'@halo-dev/components': workspace:*
'@halo-dev/richtext-editor': 0.0.0-alpha.3
'@rushstack/eslint-patch': ^1.1.4
@ -39,7 +38,9 @@ importers:
autoprefixer: ^10.4.8
axios: ^0.27.2
c8: ^7.12.0
colorjs.io: ^0.4.0
cypress: ^9.7.0
dayjs: ^1.11.5
eslint: ^8.22.0
eslint-plugin-cypress: ^2.12.1
eslint-plugin-vue: ^9.3.0
@ -85,15 +86,16 @@ importers:
'@formkit/inputs': 1.0.0-beta.10
'@formkit/themes': 1.0.0-beta.10_tailwindcss@3.1.8
'@formkit/vue': 1.0.0-beta.10_wwmyxdjqen5bmh3tr2meig5lki
'@halo-dev/admin-api': 1.1.0
'@halo-dev/admin-shared': link:packages/shared
'@halo-dev/api-client': 0.0.10
'@halo-dev/api-client': 0.0.12
'@halo-dev/components': link:packages/components
'@halo-dev/richtext-editor': 0.0.0-alpha.3_vue@3.2.37
'@vueuse/components': 8.9.4_vue@3.2.37
'@vueuse/core': 8.9.4_vue@3.2.37
'@vueuse/router': 9.1.0_26a4nhf5pwzjzqc5ckt7ohj5zi
axios: 0.27.2
colorjs.io: 0.4.0
dayjs: 1.11.5
filepond: 4.30.4
filepond-plugin-image-preview: 4.6.11_filepond@4.30.4
floating-vue: 2.0.0-beta.19_vue@3.2.37
@ -182,12 +184,12 @@ importers:
packages/shared:
specifiers:
'@halo-dev/api-client': ^0.0.10
'@halo-dev/api-client': ^0.0.12
'@halo-dev/components': workspace:*
axios: ^0.27.2
vite-plugin-dts: ^1.4.1
dependencies:
'@halo-dev/api-client': 0.0.10
'@halo-dev/api-client': 0.0.12
'@halo-dev/components': link:../components
axios: 0.27.2
devDependencies:
@ -2109,39 +2111,8 @@ packages:
- windicss
dev: false
/@halo-dev/admin-api/1.1.0:
resolution: {integrity: sha512-2K8ulSucPudWfBo8SWz92hLtlu8C7hVVfYNejlns0BZfMxFyivvGyTgeYiMYHgNR5n4K6tF903Zn1DkS7jnv0g==}
engines: {node: '>=12'}
dependencies:
'@halo-dev/rest-api-client': 1.1.0
tslib: 2.4.0
transitivePeerDependencies:
- debug
dev: false
/@halo-dev/api-client/0.0.10:
resolution: {integrity: sha512-DKQKkEAKMR/rbopI6jbjbzLiYUZeY6dOcgqGoDGG8MAcwkWOI6iWaZnuR5z+X8vd51XjiPhnekAphfcO6PaWEQ==}
dev: false
/@halo-dev/logger/1.1.0:
resolution: {integrity: sha512-y0jVivYwF8MCVi/OdW2D0LN+GTM5rzMsR/ZmQVfgmKQw7Q7Q+EXPijxON6iCMZnWANGa4NaAcOO9k3ggG8oRwg==}
engines: {node: '>=12.0.0'}
dependencies:
tslib: 2.4.0
dev: false
/@halo-dev/rest-api-client/1.1.0:
resolution: {integrity: sha512-zoAzaswdgBpkAw8A6zs4N+n03sSwF/YKnLnj9p7PNQoiix1Z1GK+Tc/WV59A+wRB9Xw5W3JZhzvuaOPOe7RpEw==}
engines: {node: '>=12'}
dependencies:
'@halo-dev/logger': 1.1.0
axios: 0.24.0
form-data: 4.0.0
js-base64: 3.7.2
qs: 6.11.0
tslib: 2.4.0
transitivePeerDependencies:
- debug
/@halo-dev/api-client/0.0.12:
resolution: {integrity: sha512-fOI3DB9rOA1Z+h1aKiEQ+2kWkNSmdWIDvd+39dR5b3X0DmKH+zWrNmygA5Qe2gBPX28TNt/zr2qCKUjGjb99CA==}
dev: false
/@halo-dev/richtext-editor/0.0.0-alpha.3_vue@3.2.37:
@ -3950,14 +3921,6 @@ packages:
- debug
dev: true
/axios/0.24.0:
resolution: {integrity: sha512-Q6cWsys88HoPgAaFAVUb0WpPk0O8iTeisR9IMqy9G8AbO4NlpVknrnQS03zzF9PGAWgO3cgletO3VjV/P7VztA==}
dependencies:
follow-redirects: 1.14.9
transitivePeerDependencies:
- debug
dev: false
/axios/0.27.2:
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
dependencies:
@ -4379,6 +4342,10 @@ packages:
resolution: {integrity: sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==}
dev: true
/colorjs.io/0.4.0:
resolution: {integrity: sha512-AUKG9GCDSHsFRUnxGrEMCm6nq6lxddnDvD0avmsy/klCEk68htpqgl9IERGtGoxaGJlr7uP5wmD381gY2uG8hw==}
dev: false
/colors/1.2.5:
resolution: {integrity: sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==}
engines: {node: '>=0.1.90'}
@ -4579,7 +4546,7 @@ packages:
cli-table3: 0.6.1
commander: 5.1.0
common-tags: 1.8.2
dayjs: 1.11.3
dayjs: 1.11.5
debug: 4.3.4_supports-color@8.1.1
enquirer: 2.3.6
eventemitter2: 6.4.5
@ -4623,9 +4590,8 @@ packages:
whatwg-url: 11.0.0
dev: true
/dayjs/1.11.3:
resolution: {integrity: sha512-xxwlswWOlGhzgQ4TKzASQkUhqERI3egRNqgV4ScR8wlANA/A9tZ7miXa44vTTKEq5l7vWoL5G57bG3zA+Kow0A==}
dev: true
/dayjs/1.11.5:
resolution: {integrity: sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==}
/debug/2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
@ -6461,10 +6427,6 @@ packages:
'@sideway/pinpoint': 2.0.0
dev: true
/js-base64/3.7.2:
resolution: {integrity: sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==}
dev: false
/js-tokens/4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
dev: true
@ -8560,6 +8522,7 @@ packages:
/tslib/2.4.0:
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
dev: true
/tsutils/3.21.0_typescript@4.7.4:
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}

View File

@ -7,3 +7,11 @@ export enum pluginLabels {
export enum roleLabels {
TEMPLATE = "halo.run/role-template",
}
// post
export enum postLabels {
DELETED = "content.halo.run/deleted",
OWNER = "content.halo.run/owner",
VISIBLE = "content.halo.run/visible",
PHASE = "content.halo.run/phase",
}

View File

@ -2,14 +2,14 @@ const textClassification = {
label: "block font-bold text-sm formkit-invalid:text-red-500 w-56",
wrapper: "flex flex-col sm:flex-row items-start sm:items-center",
inner:
"inline-flex items-center w-full relative box-border border border-gray-300 formkit-invalid:border-red-500 rounded-base overflow-hidden focus-within:border-primary mt-2 sm:mt-0",
"inline-flex items-center w-full relative box-border border border-gray-300 formkit-invalid:border-red-500 h-9 rounded-base overflow-hidden focus-within:border-primary mt-2 sm:mt-0",
input:
"outline-0 bg-white antialiased resize-none w-full text-black block transition-all appearance-none h-9 px-3 text-sm",
};
const boxClassification = {
fieldset:
"border border-gray-300 rounded-base px-2 pb-1 focus-within:border-primary",
"border border-gray-300 rounded-base px-2 py-2 focus-within:border-primary",
legend: "font-bold text-sm",
wrapper: "flex items-center mb-1 cursor-pointer",
help: "mb-2",

View File

@ -4,24 +4,141 @@ import {
IconSave,
VButton,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import PostSettingModal from "./components/PostSettingModal.vue";
import type { PostRequest } from "@halo-dev/api-client";
import { computed, onMounted, ref } from "vue";
import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@halo-dev/admin-shared";
import { useRouteQuery } from "@vueuse/router";
import { v4 as uuid } from "uuid";
const name = useRouteQuery("name");
const initialFormState: PostRequest = {
post: {
spec: {
title: "",
slug: "",
template: "",
cover: "",
deleted: false,
published: false,
publishTime: undefined,
pinned: false,
allowComment: true,
visible: "PUBLIC",
version: 1,
priority: 0,
excerpt: {
autoGenerate: true,
raw: "",
},
categories: [],
tags: [],
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
name: uuid(),
},
},
content: {
raw: "",
content: "",
rawType: "HTML",
},
};
const formState = ref<PostRequest>(cloneDeep(initialFormState));
const settingModal = ref(false);
const saving = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.post.metadata.creationTimestamp;
});
const handleSavePost = async () => {
try {
saving.value = true;
formState.value.content.content = formState.value.content.raw;
if (isUpdateMode.value) {
const { data } = await apiClient.post.updateDraftPost(
formState.value.post.metadata.name,
formState.value
);
formState.value.post = data;
} else {
const { data } = await apiClient.post.draftPost(formState.value);
formState.value.post = data;
name.value = data.metadata.name;
}
} catch (e) {
alert(`保存异常: ${e}`);
console.error("Failed to save post", e);
} finally {
saving.value = false;
}
};
const onSettingSaved = (post: PostRequest) => {
formState.value = post;
settingModal.value = false;
handleSavePost();
};
onMounted(async () => {
if (name.value) {
// fetch post
const { data: post } =
await apiClient.extension.post.getcontentHaloRunV1alpha1Post(
name.value as string
);
formState.value.post = post;
if (formState.value.post.spec.headSnapshot) {
const { data: content } = await apiClient.content.obtainSnapshotContent(
formState.value.post.spec.headSnapshot
);
formState.value.content = content;
}
}
});
</script>
<template>
<PostSettingModal
v-model:visible="settingModal"
:only-emit="true"
:post="formState"
@saved="onSettingSaved"
/>
<VPageHeader title="文章">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VButton :route="{ name: 'PostEditor' }" type="secondary">
<template #icon>
<IconSave class="h-full w-full" />
</template>
发布
</VButton>
<VSpace>
<VButton
:loading="saving"
size="sm"
type="default"
@click="handleSavePost"
>
保存
</VButton>
<VButton type="secondary" @click="settingModal = true">
<template #icon>
<IconSave class="h-full w-full" />
</template>
发布
</VButton>
</VSpace>
</template>
</VPageHeader>
<div class="editor border-t">
<RichTextEditor />
<RichTextEditor v-model="formState.content.raw" />
</div>
</template>

View File

@ -2,104 +2,284 @@
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 { posts } from "./posts-mock";
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import type { Post } from "@halo-dev/admin-api";
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 type { User } from "@halo-dev/api-client";
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";
const postsRef = ref(
// eslint-disable-next-line
posts.map((item: any) => {
return {
...item,
checked: false,
};
})
);
enum PostPhase {
DRAFT = "未发布",
PENDING_APPROVAL = "待审核",
PUBLISHED = "已发布",
}
const router = useRouter();
const checkAll = ref(false);
const postSettings = ref(false);
// eslint-disable-next-line
const selected = ref<Post | Record<string, unknown> | null>({});
const users = ref<User[]>([]);
const checkedCount = computed(() => {
return postsRef.value.filter((post) => post.checked).length;
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 handleFetchUsers = async () => {
const { users } = useUserFetch();
const { categories } = usePostCategory();
const { tags } = usePostTag();
const dialog = useDialog();
const handleFetchPosts = async () => {
try {
const { data } = await apiClient.extension.user.listv1alpha1User();
users.value = data.items;
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(
posts.value.page,
posts.value.size,
labelSelector
);
posts.value = data;
} catch (e) {
console.error(e);
console.error("Failed to fetch posts", e);
} finally {
loading.value = false;
}
};
const handleCheckAll = () => {
postsRef.value.forEach((item) => {
item.checked = checkAll.value;
});
const handlePaginationChange = ({
page,
size,
}: {
page: number;
size: number;
}) => {
posts.value.page = page;
posts.value.size = size;
handleFetchPosts();
};
// eslint-disable-next-line
const handleSelect = (post: any) => {
selected.value = post;
postSettings.value = true;
const handleOpenSettingModal = (post: Post) => {
selectedPost.value = post;
settingModal.value = true;
};
const handleSelectPrevious = () => {
const currentIndex = posts.findIndex(
(post) => post.id === selected.value?.id
const onSettingModalClose = () => {
selectedPost.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 (currentIndex > 0) {
selected.value = posts[currentIndex - 1];
if (index > 0) {
selectedPost.value = items[index - 1].post;
return;
}
if (index === 0 && hasPrevious) {
posts.value.page--;
await handleFetchPosts();
selectedPost.value = posts.value.items[posts.value.items.length - 1].post;
}
};
const handleSelectNext = () => {
const currentIndex = posts.findIndex(
(post) => post.id === selected.value?.id
const handleSelectNext = async () => {
const { items, hasNext } = posts.value;
const index = items.findIndex(
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
);
if (currentIndex < posts.length - 1) {
selected.value = posts[currentIndex + 1];
if (index < items.length - 1) {
selectedPost.value = items[index + 1].post;
return;
}
if (index === items.length - 1 && hasNext) {
posts.value.page++;
await handleFetchPosts();
selectedPost.value = posts.value.items[0].post;
}
};
// eslint-disable-next-line
const handleRouteToEditor = (post: any) => {
router.push({
name: "PostEditor",
params: {
id: post.id,
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(
postToUpdate.metadata.name,
postToUpdate
);
await handleFetchPosts();
},
});
};
onMounted(() => {
handleFetchUsers();
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(
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="postSettings"
:post="selected"
@next="handleSelectNext"
@previous="handleSelectPrevious"
/>
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" />
@ -107,6 +287,7 @@ onMounted(() => {
<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" />
@ -126,18 +307,16 @@ onMounted(() => {
>
<div class="mr-4 hidden items-center sm:flex">
<input
v-model="checkAll"
v-model="checkedAll"
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
@change="handleCheckAll()"
@change="handleCheckAllChange"
/>
</div>
<div class="flex w-full flex-1 sm:w-auto">
<FormKit
v-if="checkedCount <= 0"
placeholder="输入关键词搜索"
type="text"
></FormKit>
<div v-if="!selectedPostNames.length">
<FormKit placeholder="输入关键词搜索" type="text"></FormKit>
</div>
<VSpace v-else>
<VButton type="default">设置</VButton>
<VButton type="danger">删除</VButton>
@ -158,24 +337,50 @@ onMounted(() => {
<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">全部</span>
<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">已发布</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>
<span class="truncate">
{{ filterItem.label }}
</span>
</li>
</ul>
</div>
@ -191,11 +396,57 @@ onMounted(() => {
</span>
</div>
<template #popper>
<div class="h-96 w-80 p-4">
<FormKit
placeholder="输入关键词搜索"
type="text"
></FormKit>
<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 flex-col-reverse 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>
@ -209,11 +460,54 @@ onMounted(() => {
</span>
</div>
<template #popper>
<div class="h-96 w-80 p-4">
<FormKit
placeholder="输入关键词搜索"
type="text"
></FormKit>
<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 flex-col-reverse 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>
@ -227,8 +521,8 @@ onMounted(() => {
</span>
</div>
<template #popper>
<div class="h-96 w-80 p-4">
<div class="bg-white">
<div class="h-96 w-80">
<div class="bg-white p-4">
<!--TODO: Auto Focus-->
<FormKit
placeholder="输入关键词搜索"
@ -240,15 +534,10 @@ onMounted(() => {
<li
v-for="(user, index) in users"
:key="index"
class="cursor-pointer py-4 hover:bg-gray-50"
v-close-popper
class="cursor-pointer hover:bg-gray-50"
>
<div class="flex items-center space-x-4">
<div class="flex items-center">
<input
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
type="checkbox"
/>
</div>
<div class="flex items-center space-x-4 px-4 py-3">
<div class="flex-shrink-0">
<img
:alt="user.spec.displayName"
@ -327,48 +616,93 @@ onMounted(() => {
</div>
</div>
</template>
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="(post, index) in postsRef" :key="index">
<VEmpty
v-if="!posts.items.length && !loading"
message="你可以尝试刷新或者新建文章"
title="当前没有文章"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchPosts"></VButton>
<VButton type="primary" :route="{ name: 'PostEditor' }">
<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': selected?.id === post.id || post.checked,
'bg-gray-100': checkSelection(post.post),
}"
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div
v-show="selected?.id === post.id || post.checked"
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="post.checked"
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 sm:flex-row">
<span
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
@click="handleRouteToEditor(post)"
<RouterLink
:to="{
name: 'PostEditor',
query: { name: post.post.metadata.name },
}"
>
{{ post.title }}
</span>
<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">
<VTag v-for="(tag, tagIndex) in post.tags" :key="tagIndex">
{{ tag.name }}
</VTag>
<RouterLink
v-for="(tag, tagIndex) in post.tags"
:key="tagIndex"
:to="{
name: 'Tags',
query: { name: tag.metadata.name },
}"
>
<PostTag :tag="tag"></PostTag>
</RouterLink>
</VSpace>
</div>
<div class="mt-1 flex">
<VSpace>
<span class="text-xs text-gray-500"
>访问量 {{ post.visits }}</span
>
<span class="text-xs text-gray-500"
>评论 {{ post.commentCount }}</span
<p
v-if="post.categories.length"
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
>
分类<span
v-for="(category, index) in post.categories"
:key="index"
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>
@ -376,15 +710,73 @@ onMounted(() => {
<div
class="inline-flex flex-col flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<img
class="hidden h-6 w-6 rounded-full ring-2 ring-white sm:inline-block"
src="https://ryanc.cc/avatar"
/>
<time class="text-sm text-gray-500" datetime="2020-01-07">
2020-01-07
<RouterLink
v-for="(contributor, index) in post.contributors"
:key="index"
: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 class="cursor-pointer">
<IconSettings @click.stop="handleSelect(post)" />
<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>
@ -395,7 +787,12 @@ onMounted(() => {
<template #footer>
<div class="bg-white sm:flex sm:items-center sm:justify-end">
<VPagination :page="1" :size="10" :total="20" />
<VPagination
:page="posts.page"
:size="posts.size"
:total="posts.total"
@change="handlePaginationChange"
/>
</div>
</template>
</VCard>

View File

@ -1,25 +1,96 @@
<script lang="ts" setup>
// core libs
import { ref } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
// components
import {
IconAddCircle,
IconBookRead,
IconList,
IconSettings,
VButton,
VCard,
VEmpty,
VPageHeader,
VSpace,
} from "@halo-dev/components";
import CategoryEditingModal from "./components/CategoryEditingModal.vue";
import CategoryListItem from "./components/CategoryListItem.vue";
import { ref } from "vue";
// types
import type { Category } from "@halo-dev/api-client";
import type { CategoryTree } from "./utils";
import {
convertCategoryTreeToCategory,
convertTreeToCategories,
resetCategoriesTreePriority,
} from "./utils";
// libs
import { useDebounceFn } from "@vueuse/core";
// hooks
import { usePostCategory } from "./composables/use-post-category";
const editingModal = ref(false);
const selectedCategory = ref<Category | null>(null);
const {
categories,
categoriesTree,
loading,
handleFetchCategories,
handleDelete,
} = usePostCategory();
const handleUpdateInBatch = useDebounceFn(async () => {
const categoriesTreeToUpdate = resetCategoriesTreePriority(
categoriesTree.value
);
const categoriesToUpdate = convertTreeToCategories(categoriesTreeToUpdate);
try {
const promises = categoriesToUpdate.map((category) =>
apiClient.extension.category.updatecontentHaloRunV1alpha1Category(
category.metadata.name,
category
)
);
await Promise.all(promises);
} catch (e) {
console.log("Failed to update categories", e);
} finally {
await handleFetchCategories();
}
}, 500);
const handleOpenEditingModal = (category: CategoryTree) => {
selectedCategory.value = convertCategoryTreeToCategory(category);
editingModal.value = true;
};
const onEditingModalClose = () => {
selectedCategory.value = null;
handleFetchCategories();
};
</script>
<template>
<CategoryEditingModal v-model:visible="editingModal" />
<CategoryEditingModal
v-model:visible="editingModal"
:category="selectedCategory"
@close="onEditingModalClose"
/>
<VPageHeader title="文章分类">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VButton type="secondary" @click="editingModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
@ -29,81 +100,37 @@ const editingModal = ref(false);
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium"> {{ 10 }} 个分类 </span>
</div>
<div class="mt-4 flex sm:mt-0">
<VSpace>
<VButton size="xs" type="default" @click="editingModal = true">
新增
</VButton>
</VSpace>
<span class="text-base font-medium">
{{ categories.length }} 个分类
</span>
</div>
</div>
</div>
</template>
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="i in 10" :key="i">
<div
class="group relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div
class="drag-element absolute inset-y-0 left-0 flex hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex"
>
<IconList class="h-3.5 w-3.5" />
</div>
<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"
>
主题
</span>
<VSpace class="mt-1 sm:mt-0"></VSpace>
</div>
<div class="mt-1 flex">
<span class="text-xs text-gray-500">
https://halo.run/categories/themes
</span>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col flex-col-reverse 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>
<time class="text-sm text-gray-500" datetime="2020-01-07">
2020-01-07
</time>
<span class="cursor-pointer">
<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">
修改
</VButton>
<VButton v-close-popper block type="danger">
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</span>
</div>
</div>
</div>
</div>
</li>
</ul>
<VEmpty
v-if="!categories.length && !loading"
message="你可以尝试刷新或者新建分类"
title="当前没有分类"
>
<template #actions>
<VSpace>
<VButton @click="handleFetchCategories"></VButton>
<VButton type="primary" @click="editingModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建分类
</VButton>
</VSpace>
</template>
</VEmpty>
<CategoryListItem
v-else
:categories="categoriesTree"
@change="handleUpdateInBatch"
@delete="handleDelete"
@open-editing="handleOpenEditingModal"
/>
</VCard>
</div>
</template>

View File

@ -1,14 +1,28 @@
<script lang="ts" setup>
// core libs
import { computed, ref, watch, watchEffect } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
// components
import { VButton, VModal, VSpace } from "@halo-dev/components";
withDefaults(
// types
import type { Category } from "@halo-dev/api-client";
// libs
import cloneDeep from "lodash.clonedeep";
import { reset, submitForm } from "@formkit/core";
import { useMagicKeys } from "@vueuse/core";
import { v4 as uuid } from "uuid";
const props = withDefaults(
defineProps<{
visible: boolean;
category: unknown | null;
category: Category | null;
}>(),
{
visible: false,
category: undefined,
category: null,
}
);
@ -17,36 +31,123 @@ const emit = defineEmits<{
(event: "close"): void;
}>();
const initialFormState: Category = {
spec: {
displayName: "",
slug: "",
description: undefined,
cover: undefined,
template: undefined,
priority: 0,
children: [],
},
status: {},
apiVersion: "content.halo.run/v1alpha1",
kind: "Category",
metadata: {
name: uuid(),
},
};
const formState = ref<Category>(cloneDeep(initialFormState));
const saving = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑文章分类" : "新增文章分类";
});
const handleSaveCategory = async () => {
try {
saving.value = true;
if (isUpdateMode.value) {
await apiClient.extension.category.updatecontentHaloRunV1alpha1Category(
formState.value.metadata.name,
formState.value
);
} else {
await apiClient.extension.category.createcontentHaloRunV1alpha1Category(
formState.value
);
}
onVisibleChange(false);
} catch (e) {
console.error("Failed to create category", e);
} finally {
saving.value = false;
}
};
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const { Command_Enter } = useMagicKeys();
watchEffect(() => {
if (Command_Enter.value && props.visible) {
submitForm("category-form");
}
});
watch(
() => props.visible,
(visible) => {
if (visible && props.category) {
formState.value = cloneDeep(props.category);
return;
}
formState.value = cloneDeep(initialFormState);
reset("category-form");
formState.value.metadata.name = uuid();
}
);
</script>
<template>
<VModal
:title="modalTitle"
:visible="visible"
:width="600"
title="编辑文章分类"
@update:visible="onVisibleChange"
>
<FormKit id="category-form" type="form">
<FormKit label="名称" type="text" validation="required"></FormKit>
<FormKit id="category-form" type="form" @submit="handleSaveCategory">
<FormKit
v-model="formState.spec.displayName"
label="名称"
type="text"
validation="required"
></FormKit>
<FormKit
v-model="formState.spec.slug"
help="通常作为分类访问地址标识"
label="别名"
type="text"
validation="required"
></FormKit>
<FormKit label="上级目录" type="select"></FormKit>
<FormKit help="需要主题适配以支持" label="封面图" type="text"></FormKit>
<FormKit help="需要主题适配以支持" label="描述" type="textarea"></FormKit>
<FormKit
v-model="formState.spec.cover"
help="需要主题适配以支持"
label="封面图"
type="text"
></FormKit>
<FormKit
v-model="formState.spec.description"
help="需要主题适配以支持"
label="描述"
type="textarea"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton type="secondary" @click="$formkit.submit('category-form')">
提交 +
保存 +
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>

View File

@ -0,0 +1,142 @@
<script lang="ts" setup>
import { IconList, IconSettings, VButton, VSpace } from "@halo-dev/components";
import Draggable from "vuedraggable";
import type { CategoryTree } from "../utils";
import { ref } from "vue";
import { formatDatetime } from "@/utils/date";
withDefaults(
defineProps<{
categories: CategoryTree[];
}>(),
{
categories: () => [],
}
);
const emit = defineEmits<{
(event: "change"): void;
(event: "open-editing", category: CategoryTree): void;
(event: "delete", category: CategoryTree): void;
}>();
const isDragging = ref(false);
function onChange() {
emit("change");
}
function onOpenEditingModal(category: CategoryTree) {
emit("open-editing", category);
}
function onDelete(category: CategoryTree) {
emit("delete", category);
}
</script>
<template>
<draggable
:list="categories"
class="box-border h-full w-full divide-y divide-gray-100"
ghost-class="opacity-50"
group="category-item"
handle=".drag-element"
item-key="metadata.name"
tag="ul"
@change="onChange"
@end="isDragging = false"
@start="isDragging = true"
>
<template #item="{ element: category }">
<li>
<div
class="group relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div
class="drag-element absolute inset-y-0 left-0 flex hidden w-3.5 cursor-move items-center bg-gray-100 transition-all hover:bg-gray-200 group-hover:flex"
>
<IconList class="h-3.5 w-3.5" />
</div>
<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 flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<FloatingTooltip
v-if="category.metadata.deletionTimestamp"
class="mr-4 hidden items-center sm:flex"
>
<div class="inline-flex h-1.5 w-1.5 rounded-full bg-red-600">
<span
class="inline-block h-1.5 w-1.5 animate-ping rounded-full bg-red-600"
></span>
</div>
<template #popper> 删除中</template>
</FloatingTooltip>
<div
class="cursor-pointer text-sm text-gray-500 hover:text-gray-900"
>
20 篇文章
</div>
<time class="text-sm text-gray-500">
{{ formatDatetime(category.metadata.creationTimestamp) }}
</time>
<span class="self-center">
<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="onOpenEditingModal(category)"
>
修改
</VButton>
<VButton
v-close-popper
block
type="danger"
@click="onDelete(category)"
>
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</span>
</div>
</div>
</div>
</div>
<CategoryListItem
:categories="category.spec.children"
class="pl-10 transition-all duration-300"
@change="onChange"
@delete="onDelete"
@open-editing="onOpenEditingModal"
/>
</li>
</template>
</draggable>
</template>

View File

@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import CategoryEditingModal from "../CategoryEditingModal.vue";
describe("CategoryEditingModal", function () {
it("should render", function () {
expect(mount(CategoryEditingModal)).toBeDefined();
});
});

View File

@ -0,0 +1,69 @@
import { apiClient } from "@halo-dev/admin-shared";
import type { Category } from "@halo-dev/api-client";
import type { Ref } from "vue";
import { onMounted, ref } from "vue";
import type { CategoryTree } from "@/modules/contents/posts/categories/utils";
import { buildCategoriesTree } from "@/modules/contents/posts/categories/utils";
import { useDialog } from "@halo-dev/components";
interface usePostCategoryReturn {
categories: Ref<Category[]>;
categoriesTree: Ref<CategoryTree[]>;
loading: Ref<boolean>;
handleFetchCategories: () => void;
handleDelete: (category: CategoryTree) => void;
}
export function usePostCategory(): usePostCategoryReturn {
const categories = ref<Category[]>([] as Category[]);
const categoriesTree = ref<CategoryTree[]>([] as CategoryTree[]);
const loading = ref(false);
const dialog = useDialog();
const handleFetchCategories = async () => {
try {
loading.value = true;
const { data } =
await apiClient.extension.category.listcontentHaloRunV1alpha1Category(
0,
0
);
categories.value = data.items;
categoriesTree.value = buildCategoriesTree(data.items);
} catch (e) {
console.error("Failed to fetch categories", e);
} finally {
loading.value = false;
}
};
const handleDelete = async (category: CategoryTree) => {
dialog.warning({
title: "确定要删除该分类吗?",
description: "删除此分类之后,对应文章的关联将被解除。该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
await apiClient.extension.category.deletecontentHaloRunV1alpha1Category(
category.metadata.name
);
} catch (e) {
console.error("Failed to delete tag", e);
} finally {
await handleFetchCategories();
}
},
});
};
onMounted(handleFetchCategories);
return {
categories,
categoriesTree,
loading,
handleFetchCategories,
handleDelete,
};
}

View File

@ -0,0 +1,123 @@
import type { Category, CategorySpec } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
export interface CategoryTreeSpec extends Omit<CategorySpec, "children"> {
children: CategoryTree[];
}
export interface CategoryTree extends Omit<Category, "spec"> {
spec: CategoryTreeSpec;
}
export function buildCategoriesTree(categories: Category[]): CategoryTree[] {
const categoriesToUpdate = cloneDeep(categories);
const categoriesMap = {};
const parentMap = {};
categoriesToUpdate.forEach((category) => {
categoriesMap[category.metadata.name] = category;
// @ts-ignore
category.spec.children.forEach((child) => {
parentMap[child] = category.metadata.name;
});
// @ts-ignore
category.spec.children = [];
});
categoriesToUpdate.forEach((category) => {
const parentName = parentMap[category.metadata.name];
if (parentName && categoriesMap[parentName]) {
categoriesMap[parentName].spec.children.push(category);
}
});
const categoriesTree = categoriesToUpdate.filter(
(node) => parentMap[node.metadata.name] === undefined
);
return sortCategoriesTree(categoriesTree);
}
export function sortCategoriesTree(
categoriesTree: CategoryTree[] | Category[]
): CategoryTree[] {
return categoriesTree
.sort((a, b) => {
if (a.spec.priority < b.spec.priority) {
return -1;
}
if (a.spec.priority > b.spec.priority) {
return 1;
}
return 0;
})
.map((category) => {
if (category.spec.children.length) {
return {
...category,
spec: {
...category.spec,
children: sortCategoriesTree(category.spec.children),
},
};
}
return category;
});
}
export function resetCategoriesTreePriority(
categoriesTree: CategoryTree[]
): CategoryTree[] {
for (let i = 0; i < categoriesTree.length; i++) {
categoriesTree[i].spec.priority = i;
if (categoriesTree[i].spec.children) {
resetCategoriesTreePriority(categoriesTree[i].spec.children);
}
}
return categoriesTree;
}
export function convertTreeToCategories(categoriesTree: CategoryTree[]) {
const categories: Category[] = [];
const categoriesMap = new Map<string, Category>();
const convertCategory = (node: CategoryTree | undefined) => {
if (!node) {
return;
}
const children = node.spec.children || [];
categoriesMap.set(node.metadata.name, {
...node,
spec: {
...node.spec,
// @ts-ignore
children: children.map((child) => child.metadata.name),
},
});
children.forEach((child) => {
convertCategory(child);
});
};
categoriesTree.forEach((node) => {
convertCategory(node);
});
categoriesMap.forEach((node) => {
categories.push(node);
});
return categories;
}
export function convertCategoryTreeToCategory(
categoryTree: CategoryTree
): Category {
const childNames = categoryTree.spec.children.map(
(child) => child.metadata.name
);
return {
...categoryTree,
spec: {
...categoryTree.spec,
children: childNames,
},
};
}

View File

@ -1,47 +1,96 @@
<script lang="ts" setup>
import {
IconArrowLeft,
IconArrowRight,
VButton,
VModal,
VSpace,
VTabItem,
VTabs,
} from "@halo-dev/components";
import { ref, unref, watch } from "vue";
import type { Post } from "@halo-dev/admin-api";
import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components";
import { computed, ref, watch, watchEffect } from "vue";
import type { PostRequest } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
import { usePostCategory } from "@/modules/contents/posts/categories/composables/use-post-category";
import { apiClient } from "@halo-dev/admin-shared";
import { v4 as uuid } from "uuid";
interface FormState {
post: Post | Record<string, unknown>;
saving: boolean;
}
const initialFormState: PostRequest = {
post: {
spec: {
title: "",
slug: "",
template: "",
cover: "",
deleted: false,
published: false,
publishTime: undefined,
pinned: false,
allowComment: true,
visible: "PUBLIC",
version: 1,
priority: 0,
excerpt: {
autoGenerate: true,
raw: "",
},
categories: [],
tags: [],
htmlMetas: [],
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Post",
metadata: {
name: uuid(),
},
},
content: {
raw: "",
content: "",
rawType: "HTML",
},
};
const props = withDefaults(
defineProps<{
visible: boolean;
post: Post | Record<string, unknown> | null;
post?: PostRequest | null;
onlyEmit?: boolean;
}>(),
{
visible: false,
post: null,
onlyEmit: false,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
(event: "previous"): void;
(event: "next"): void;
(event: "saved", post: PostRequest): void;
}>();
const settingActiveId = ref("general");
const formState = ref<FormState>({
post: {},
saving: false,
const activeTab = ref("general");
const formState = ref<PostRequest>(cloneDeep(initialFormState));
const saving = ref(false);
const publishing = ref(false);
const publishCanceling = ref(false);
const { categories } = usePostCategory();
const categoriesMap = computed(() => {
return categories.value.map((category) => {
return {
value: category.metadata.name,
label: category.spec.displayName,
};
});
});
watch([() => props.visible, () => props.post], () => {
formState.value.post = unref(props.post) || {};
const { tags } = usePostTag();
const tagsMap = computed(() => {
return tags.value.map((tag) => {
return {
value: tag.metadata.name,
label: tag.spec.displayName,
};
});
});
const isUpdateMode = computed(() => {
return !!formState.value.post.metadata.creationTimestamp;
});
const handleVisibleChange = (visible: boolean) => {
@ -50,92 +99,197 @@ const handleVisibleChange = (visible: boolean) => {
emit("close");
}
};
const handleSaveOnly = async () => {
if (props.onlyEmit) {
emit("saved", formState.value);
return;
}
try {
saving.value = true;
if (isUpdateMode.value) {
const { data } = await apiClient.post.updateDraftPost(
formState.value.post.metadata.name,
formState.value
);
formState.value.post = data;
emit("saved", formState.value);
} else {
const { data } = await apiClient.post.draftPost(formState.value);
formState.value.post = data;
emit("saved", formState.value);
}
} catch (e) {
console.error("Failed to save post", e);
} finally {
saving.value = false;
}
};
const handlePublish = async () => {
try {
publishing.value = true;
const { data } = await apiClient.post.publishPost(
formState.value.post.metadata.name
);
formState.value.post = data;
emit("saved", formState.value);
} catch (e) {
alert(`发布异常: ${e}`);
console.error("Failed to publish post", e);
} finally {
publishing.value = false;
}
};
const handlePublishCanceling = async () => {
try {
publishCanceling.value = true;
const postToUpdate = cloneDeep(formState.value);
postToUpdate.post.spec.published = false;
const { data } = await apiClient.post.updateDraftPost(
postToUpdate.post.metadata.name,
postToUpdate
);
formState.value.post = data;
emit("saved", formState.value);
} catch (e) {
console.log("Failed to cancel publish", e);
} finally {
publishCanceling.value = false;
}
};
watch(
() => props.visible,
(visible) => {
if (visible && props.post) {
formState.value = cloneDeep(props.post);
}
if (!visible) {
// TODO
}
}
);
watchEffect(() => {
if (props.post) {
formState.value = cloneDeep(props.post);
}
});
</script>
<template>
<VModal
:visible="visible"
:width="680"
:width="700"
title="文章设置"
@update:visible="handleVisibleChange"
>
<template #actions>
<div class="modal-header-action" @click="emit('previous')">
<IconArrowLeft />
</div>
<div class="modal-header-action" @click="emit('next')">
<IconArrowRight />
</div>
<slot name="actions"></slot>
</template>
<VTabs v-model:active-id="settingActiveId" type="outline">
<VTabs v-model:active-id="activeTab" type="outline">
<VTabItem id="general" label="常规">
<FormKit
id="basic"
:actions="false"
:model-value="formState.post"
:preserve="true"
type="form"
>
<FormKit id="basic" :actions="false" :preserve="true" type="form">
<FormKit
v-model="formState.post.spec.title"
label="标题"
name="title"
type="text"
validation="required"
></FormKit>
<FormKit
v-model="formState.post.spec.slug"
label="别名"
name="slug"
type="text"
validation="required"
></FormKit>
<FormKit label="分类目录" type="select"></FormKit>
<FormKit label="标签" type="select"></FormKit>
<FormKit label="摘要" name="summary" type="textarea"></FormKit>
<FormKit
v-model="formState.post.spec.categories"
:options="categoriesMap"
label="分类目录"
name="categories"
type="checkbox"
/>
<FormKit
v-model="formState.post.spec.tags"
:options="tagsMap"
label="标签"
name="tags"
type="checkbox"
/>
<FormKit
v-model="formState.post.spec.excerpt.autoGenerate"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
]"
label="自动生成摘要"
type="radio"
>
</FormKit>
<FormKit
v-if="!formState.post.spec.excerpt.autoGenerate"
v-model="formState.post.spec.excerpt.raw"
label="自定义摘要"
type="textarea"
></FormKit>
</FormKit>
</VTabItem>
<VTabItem id="advanced" label="高级">
<FormKit
id="advanced"
:actions="false"
:model-value="formState.post"
:preserve="true"
type="form"
>
<FormKit id="advanced" :actions="false" :preserve="true" type="form">
<FormKit
v-model="formState.post.spec.allowComment"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
]"
label="禁止评论"
name="disallowComment"
type="radio"
></FormKit>
<FormKit
v-model="formState.post.spec.pinned"
:options="[
{ label: '是', value: true },
{ label: '否', value: false },
]"
label="是否置顶"
name="topPriority"
name="pinned"
type="radio"
></FormKit>
<FormKit
v-model="formState.post.spec.visible"
:options="[
{ label: '公开', value: 'PUBLIC' },
{ label: '内部成员可访问', value: 'INTERNAL' },
{ label: '私有', value: 'PRIVATE' },
]"
label="可见性"
name="visible"
type="select"
></FormKit>
<FormKit
v-model="formState.post.spec.publishTime"
label="发表时间"
name="createTime"
type="datetime-local"
></FormKit>
<FormKit label="自定义模板" type="select"></FormKit>
<FormKit label="封面图" name="thumbnail" type="text"></FormKit>
<FormKit
v-model="formState.post.spec.template"
label="自定义模板"
type="text"
></FormKit>
<FormKit
v-model="formState.post.spec.cover"
label="封面图"
type="text"
></FormKit>
</FormKit>
</VTabItem>
<VTabItem id="seo" label="SEO">
<FormKit
id="seo"
:actions="false"
:model-value="formState.post"
:preserve="true"
type="form"
>
<FormKit id="seo" :actions="false" :preserve="true" type="form">
<FormKit
label="自定义关键词"
name="metaKeywords"
@ -150,13 +304,7 @@ const handleVisibleChange = (visible: boolean) => {
</VTabItem>
<VTabItem id="metas" label="元数据"></VTabItem>
<VTabItem id="inject-code" label="代码注入">
<FormKit
id="inject-code"
:actions="false"
:model-value="formState.post"
:preserve="true"
type="form"
>
<FormKit id="inject-code" :actions="false" :preserve="true" type="form">
<FormKit label="CSS" type="textarea"></FormKit>
<FormKit label="JavaScript" type="textarea"></FormKit>
</FormKit>
@ -166,14 +314,32 @@ const handleVisibleChange = (visible: boolean) => {
<template #footer>
<VSpace>
<VButton
:loading="formState.saving"
type="secondary"
@click="formState.saving = !formState.saving"
v-if="formState.post.status?.phase === 'PUBLISHED'"
:loading="publishCanceling"
type="danger"
@click="handlePublishCanceling"
>
保存
取消发布
</VButton>
<VButton type="default" @click="handleVisibleChange(false)"
>取消
<VButton
v-else
:disabled="!isUpdateMode"
:loading="publishing"
type="secondary"
@click="handlePublish"
>
发布
</VButton>
<VButton
:loading="saving"
size="sm"
type="secondary"
@click="handleSaveOnly"
>
仅保存
</VButton>
<VButton size="sm" type="default" @click="handleVisibleChange(false)">
关闭
</VButton>
</VSpace>
</template>

View File

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import PostSettingModal from "../PostSettingModal.vue";
import { VDialogProvider } from "@halo-dev/components";
describe("PostSettingModal", () => {
it("should render", () => {
const wrapper = mount({
components: {
VDialogProvider,
PostSettingModal,
},
template: `
<VDialogProvider>
<PostSettingModal></PostSettingModal>
</VDialogProvider>`,
});
expect(wrapper).toBeDefined();
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,8 @@
<script lang="ts" setup>
import { ref } from "vue";
// core libs
import { onMounted, ref } from "vue";
// components
import {
IconAddCircle,
IconBookRead,
@ -8,13 +11,22 @@ import {
IconSettings,
VButton,
VCard,
VEmpty,
VPageHeader,
VSpace,
VTag,
} from "@halo-dev/components";
import TagEditingModal from "./components/TagEditingModal.vue";
import PostTag from "./components/PostTag.vue";
// types
import type { Tag } from "@halo-dev/api-client";
import { usePostTag } from "./composables/use-post-tag";
import { formatDatetime } from "@/utils/date";
import { useRouteQuery } from "@vueuse/router";
import { apiClient } from "@halo-dev/admin-shared";
const editingModal = ref(false);
const viewTypes = [
{
name: "list",
@ -27,22 +39,82 @@ const viewTypes = [
];
const viewType = ref("list");
const { tags, loading, handleFetchTags, handleDelete } = usePostTag();
const editingModal = ref(false);
const selectedTag = ref<Tag | null>(null);
const handleOpenEditingModal = (tag: Tag | null) => {
selectedTag.value = tag;
editingModal.value = true;
};
const handleSelectPrevious = () => {
const currentIndex = tags.value.findIndex(
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
);
if (currentIndex > 0) {
selectedTag.value = tags.value[currentIndex - 1];
return;
}
if (currentIndex <= 0) {
selectedTag.value = null;
}
};
const handleSelectNext = () => {
if (!selectedTag.value) {
selectedTag.value = tags.value[0];
return;
}
const currentIndex = tags.value.findIndex(
(tag) => tag.metadata.name === selectedTag.value?.metadata.name
);
if (currentIndex !== tags.value.length - 1) {
selectedTag.value = tags.value[currentIndex + 1];
}
};
const onEditingModalClose = () => {
selectedTag.value = null;
queryName.value = null;
handleFetchTags();
};
const queryName = useRouteQuery("name");
onMounted(async () => {
if (queryName.value) {
const { data } = await apiClient.extension.tag.getcontentHaloRunV1alpha1Tag(
queryName.value as string
);
selectedTag.value = data;
editingModal.value = true;
}
});
</script>
<template>
<TagEditingModal v-model:visible="editingModal" />
<TagEditingModal
v-model:visible="editingModal"
:tag="selectedTag"
@close="onEditingModalClose"
@next="handleSelectNext"
@previous="handleSelectPrevious"
/>
<VPageHeader title="文章标签">
<template #icon>
<IconBookRead class="mr-2 self-center" />
</template>
<template #actions>
<VSpace>
<VButton type="secondary" @click="editingModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
</VSpace>
<VButton type="secondary" @click="editingModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建
</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
@ -53,7 +125,9 @@ const viewType = ref("list");
class="relative flex flex-col items-start sm:flex-row sm:items-center"
>
<div class="flex w-full flex-1 sm:w-auto">
<span class="text-base font-medium"> {{ 10 }} 个标签 </span>
<span class="text-base font-medium">
{{ tags.length }} 个标签
</span>
</div>
<div class="flex flex-row gap-2">
<div
@ -71,71 +145,120 @@ const viewType = ref("list");
</div>
</div>
</template>
<ul
v-if="viewType === 'list'"
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
<VEmpty
v-if="!tags.length && !loading"
message="你可以尝试刷新或者新建标签"
title="当前没有标签"
>
<li v-for="i in 10" :key="i">
<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">
<VTag>主题</VTag>
</div>
<div class="mt-1 flex">
<span class="text-xs text-gray-500">
https://halo.run/tags/themes
</span>
</div>
</div>
<div class="flex">
<div
class="inline-flex flex-col flex-col-reverse 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 篇文章
<template #actions>
<VSpace>
<VButton @click="handleFetchTags"></VButton>
<VButton type="primary" @click="editingModal = true">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
新建标签
</VButton>
</VSpace>
</template>
</VEmpty>
<div v-else>
<ul
v-if="viewType === 'list'"
class="box-border h-full w-full divide-y divide-gray-100"
role="list"
>
<li v-for="(tag, index) in tags" :key="index">
<div
:class="{
'bg-gray-100': selectedTag?.metadata.name === tag.metadata.name,
}"
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
>
<div
v-show="selectedTag?.metadata.name === tag.metadata.name"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<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 flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
>
<FloatingTooltip
v-if="tag.metadata.deletionTimestamp"
class="mr-4 hidden items-center sm:flex"
>
<div
class="inline-flex h-1.5 w-1.5 rounded-full bg-red-600"
>
<span
class="inline-block h-1.5 w-1.5 animate-ping rounded-full bg-red-600"
></span>
</div>
<template #popper> 删除中</template>
</FloatingTooltip>
<div
class="cursor-pointer text-sm text-gray-500 hover:text-gray-900"
>
20 篇文章
</div>
<time class="text-sm text-gray-500">
{{ formatDatetime(tag.metadata.creationTimestamp) }}
</time>
<span class="self-center">
<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="handleOpenEditingModal(tag)"
>
修改
</VButton>
<VButton
v-close-popper
block
type="danger"
@click="handleDelete(tag)"
>
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</span>
</div>
<time class="text-sm text-gray-500" datetime="2020-01-07">
2020-01-07
</time>
<span class="cursor-pointer">
<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="editingModal = true"
>
修改
</VButton>
<VButton v-close-popper block type="danger">
删除
</VButton>
</VSpace>
</div>
</template>
</FloatingDropdown>
</span>
</div>
</div>
</div>
</div>
</li>
</ul>
</li>
</ul>
<div v-else class="flex flex-wrap gap-3 p-4" role="list">
<VTag v-for="i in 100" :key="i">(10)</VTag>
<div v-else class="flex flex-wrap gap-3 p-4" role="list">
<PostTag
v-for="(tag, index) in tags"
:key="index"
:tag="tag"
@click="handleOpenEditingModal(tag)"
/>
</div>
</div>
</VCard>
</div>

View File

@ -0,0 +1,26 @@
<script lang="ts" setup>
import { VTag } from "@halo-dev/components";
import type { Tag } from "@halo-dev/api-client";
import { computed } from "vue";
// @ts-ignore
import Color from "colorjs.io";
const props = defineProps<{
tag: Tag;
}>();
const labelColor = computed(() => {
const { color } = props.tag.spec;
if (!color) {
return "inherit";
}
const onWhite = Math.abs(Color.contrast(color, "white", "APCA"));
const onBlack = Math.abs(Color.contrast(color, "black", "APCA"));
return onWhite > onBlack ? "white" : "#333";
});
</script>
<template>
<VTag :styles="{ background: tag.spec.color, color: labelColor }">
{{ tag.spec.displayName }}
</VTag>
</template>

View File

@ -1,51 +1,181 @@
<script lang="ts" setup>
import { VButton, VModal, VSpace } from "@halo-dev/components";
// core libs
import { computed, ref, watch, watchEffect } from "vue";
import { apiClient } from "@halo-dev/admin-shared";
withDefaults(
// components
import {
IconArrowLeft,
IconArrowRight,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
// types
import type { Tag } from "@halo-dev/api-client";
// libs
import cloneDeep from "lodash.clonedeep";
import { reset, submitForm } from "@formkit/core";
import { useMagicKeys } from "@vueuse/core";
import { v4 as uuid } from "uuid";
const props = withDefaults(
defineProps<{
visible: boolean;
tag: unknown | null;
tag: Tag | null;
}>(),
{
visible: false,
tag: undefined,
tag: null,
}
);
const emit = defineEmits<{
(event: "update:visible", visible: boolean): void;
(event: "close"): void;
(event: "previous"): void;
(event: "next"): void;
}>();
const initialFormState: Tag = {
spec: {
displayName: "",
slug: "",
color: "#b16cBe",
cover: "",
},
apiVersion: "content.halo.run/v1alpha1",
kind: "Tag",
metadata: {
name: uuid(),
},
};
const formState = ref<Tag>(cloneDeep(initialFormState));
const saving = ref(false);
const isUpdateMode = computed(() => {
return !!formState.value.metadata.creationTimestamp;
});
const modalTitle = computed(() => {
return isUpdateMode.value ? "编辑文章标签" : "新增文章标签";
});
const handleSaveTag = async () => {
try {
saving.value = true;
if (isUpdateMode.value) {
await apiClient.extension.tag.updatecontentHaloRunV1alpha1Tag(
formState.value.metadata.name,
formState.value
);
} else {
await apiClient.extension.tag.createcontentHaloRunV1alpha1Tag(
formState.value
);
}
onVisibleChange(false);
} catch (e) {
console.error("Failed to create tag", e);
} finally {
saving.value = false;
}
};
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleResetForm = () => {
formState.value = cloneDeep(initialFormState);
formState.value.metadata.name = uuid();
reset("tag-form");
};
const { Command_Enter } = useMagicKeys();
watchEffect(() => {
if (Command_Enter.value && props.visible) {
submitForm("tag-form");
}
});
watch(
() => props.visible,
(visible) => {
if (!visible) {
handleResetForm();
}
}
);
watch(
() => props.tag,
(tag) => {
if (tag) {
formState.value = cloneDeep(tag);
} else {
handleResetForm();
}
}
);
</script>
<template>
<VModal
:title="modalTitle"
:visible="visible"
:width="600"
title="编辑文章标签"
@update:visible="onVisibleChange"
>
<FormKit id="tag-form" type="form">
<FormKit label="名称" type="text" validation="required"></FormKit>
<template #actions>
<div class="modal-header-action" @click="emit('previous')">
<IconArrowLeft />
</div>
<div class="modal-header-action" @click="emit('next')">
<IconArrowRight />
</div>
</template>
<FormKit id="tag-form" type="form" @submit="handleSaveTag">
<FormKit
v-model="formState.spec.displayName"
label="名称"
type="text"
validation="required"
></FormKit>
<FormKit
v-model="formState.spec.slug"
help="通常作为标签访问地址标识"
label="别名"
type="text"
validation="required"
></FormKit>
<FormKit help="需要主题适配以支持" label="颜色" type="color"></FormKit>
<FormKit help="需要主题适配以支持" label="封面图" type="text"></FormKit>
<FormKit
v-model="formState.spec.color"
help="需要主题适配以支持"
label="颜色"
type="color"
></FormKit>
<FormKit
v-model="formState.spec.cover"
help="需要主题适配以支持"
label="封面图"
type="text"
></FormKit>
</FormKit>
<template #footer>
<VSpace>
<VButton type="secondary" @click="$formkit.submit('tag-form')">
提交 +
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit('tag-form')"
>
保存
</VButton>
<VButton @click="onVisibleChange(false)"> Esc</VButton>
</VSpace>

View File

@ -0,0 +1,61 @@
import { apiClient } from "@halo-dev/admin-shared";
import type { Tag } from "@halo-dev/api-client";
import type { Ref } from "vue";
import { onMounted, ref } from "vue";
import { useDialog } from "@halo-dev/components";
interface usePostTagReturn {
tags: Ref<Tag[]>;
loading: Ref<boolean>;
handleFetchTags: () => void;
handleDelete: (tag: Tag) => void;
}
export function usePostTag(): usePostTagReturn {
const tags = ref<Tag[]>([] as Tag[]);
const loading = ref(false);
const dialog = useDialog();
const handleFetchTags = async () => {
try {
loading.value = true;
const { data } =
await apiClient.extension.tag.listcontentHaloRunV1alpha1Tag(0, 0);
tags.value = data.items;
} catch (e) {
console.error("Failed to fetch tags", e);
} finally {
loading.value = false;
}
};
const handleDelete = async (tag: Tag) => {
dialog.warning({
title: "确定要删除该标签吗?",
description: "删除此标签之后,对应文章的关联将被解除。该操作不可恢复。",
confirmType: "danger",
onConfirm: async () => {
try {
await apiClient.extension.tag.deletecontentHaloRunV1alpha1Tag(
tag.metadata.name
);
} catch (e) {
console.error("Failed to delete tag", e);
} finally {
await handleFetchTags();
}
},
});
};
onMounted(handleFetchTags);
return {
tags,
loading,
handleFetchTags,
handleDelete,
};
}

View File

@ -1,6 +1,22 @@
<script lang="ts" name="RecentPublishedWidget" setup>
import { VCard, VSpace } from "@halo-dev/components";
import { posts } from "@/modules/contents/posts/posts-mock";
import { onMounted, ref } from "vue";
import type { Post } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
const posts = ref<Post[]>([] as Post[]);
const handleFetchPosts = async () => {
try {
const { data } =
await apiClient.extension.post.listcontentHaloRunV1alpha1Post();
posts.value = data.items;
} catch (e) {
console.error("Failed to fetch posts", e);
}
};
onMounted(handleFetchPosts);
</script>
<template>
<VCard
@ -18,23 +34,19 @@ import { posts } from "@/modules/contents/posts/posts-mock";
<div class="flex items-center space-x-4">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-gray-900">
{{ post.title }}
{{ post.spec.title }}
</p>
<div class="mt-1 flex">
<VSpace>
<span class="text-xs text-gray-500">
阅读 {{ post.visits }}
</span>
<span class="text-xs text-gray-500">
评论 {{ post.commentCount }}
</span>
<span class="text-xs text-gray-500"> 阅读 0 </span>
<span class="text-xs text-gray-500"> 评论 0 </span>
</VSpace>
</div>
</div>
<div>
<time class="text-sm text-gray-500" datetime="2020-01-07 20:00">
2020-01-07 20:00
<time class="text-sm text-gray-500">
{{ post.metadata.creationTimestamp }}
</time>
</div>
</div>

View File

@ -0,0 +1,10 @@
import { describe, expect, it } from "vitest";
import { formatDatetime } from "../date";
describe.skip("date#formatDatetime", () => {
it("should return formatted datetime", () => {
const formattedDatetime = formatDatetime("2022-08-17T06:01:16.511575Z");
expect(formattedDatetime).toEqual("2022-08-17 14:01");
});
});

14
src/utils/date.ts Normal file
View File

@ -0,0 +1,14 @@
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import timezone from "dayjs/plugin/timezone";
dayjs.extend(timezone);
dayjs.locale("zh-cn");
export function formatDatetime(date: string | Date | undefined | null): string {
if (!date) {
return "";
}
return dayjs(date).format("YYYY-MM-DD HH:mm");
}