mirror of https://github.com/halo-dev/halo-admin
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
parent
73d072a8fa
commit
3ee45a117e
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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"
|
||||
},
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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==}
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
});
|
|
@ -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");
|
||||
}
|
Loading…
Reference in New Issue