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/inputs": "1.0.0-beta.10",
|
||||||
"@formkit/themes": "1.0.0-beta.10",
|
"@formkit/themes": "1.0.0-beta.10",
|
||||||
"@formkit/vue": "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/admin-shared": "workspace:*",
|
||||||
"@halo-dev/api-client": "^0.0.10",
|
"@halo-dev/api-client": "^0.0.12",
|
||||||
"@halo-dev/components": "workspace:*",
|
"@halo-dev/components": "workspace:*",
|
||||||
"@halo-dev/richtext-editor": "0.0.0-alpha.3",
|
"@halo-dev/richtext-editor": "0.0.0-alpha.3",
|
||||||
"@vueuse/components": "^8.9.4",
|
"@vueuse/components": "^8.9.4",
|
||||||
"@vueuse/core": "^8.9.4",
|
"@vueuse/core": "^8.9.4",
|
||||||
"@vueuse/router": "^9.1.0",
|
"@vueuse/router": "^9.1.0",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
|
"colorjs.io": "^0.4.0",
|
||||||
|
"dayjs": "^1.11.5",
|
||||||
"filepond": "^4.30.4",
|
"filepond": "^4.30.4",
|
||||||
"filepond-plugin-image-preview": "^4.6.11",
|
"filepond-plugin-image-preview": "^4.6.11",
|
||||||
"floating-vue": "2.0.0-beta.19",
|
"floating-vue": "2.0.0-beta.19",
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import type { CSSProperties } from "vue";
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import type { Theme } from "./interface";
|
import type { Theme } from "./interface";
|
||||||
|
|
||||||
|
@ -6,10 +7,14 @@ const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
theme?: Theme;
|
theme?: Theme;
|
||||||
rounded?: boolean;
|
rounded?: boolean;
|
||||||
|
styles?: CSSProperties;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
theme: "default",
|
theme: "default",
|
||||||
rounded: false,
|
rounded: false,
|
||||||
|
styles: () => {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -18,7 +23,7 @@ const classes = computed(() => {
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div :class="classes" class="tag-wrapper">
|
<div :class="classes" :style="styles" class="tag-wrapper">
|
||||||
<div v-if="$slots.leftIcon" class="tag-left-icon">
|
<div v-if="$slots.leftIcon" class="tag-left-icon">
|
||||||
<slot name="leftIcon" />
|
<slot name="leftIcon" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import IconUserSettings from "~icons/ri/user-settings-line";
|
||||||
import IconSettings from "~icons/ri/settings-4-line";
|
import IconSettings from "~icons/ri/settings-4-line";
|
||||||
import IconPlug from "~icons/ri/plug-2-line";
|
import IconPlug from "~icons/ri/plug-2-line";
|
||||||
import IconEye from "~icons/ri/eye-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 IconFolder from "~icons/ri/folder-2-line";
|
||||||
import IconMore from "~icons/ri/more-line";
|
import IconMore from "~icons/ri/more-line";
|
||||||
import IconClose from "~icons/ri/close-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 IconForbidLine from "~icons/ri/forbid-line";
|
||||||
import IconCodeBoxLine from "~icons/ri/code-box-line";
|
import IconCodeBoxLine from "~icons/ri/code-box-line";
|
||||||
import IconDatabase2Line from "~icons/ri/database-2-line";
|
import IconDatabase2Line from "~icons/ri/database-2-line";
|
||||||
|
import IconTeam from "~icons/ri/team-fill";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
IconDashboard,
|
IconDashboard,
|
||||||
|
@ -57,6 +59,7 @@ export {
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconPlug,
|
IconPlug,
|
||||||
IconEye,
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
IconFolder,
|
IconFolder,
|
||||||
IconMore,
|
IconMore,
|
||||||
IconClose,
|
IconClose,
|
||||||
|
@ -86,4 +89,5 @@ export {
|
||||||
IconForbidLine,
|
IconForbidLine,
|
||||||
IconCodeBoxLine,
|
IconCodeBoxLine,
|
||||||
IconDatabase2Line,
|
IconDatabase2Line,
|
||||||
|
IconTeam,
|
||||||
};
|
};
|
||||||
|
|
|
@ -36,7 +36,7 @@
|
||||||
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
|
"homepage": "https://github.com/halo-dev/halo-admin/tree/next/shared/components#readme",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@halo-dev/api-client": "^0.0.10",
|
"@halo-dev/api-client": "^0.0.12",
|
||||||
"@halo-dev/components": "workspace:*",
|
"@halo-dev/components": "workspace:*",
|
||||||
"axios": "^0.27.2"
|
"axios": "^0.27.2"
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,18 +1,26 @@
|
||||||
import {
|
import {
|
||||||
|
ApiHaloRunV1alpha1ContentApi,
|
||||||
ApiHaloRunV1alpha1PluginApi,
|
ApiHaloRunV1alpha1PluginApi,
|
||||||
|
ApiHaloRunV1alpha1PostApi,
|
||||||
|
ApiHaloRunV1alpha1ThemeApi,
|
||||||
ApiHaloRunV1alpha1UserApi,
|
ApiHaloRunV1alpha1UserApi,
|
||||||
|
ContentHaloRunV1alpha1CategoryApi,
|
||||||
|
ContentHaloRunV1alpha1CommentApi,
|
||||||
|
ContentHaloRunV1alpha1PostApi,
|
||||||
|
ContentHaloRunV1alpha1ReplyApi,
|
||||||
|
ContentHaloRunV1alpha1SnapshotApi,
|
||||||
|
ContentHaloRunV1alpha1TagApi,
|
||||||
PluginHaloRunV1alpha1PluginApi,
|
PluginHaloRunV1alpha1PluginApi,
|
||||||
PluginHaloRunV1alpha1ReverseProxyApi,
|
PluginHaloRunV1alpha1ReverseProxyApi,
|
||||||
|
ThemeHaloRunV1alpha1ThemeApi,
|
||||||
V1alpha1ConfigMapApi,
|
V1alpha1ConfigMapApi,
|
||||||
|
V1alpha1MenuApi,
|
||||||
|
V1alpha1MenuItemApi,
|
||||||
V1alpha1PersonalAccessTokenApi,
|
V1alpha1PersonalAccessTokenApi,
|
||||||
V1alpha1RoleApi,
|
V1alpha1RoleApi,
|
||||||
V1alpha1RoleBindingApi,
|
V1alpha1RoleBindingApi,
|
||||||
V1alpha1SettingApi,
|
V1alpha1SettingApi,
|
||||||
V1alpha1UserApi,
|
V1alpha1UserApi,
|
||||||
V1alpha1MenuApi,
|
|
||||||
V1alpha1MenuItemApi,
|
|
||||||
ThemeHaloRunV1alpha1ThemeApi,
|
|
||||||
ApiHaloRunV1alpha1ThemeApi,
|
|
||||||
} from "@halo-dev/api-client";
|
} from "@halo-dev/api-client";
|
||||||
import type { AxiosInstance } from "axios";
|
import type { AxiosInstance } from "axios";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
@ -65,10 +73,19 @@ function setupApiClient(axios: AxiosInstance) {
|
||||||
theme: new ThemeHaloRunV1alpha1ThemeApi(undefined, apiUrl, axios),
|
theme: new ThemeHaloRunV1alpha1ThemeApi(undefined, apiUrl, axios),
|
||||||
menu: new V1alpha1MenuApi(undefined, apiUrl, axios),
|
menu: new V1alpha1MenuApi(undefined, apiUrl, axios),
|
||||||
menuItem: new V1alpha1MenuItemApi(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),
|
user: new ApiHaloRunV1alpha1UserApi(undefined, apiUrl, axios),
|
||||||
plugin: new ApiHaloRunV1alpha1PluginApi(undefined, apiUrl, axios),
|
plugin: new ApiHaloRunV1alpha1PluginApi(undefined, apiUrl, axios),
|
||||||
theme: new ApiHaloRunV1alpha1ThemeApi(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/inputs': 1.0.0-beta.10
|
||||||
'@formkit/themes': 1.0.0-beta.10
|
'@formkit/themes': 1.0.0-beta.10
|
||||||
'@formkit/vue': 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/admin-shared': workspace:*
|
||||||
'@halo-dev/api-client': ^0.0.10
|
'@halo-dev/api-client': ^0.0.12
|
||||||
'@halo-dev/components': workspace:*
|
'@halo-dev/components': workspace:*
|
||||||
'@halo-dev/richtext-editor': 0.0.0-alpha.3
|
'@halo-dev/richtext-editor': 0.0.0-alpha.3
|
||||||
'@rushstack/eslint-patch': ^1.1.4
|
'@rushstack/eslint-patch': ^1.1.4
|
||||||
|
@ -39,7 +38,9 @@ importers:
|
||||||
autoprefixer: ^10.4.8
|
autoprefixer: ^10.4.8
|
||||||
axios: ^0.27.2
|
axios: ^0.27.2
|
||||||
c8: ^7.12.0
|
c8: ^7.12.0
|
||||||
|
colorjs.io: ^0.4.0
|
||||||
cypress: ^9.7.0
|
cypress: ^9.7.0
|
||||||
|
dayjs: ^1.11.5
|
||||||
eslint: ^8.22.0
|
eslint: ^8.22.0
|
||||||
eslint-plugin-cypress: ^2.12.1
|
eslint-plugin-cypress: ^2.12.1
|
||||||
eslint-plugin-vue: ^9.3.0
|
eslint-plugin-vue: ^9.3.0
|
||||||
|
@ -85,15 +86,16 @@ importers:
|
||||||
'@formkit/inputs': 1.0.0-beta.10
|
'@formkit/inputs': 1.0.0-beta.10
|
||||||
'@formkit/themes': 1.0.0-beta.10_tailwindcss@3.1.8
|
'@formkit/themes': 1.0.0-beta.10_tailwindcss@3.1.8
|
||||||
'@formkit/vue': 1.0.0-beta.10_wwmyxdjqen5bmh3tr2meig5lki
|
'@formkit/vue': 1.0.0-beta.10_wwmyxdjqen5bmh3tr2meig5lki
|
||||||
'@halo-dev/admin-api': 1.1.0
|
|
||||||
'@halo-dev/admin-shared': link:packages/shared
|
'@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/components': link:packages/components
|
||||||
'@halo-dev/richtext-editor': 0.0.0-alpha.3_vue@3.2.37
|
'@halo-dev/richtext-editor': 0.0.0-alpha.3_vue@3.2.37
|
||||||
'@vueuse/components': 8.9.4_vue@3.2.37
|
'@vueuse/components': 8.9.4_vue@3.2.37
|
||||||
'@vueuse/core': 8.9.4_vue@3.2.37
|
'@vueuse/core': 8.9.4_vue@3.2.37
|
||||||
'@vueuse/router': 9.1.0_26a4nhf5pwzjzqc5ckt7ohj5zi
|
'@vueuse/router': 9.1.0_26a4nhf5pwzjzqc5ckt7ohj5zi
|
||||||
axios: 0.27.2
|
axios: 0.27.2
|
||||||
|
colorjs.io: 0.4.0
|
||||||
|
dayjs: 1.11.5
|
||||||
filepond: 4.30.4
|
filepond: 4.30.4
|
||||||
filepond-plugin-image-preview: 4.6.11_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
|
floating-vue: 2.0.0-beta.19_vue@3.2.37
|
||||||
|
@ -182,12 +184,12 @@ importers:
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@halo-dev/api-client': ^0.0.10
|
'@halo-dev/api-client': ^0.0.12
|
||||||
'@halo-dev/components': workspace:*
|
'@halo-dev/components': workspace:*
|
||||||
axios: ^0.27.2
|
axios: ^0.27.2
|
||||||
vite-plugin-dts: ^1.4.1
|
vite-plugin-dts: ^1.4.1
|
||||||
dependencies:
|
dependencies:
|
||||||
'@halo-dev/api-client': 0.0.10
|
'@halo-dev/api-client': 0.0.12
|
||||||
'@halo-dev/components': link:../components
|
'@halo-dev/components': link:../components
|
||||||
axios: 0.27.2
|
axios: 0.27.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
@ -2109,39 +2111,8 @@ packages:
|
||||||
- windicss
|
- windicss
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@halo-dev/admin-api/1.1.0:
|
/@halo-dev/api-client/0.0.12:
|
||||||
resolution: {integrity: sha512-2K8ulSucPudWfBo8SWz92hLtlu8C7hVVfYNejlns0BZfMxFyivvGyTgeYiMYHgNR5n4K6tF903Zn1DkS7jnv0g==}
|
resolution: {integrity: sha512-fOI3DB9rOA1Z+h1aKiEQ+2kWkNSmdWIDvd+39dR5b3X0DmKH+zWrNmygA5Qe2gBPX28TNt/zr2qCKUjGjb99CA==}
|
||||||
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
|
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@halo-dev/richtext-editor/0.0.0-alpha.3_vue@3.2.37:
|
/@halo-dev/richtext-editor/0.0.0-alpha.3_vue@3.2.37:
|
||||||
|
@ -3950,14 +3921,6 @@ packages:
|
||||||
- debug
|
- debug
|
||||||
dev: true
|
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:
|
/axios/0.27.2:
|
||||||
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
|
resolution: {integrity: sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -4379,6 +4342,10 @@ packages:
|
||||||
resolution: {integrity: sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==}
|
resolution: {integrity: sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/colorjs.io/0.4.0:
|
||||||
|
resolution: {integrity: sha512-AUKG9GCDSHsFRUnxGrEMCm6nq6lxddnDvD0avmsy/klCEk68htpqgl9IERGtGoxaGJlr7uP5wmD381gY2uG8hw==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/colors/1.2.5:
|
/colors/1.2.5:
|
||||||
resolution: {integrity: sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==}
|
resolution: {integrity: sha512-erNRLao/Y3Fv54qUa0LBB+//Uf3YwMUmdJinN20yMXm9zdKKqH9wt7R9IIVZ+K7ShzfpLV/Zg8+VyrBJYB4lpg==}
|
||||||
engines: {node: '>=0.1.90'}
|
engines: {node: '>=0.1.90'}
|
||||||
|
@ -4579,7 +4546,7 @@ packages:
|
||||||
cli-table3: 0.6.1
|
cli-table3: 0.6.1
|
||||||
commander: 5.1.0
|
commander: 5.1.0
|
||||||
common-tags: 1.8.2
|
common-tags: 1.8.2
|
||||||
dayjs: 1.11.3
|
dayjs: 1.11.5
|
||||||
debug: 4.3.4_supports-color@8.1.1
|
debug: 4.3.4_supports-color@8.1.1
|
||||||
enquirer: 2.3.6
|
enquirer: 2.3.6
|
||||||
eventemitter2: 6.4.5
|
eventemitter2: 6.4.5
|
||||||
|
@ -4623,9 +4590,8 @@ packages:
|
||||||
whatwg-url: 11.0.0
|
whatwg-url: 11.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/dayjs/1.11.3:
|
/dayjs/1.11.5:
|
||||||
resolution: {integrity: sha512-xxwlswWOlGhzgQ4TKzASQkUhqERI3egRNqgV4ScR8wlANA/A9tZ7miXa44vTTKEq5l7vWoL5G57bG3zA+Kow0A==}
|
resolution: {integrity: sha512-CAdX5Q3YW3Gclyo5Vpqkgpj8fSdLQcRuzfX6mC6Phy0nfJ0eGYOeS7m4mt2plDWLAtA4TqTakvbboHvUxfe4iA==}
|
||||||
dev: true
|
|
||||||
|
|
||||||
/debug/2.6.9:
|
/debug/2.6.9:
|
||||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||||
|
@ -6461,10 +6427,6 @@ packages:
|
||||||
'@sideway/pinpoint': 2.0.0
|
'@sideway/pinpoint': 2.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/js-base64/3.7.2:
|
|
||||||
resolution: {integrity: sha512-NnRs6dsyqUXejqk/yv2aiXlAvOs56sLkX6nUdeaNezI5LFFLlsZjOThmwnrcwh5ZZRwZlCMnVAY3CvhIhoVEKQ==}
|
|
||||||
dev: false
|
|
||||||
|
|
||||||
/js-tokens/4.0.0:
|
/js-tokens/4.0.0:
|
||||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -8560,6 +8522,7 @@ packages:
|
||||||
|
|
||||||
/tslib/2.4.0:
|
/tslib/2.4.0:
|
||||||
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
|
resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/tsutils/3.21.0_typescript@4.7.4:
|
/tsutils/3.21.0_typescript@4.7.4:
|
||||||
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
|
resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==}
|
||||||
|
|
|
@ -7,3 +7,11 @@ export enum pluginLabels {
|
||||||
export enum roleLabels {
|
export enum roleLabels {
|
||||||
TEMPLATE = "halo.run/role-template",
|
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",
|
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",
|
wrapper: "flex flex-col sm:flex-row items-start sm:items-center",
|
||||||
inner:
|
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:
|
input:
|
||||||
"outline-0 bg-white antialiased resize-none w-full text-black block transition-all appearance-none h-9 px-3 text-sm",
|
"outline-0 bg-white antialiased resize-none w-full text-black block transition-all appearance-none h-9 px-3 text-sm",
|
||||||
};
|
};
|
||||||
|
|
||||||
const boxClassification = {
|
const boxClassification = {
|
||||||
fieldset:
|
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",
|
legend: "font-bold text-sm",
|
||||||
wrapper: "flex items-center mb-1 cursor-pointer",
|
wrapper: "flex items-center mb-1 cursor-pointer",
|
||||||
help: "mb-2",
|
help: "mb-2",
|
||||||
|
|
|
@ -4,24 +4,141 @@ import {
|
||||||
IconSave,
|
IconSave,
|
||||||
VButton,
|
VButton,
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
|
VSpace,
|
||||||
} from "@halo-dev/components";
|
} 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<PostSettingModal
|
||||||
|
v-model:visible="settingModal"
|
||||||
|
:only-emit="true"
|
||||||
|
:post="formState"
|
||||||
|
@saved="onSettingSaved"
|
||||||
|
/>
|
||||||
<VPageHeader title="文章">
|
<VPageHeader title="文章">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconBookRead class="mr-2 self-center" />
|
<IconBookRead class="mr-2 self-center" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VButton :route="{ name: 'PostEditor' }" type="secondary">
|
<VSpace>
|
||||||
<template #icon>
|
<VButton
|
||||||
<IconSave class="h-full w-full" />
|
:loading="saving"
|
||||||
</template>
|
size="sm"
|
||||||
发布
|
type="default"
|
||||||
</VButton>
|
@click="handleSavePost"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</VButton>
|
||||||
|
<VButton type="secondary" @click="settingModal = true">
|
||||||
|
<template #icon>
|
||||||
|
<IconSave class="h-full w-full" />
|
||||||
|
</template>
|
||||||
|
发布
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
</template>
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
<div class="editor border-t">
|
<div class="editor border-t">
|
||||||
<RichTextEditor />
|
<RichTextEditor v-model="formState.content.raw" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -2,104 +2,284 @@
|
||||||
import {
|
import {
|
||||||
IconAddCircle,
|
IconAddCircle,
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
IconBookRead,
|
IconBookRead,
|
||||||
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
|
IconTeam,
|
||||||
|
useDialog,
|
||||||
VButton,
|
VButton,
|
||||||
VCard,
|
VCard,
|
||||||
|
VEmpty,
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
VPagination,
|
VPagination,
|
||||||
VSpace,
|
VSpace,
|
||||||
VTag,
|
VTag,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import PostSettingModal from "./components/PostSettingModal.vue";
|
import PostSettingModal from "./components/PostSettingModal.vue";
|
||||||
import { posts } from "./posts-mock";
|
import PostTag from "../posts/tags/components/PostTag.vue";
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { onMounted, ref, watch, watchEffect } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import type { ListedPostList, Post, PostRequest } from "@halo-dev/api-client";
|
||||||
import type { Post } from "@halo-dev/admin-api";
|
|
||||||
import { apiClient } from "@halo-dev/admin-shared";
|
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(
|
enum PostPhase {
|
||||||
// eslint-disable-next-line
|
DRAFT = "未发布",
|
||||||
posts.map((item: any) => {
|
PENDING_APPROVAL = "待审核",
|
||||||
return {
|
PUBLISHED = "已发布",
|
||||||
...item,
|
}
|
||||||
checked: false,
|
|
||||||
};
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const router = useRouter();
|
const posts = ref<ListedPostList>({
|
||||||
const checkAll = ref(false);
|
page: 1,
|
||||||
const postSettings = ref(false);
|
size: 20,
|
||||||
// eslint-disable-next-line
|
total: 0,
|
||||||
const selected = ref<Post | Record<string, unknown> | null>({});
|
items: [],
|
||||||
const users = ref<User[]>([]);
|
first: true,
|
||||||
|
last: false,
|
||||||
const checkedCount = computed(() => {
|
hasNext: false,
|
||||||
return postsRef.value.filter((post) => post.checked).length;
|
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 {
|
try {
|
||||||
const { data } = await apiClient.extension.user.listv1alpha1User();
|
loading.value = true;
|
||||||
users.value = data.items;
|
|
||||||
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error("Failed to fetch posts", e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheckAll = () => {
|
const handlePaginationChange = ({
|
||||||
postsRef.value.forEach((item) => {
|
page,
|
||||||
item.checked = checkAll.value;
|
size,
|
||||||
});
|
}: {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
posts.value.page = page;
|
||||||
|
posts.value.size = size;
|
||||||
|
handleFetchPosts();
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line
|
const handleOpenSettingModal = (post: Post) => {
|
||||||
const handleSelect = (post: any) => {
|
selectedPost.value = post;
|
||||||
selected.value = post;
|
settingModal.value = true;
|
||||||
postSettings.value = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectPrevious = () => {
|
const onSettingModalClose = () => {
|
||||||
const currentIndex = posts.findIndex(
|
selectedPost.value = null;
|
||||||
(post) => post.id === selected.value?.id
|
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) {
|
if (index > 0) {
|
||||||
selected.value = posts[currentIndex - 1];
|
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 handleSelectNext = async () => {
|
||||||
const currentIndex = posts.findIndex(
|
const { items, hasNext } = posts.value;
|
||||||
(post) => post.id === selected.value?.id
|
const index = items.findIndex(
|
||||||
|
(post) => post.post.metadata.name === selectedPost.value?.metadata.name
|
||||||
);
|
);
|
||||||
if (currentIndex < posts.length - 1) {
|
if (index < items.length - 1) {
|
||||||
selected.value = posts[currentIndex + 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 checkSelection = (post: Post) => {
|
||||||
const handleRouteToEditor = (post: any) => {
|
return (
|
||||||
router.push({
|
post.metadata.name === selectedPost.value?.metadata.name ||
|
||||||
name: "PostEditor",
|
selectedPostNames.value.includes(post.metadata.name)
|
||||||
params: {
|
);
|
||||||
id: post.id,
|
};
|
||||||
|
|
||||||
|
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(() => {
|
watch(selectedPostNames, (newValue) => {
|
||||||
handleFetchUsers();
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<PostSettingModal
|
<PostSettingModal
|
||||||
v-model:visible="postSettings"
|
v-model:visible="settingModal"
|
||||||
:post="selected"
|
:post="selectedPostWithContent"
|
||||||
@next="handleSelectNext"
|
@close="onSettingModalClose"
|
||||||
@previous="handleSelectPrevious"
|
>
|
||||||
/>
|
<template #actions>
|
||||||
|
<div class="modal-header-action" @click="handleSelectPrevious">
|
||||||
|
<IconArrowLeft />
|
||||||
|
</div>
|
||||||
|
<div class="modal-header-action" @click="handleSelectNext">
|
||||||
|
<IconArrowRight />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PostSettingModal>
|
||||||
<VPageHeader title="文章">
|
<VPageHeader title="文章">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconBookRead class="mr-2 self-center" />
|
<IconBookRead class="mr-2 self-center" />
|
||||||
|
@ -107,6 +287,7 @@ onMounted(() => {
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VSpace>
|
<VSpace>
|
||||||
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
|
<VButton :route="{ name: 'Categories' }" size="sm">分类</VButton>
|
||||||
|
<VButton :route="{ name: 'Tags' }" size="sm">标签</VButton>
|
||||||
<VButton :route="{ name: 'PostEditor' }" type="secondary">
|
<VButton :route="{ name: 'PostEditor' }" type="secondary">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconAddCircle class="h-full w-full" />
|
<IconAddCircle class="h-full w-full" />
|
||||||
|
@ -126,18 +307,16 @@ onMounted(() => {
|
||||||
>
|
>
|
||||||
<div class="mr-4 hidden items-center sm:flex">
|
<div class="mr-4 hidden items-center sm:flex">
|
||||||
<input
|
<input
|
||||||
v-model="checkAll"
|
v-model="checkedAll"
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@change="handleCheckAll()"
|
@change="handleCheckAllChange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-1 sm:w-auto">
|
<div class="flex w-full flex-1 sm:w-auto">
|
||||||
<FormKit
|
<div v-if="!selectedPostNames.length">
|
||||||
v-if="checkedCount <= 0"
|
<FormKit placeholder="输入关键词搜索" type="text"></FormKit>
|
||||||
placeholder="输入关键词搜索"
|
</div>
|
||||||
type="text"
|
|
||||||
></FormKit>
|
|
||||||
<VSpace v-else>
|
<VSpace v-else>
|
||||||
<VButton type="default">设置</VButton>
|
<VButton type="default">设置</VButton>
|
||||||
<VButton type="danger">删除</VButton>
|
<VButton type="danger">删除</VButton>
|
||||||
|
@ -158,24 +337,50 @@ onMounted(() => {
|
||||||
<div class="w-72 p-4">
|
<div class="w-72 p-4">
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<li
|
<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"
|
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>
|
</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
|
<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"
|
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>
|
<span class="truncate">
|
||||||
</li>
|
{{ filterItem.label }}
|
||||||
<li
|
</span>
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">草稿</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">未审核</span>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -191,11 +396,57 @@ onMounted(() => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div class="h-96 w-80 p-4">
|
<div class="h-96 w-80">
|
||||||
<FormKit
|
<div class="bg-white p-4">
|
||||||
placeholder="输入关键词搜索"
|
<FormKit
|
||||||
type="text"
|
placeholder="输入关键词搜索"
|
||||||
></FormKit>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FloatingDropdown>
|
</FloatingDropdown>
|
||||||
|
@ -209,11 +460,54 @@ onMounted(() => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div class="h-96 w-80 p-4">
|
<div class="h-96 w-80">
|
||||||
<FormKit
|
<div class="bg-white p-4">
|
||||||
placeholder="输入关键词搜索"
|
<FormKit
|
||||||
type="text"
|
placeholder="输入关键词搜索"
|
||||||
></FormKit>
|
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</FloatingDropdown>
|
</FloatingDropdown>
|
||||||
|
@ -227,8 +521,8 @@ onMounted(() => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div class="h-96 w-80 p-4">
|
<div class="h-96 w-80">
|
||||||
<div class="bg-white">
|
<div class="bg-white p-4">
|
||||||
<!--TODO: Auto Focus-->
|
<!--TODO: Auto Focus-->
|
||||||
<FormKit
|
<FormKit
|
||||||
placeholder="输入关键词搜索"
|
placeholder="输入关键词搜索"
|
||||||
|
@ -240,15 +534,10 @@ onMounted(() => {
|
||||||
<li
|
<li
|
||||||
v-for="(user, index) in users"
|
v-for="(user, index) in users"
|
||||||
:key="index"
|
: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 space-x-4 px-4 py-3">
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
:alt="user.spec.displayName"
|
:alt="user.spec.displayName"
|
||||||
|
@ -327,48 +616,93 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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
|
<div
|
||||||
:class="{
|
: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"
|
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
|
||||||
>
|
>
|
||||||
<div
|
<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"
|
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
|
||||||
></div>
|
></div>
|
||||||
<div class="relative flex flex-row items-center">
|
<div class="relative flex flex-row items-center">
|
||||||
<div class="mr-4 hidden items-center sm:flex">
|
<div class="mr-4 hidden items-center sm:flex">
|
||||||
<input
|
<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"
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
|
name="post-checkbox"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<div class="flex flex-col sm:flex-row">
|
<div class="flex flex-col sm:flex-row">
|
||||||
<span
|
<RouterLink
|
||||||
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
|
:to="{
|
||||||
@click="handleRouteToEditor(post)"
|
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">
|
<VSpace class="mt-1 sm:mt-0">
|
||||||
<VTag v-for="(tag, tagIndex) in post.tags" :key="tagIndex">
|
<RouterLink
|
||||||
{{ tag.name }}
|
v-for="(tag, tagIndex) in post.tags"
|
||||||
</VTag>
|
:key="tagIndex"
|
||||||
|
:to="{
|
||||||
|
name: 'Tags',
|
||||||
|
query: { name: tag.metadata.name },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<PostTag :tag="tag"></PostTag>
|
||||||
|
</RouterLink>
|
||||||
</VSpace>
|
</VSpace>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 flex">
|
<div class="mt-1 flex">
|
||||||
<VSpace>
|
<VSpace>
|
||||||
<span class="text-xs text-gray-500"
|
<p
|
||||||
>访问量 {{ post.visits }}</span
|
v-if="post.categories.length"
|
||||||
>
|
class="inline-flex flex-wrap gap-1 text-xs text-gray-500"
|
||||||
<span class="text-xs text-gray-500"
|
|
||||||
>评论 {{ post.commentCount }}</span
|
|
||||||
>
|
>
|
||||||
|
分类:<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>
|
</VSpace>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -376,15 +710,73 @@ onMounted(() => {
|
||||||
<div
|
<div
|
||||||
class="inline-flex flex-col flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
|
class="inline-flex flex-col flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
|
||||||
>
|
>
|
||||||
<img
|
<RouterLink
|
||||||
class="hidden h-6 w-6 rounded-full ring-2 ring-white sm:inline-block"
|
v-for="(contributor, index) in post.contributors"
|
||||||
src="https://ryanc.cc/avatar"
|
:key="index"
|
||||||
/>
|
:to="{
|
||||||
<time class="text-sm text-gray-500" datetime="2020-01-07">
|
name: 'UserDetail',
|
||||||
2020-01-07
|
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>
|
</time>
|
||||||
<span class="cursor-pointer">
|
<span>
|
||||||
<IconSettings @click.stop="handleSelect(post)" />
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -395,7 +787,12 @@ onMounted(() => {
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="bg-white sm:flex sm:items-center sm:justify-end">
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VCard>
|
</VCard>
|
||||||
|
|
|
@ -1,25 +1,96 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
// core libs
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { apiClient } from "@halo-dev/admin-shared";
|
||||||
|
|
||||||
|
// components
|
||||||
import {
|
import {
|
||||||
|
IconAddCircle,
|
||||||
IconBookRead,
|
IconBookRead,
|
||||||
IconList,
|
|
||||||
IconSettings,
|
|
||||||
VButton,
|
VButton,
|
||||||
VCard,
|
VCard,
|
||||||
|
VEmpty,
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
VSpace,
|
VSpace,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import CategoryEditingModal from "./components/CategoryEditingModal.vue";
|
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 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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<CategoryEditingModal v-model:visible="editingModal" />
|
<CategoryEditingModal
|
||||||
|
v-model:visible="editingModal"
|
||||||
|
:category="selectedCategory"
|
||||||
|
@close="onEditingModalClose"
|
||||||
|
/>
|
||||||
<VPageHeader title="文章分类">
|
<VPageHeader title="文章分类">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconBookRead class="mr-2 self-center" />
|
<IconBookRead class="mr-2 self-center" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<VButton type="secondary" @click="editingModal = true">
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle class="h-full w-full" />
|
||||||
|
</template>
|
||||||
|
新建
|
||||||
|
</VButton>
|
||||||
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
<div class="m-0 md:m-4">
|
<div class="m-0 md:m-4">
|
||||||
<VCard :body-class="['!p-0']">
|
<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"
|
class="relative flex flex-col items-start sm:flex-row sm:items-center"
|
||||||
>
|
>
|
||||||
<div class="flex w-full flex-1 sm:w-auto">
|
<div class="flex w-full flex-1 sm:w-auto">
|
||||||
<span class="text-base font-medium"> {{ 10 }} 个分类 </span>
|
<span class="text-base font-medium">
|
||||||
</div>
|
{{ categories.length }} 个分类
|
||||||
<div class="mt-4 flex sm:mt-0">
|
</span>
|
||||||
<VSpace>
|
|
||||||
<VButton size="xs" type="default" @click="editingModal = true">
|
|
||||||
新增
|
|
||||||
</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
|
<VEmpty
|
||||||
<li v-for="i in 10" :key="i">
|
v-if="!categories.length && !loading"
|
||||||
<div
|
message="你可以尝试刷新或者新建分类"
|
||||||
class="group relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
|
title="当前没有分类"
|
||||||
>
|
>
|
||||||
<div
|
<template #actions>
|
||||||
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"
|
<VSpace>
|
||||||
>
|
<VButton @click="handleFetchCategories">刷新</VButton>
|
||||||
<IconList class="h-3.5 w-3.5" />
|
<VButton type="primary" @click="editingModal = true">
|
||||||
</div>
|
<template #icon>
|
||||||
<div class="relative flex flex-row items-center">
|
<IconAddCircle class="h-full w-full" />
|
||||||
<div class="flex-1">
|
</template>
|
||||||
<div class="flex flex-col sm:flex-row">
|
新建分类
|
||||||
<span
|
</VButton>
|
||||||
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
|
</VSpace>
|
||||||
>
|
</template>
|
||||||
主题
|
</VEmpty>
|
||||||
</span>
|
<CategoryListItem
|
||||||
<VSpace class="mt-1 sm:mt-0"></VSpace>
|
v-else
|
||||||
</div>
|
:categories="categoriesTree"
|
||||||
<div class="mt-1 flex">
|
@change="handleUpdateInBatch"
|
||||||
<span class="text-xs text-gray-500">
|
@delete="handleDelete"
|
||||||
https://halo.run/categories/themes
|
@open-editing="handleOpenEditingModal"
|
||||||
</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>
|
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,14 +1,28 @@
|
||||||
<script lang="ts" setup>
|
<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";
|
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<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
category: unknown | null;
|
category: Category | null;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
visible: false,
|
visible: false,
|
||||||
category: undefined,
|
category: null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -17,36 +31,123 @@ const emit = defineEmits<{
|
||||||
(event: "close"): void;
|
(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) => {
|
const onVisibleChange = (visible: boolean) => {
|
||||||
emit("update:visible", visible);
|
emit("update:visible", visible);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
emit("close");
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VModal
|
<VModal
|
||||||
|
:title="modalTitle"
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:width="600"
|
:width="600"
|
||||||
title="编辑文章分类"
|
|
||||||
@update:visible="onVisibleChange"
|
@update:visible="onVisibleChange"
|
||||||
>
|
>
|
||||||
<FormKit id="category-form" type="form">
|
<FormKit id="category-form" type="form" @submit="handleSaveCategory">
|
||||||
<FormKit label="名称" type="text" validation="required"></FormKit>
|
|
||||||
<FormKit
|
<FormKit
|
||||||
|
v-model="formState.spec.displayName"
|
||||||
|
label="名称"
|
||||||
|
type="text"
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.spec.slug"
|
||||||
help="通常作为分类访问地址标识"
|
help="通常作为分类访问地址标识"
|
||||||
label="别名"
|
label="别名"
|
||||||
type="text"
|
type="text"
|
||||||
validation="required"
|
validation="required"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit label="上级目录" type="select"></FormKit>
|
<FormKit label="上级目录" type="select"></FormKit>
|
||||||
<FormKit help="需要主题适配以支持" label="封面图" type="text"></FormKit>
|
<FormKit
|
||||||
<FormKit help="需要主题适配以支持" label="描述" type="textarea"></FormKit>
|
v-model="formState.spec.cover"
|
||||||
|
help="需要主题适配以支持"
|
||||||
|
label="封面图"
|
||||||
|
type="text"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.spec.description"
|
||||||
|
help="需要主题适配以支持"
|
||||||
|
label="描述"
|
||||||
|
type="textarea"
|
||||||
|
></FormKit>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VSpace>
|
<VSpace>
|
||||||
<VButton type="secondary" @click="$formkit.submit('category-form')">
|
<VButton type="secondary" @click="$formkit.submit('category-form')">
|
||||||
提交 ⌘ + ↵
|
保存 ⌘ + ↵
|
||||||
</VButton>
|
</VButton>
|
||||||
<VButton @click="onVisibleChange(false)">取消 Esc</VButton>
|
<VButton @click="onVisibleChange(false)">取消 Esc</VButton>
|
||||||
</VSpace>
|
</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>
|
<script lang="ts" setup>
|
||||||
import {
|
import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components";
|
||||||
IconArrowLeft,
|
import { computed, ref, watch, watchEffect } from "vue";
|
||||||
IconArrowRight,
|
import type { PostRequest } from "@halo-dev/api-client";
|
||||||
VButton,
|
import cloneDeep from "lodash.clonedeep";
|
||||||
VModal,
|
import { usePostTag } from "@/modules/contents/posts/tags/composables/use-post-tag";
|
||||||
VSpace,
|
import { usePostCategory } from "@/modules/contents/posts/categories/composables/use-post-category";
|
||||||
VTabItem,
|
import { apiClient } from "@halo-dev/admin-shared";
|
||||||
VTabs,
|
import { v4 as uuid } from "uuid";
|
||||||
} from "@halo-dev/components";
|
|
||||||
import { ref, unref, watch } from "vue";
|
|
||||||
import type { Post } from "@halo-dev/admin-api";
|
|
||||||
|
|
||||||
interface FormState {
|
const initialFormState: PostRequest = {
|
||||||
post: Post | Record<string, unknown>;
|
post: {
|
||||||
saving: boolean;
|
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(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
post: Post | Record<string, unknown> | null;
|
post?: PostRequest | null;
|
||||||
|
onlyEmit?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
visible: false,
|
visible: false,
|
||||||
post: null,
|
post: null,
|
||||||
|
onlyEmit: false,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: "update:visible", visible: boolean): void;
|
(event: "update:visible", visible: boolean): void;
|
||||||
(event: "close"): void;
|
(event: "close"): void;
|
||||||
(event: "previous"): void;
|
(event: "saved", post: PostRequest): void;
|
||||||
(event: "next"): void;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const settingActiveId = ref("general");
|
const activeTab = ref("general");
|
||||||
const formState = ref<FormState>({
|
const formState = ref<PostRequest>(cloneDeep(initialFormState));
|
||||||
post: {},
|
const saving = ref(false);
|
||||||
saving: 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], () => {
|
const { tags } = usePostTag();
|
||||||
formState.value.post = unref(props.post) || {};
|
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) => {
|
const handleVisibleChange = (visible: boolean) => {
|
||||||
|
@ -50,92 +99,197 @@ const handleVisibleChange = (visible: boolean) => {
|
||||||
emit("close");
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VModal
|
<VModal
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:width="680"
|
:width="700"
|
||||||
title="文章设置"
|
title="文章设置"
|
||||||
@update:visible="handleVisibleChange"
|
@update:visible="handleVisibleChange"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<div class="modal-header-action" @click="emit('previous')">
|
<slot name="actions"></slot>
|
||||||
<IconArrowLeft />
|
|
||||||
</div>
|
|
||||||
<div class="modal-header-action" @click="emit('next')">
|
|
||||||
<IconArrowRight />
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<VTabs v-model:active-id="settingActiveId" type="outline">
|
<VTabs v-model:active-id="activeTab" type="outline">
|
||||||
<VTabItem id="general" label="常规">
|
<VTabItem id="general" label="常规">
|
||||||
<FormKit
|
<FormKit id="basic" :actions="false" :preserve="true" type="form">
|
||||||
id="basic"
|
|
||||||
:actions="false"
|
|
||||||
:model-value="formState.post"
|
|
||||||
:preserve="true"
|
|
||||||
type="form"
|
|
||||||
>
|
|
||||||
<FormKit
|
<FormKit
|
||||||
|
v-model="formState.post.spec.title"
|
||||||
label="标题"
|
label="标题"
|
||||||
name="title"
|
|
||||||
type="text"
|
type="text"
|
||||||
validation="required"
|
validation="required"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
<FormKit
|
||||||
|
v-model="formState.post.spec.slug"
|
||||||
label="别名"
|
label="别名"
|
||||||
name="slug"
|
name="slug"
|
||||||
type="text"
|
type="text"
|
||||||
validation="required"
|
validation="required"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit label="分类目录" type="select"></FormKit>
|
<FormKit
|
||||||
<FormKit label="标签" type="select"></FormKit>
|
v-model="formState.post.spec.categories"
|
||||||
<FormKit label="摘要" name="summary" type="textarea"></FormKit>
|
: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>
|
</FormKit>
|
||||||
</VTabItem>
|
</VTabItem>
|
||||||
<VTabItem id="advanced" label="高级">
|
<VTabItem id="advanced" label="高级">
|
||||||
<FormKit
|
<FormKit id="advanced" :actions="false" :preserve="true" type="form">
|
||||||
id="advanced"
|
|
||||||
:actions="false"
|
|
||||||
:model-value="formState.post"
|
|
||||||
:preserve="true"
|
|
||||||
type="form"
|
|
||||||
>
|
|
||||||
<FormKit
|
<FormKit
|
||||||
|
v-model="formState.post.spec.allowComment"
|
||||||
:options="[
|
:options="[
|
||||||
{ label: '是', value: true },
|
{ label: '是', value: true },
|
||||||
{ label: '否', value: false },
|
{ label: '否', value: false },
|
||||||
]"
|
]"
|
||||||
label="禁止评论"
|
label="禁止评论"
|
||||||
name="disallowComment"
|
|
||||||
type="radio"
|
type="radio"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit
|
<FormKit
|
||||||
|
v-model="formState.post.spec.pinned"
|
||||||
:options="[
|
:options="[
|
||||||
{ label: '是', value: true },
|
{ label: '是', value: true },
|
||||||
{ label: '否', value: false },
|
{ label: '否', value: false },
|
||||||
]"
|
]"
|
||||||
label="是否置顶"
|
label="是否置顶"
|
||||||
name="topPriority"
|
name="pinned"
|
||||||
type="radio"
|
type="radio"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<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="发表时间"
|
label="发表时间"
|
||||||
name="createTime"
|
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit label="自定义模板" type="select"></FormKit>
|
<FormKit
|
||||||
<FormKit label="封面图" name="thumbnail" type="text"></FormKit>
|
v-model="formState.post.spec.template"
|
||||||
|
label="自定义模板"
|
||||||
|
type="text"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.post.spec.cover"
|
||||||
|
label="封面图"
|
||||||
|
type="text"
|
||||||
|
></FormKit>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
</VTabItem>
|
</VTabItem>
|
||||||
<VTabItem id="seo" label="SEO">
|
<VTabItem id="seo" label="SEO">
|
||||||
<FormKit
|
<FormKit id="seo" :actions="false" :preserve="true" type="form">
|
||||||
id="seo"
|
|
||||||
:actions="false"
|
|
||||||
:model-value="formState.post"
|
|
||||||
:preserve="true"
|
|
||||||
type="form"
|
|
||||||
>
|
|
||||||
<FormKit
|
<FormKit
|
||||||
label="自定义关键词"
|
label="自定义关键词"
|
||||||
name="metaKeywords"
|
name="metaKeywords"
|
||||||
|
@ -150,13 +304,7 @@ const handleVisibleChange = (visible: boolean) => {
|
||||||
</VTabItem>
|
</VTabItem>
|
||||||
<VTabItem id="metas" label="元数据"></VTabItem>
|
<VTabItem id="metas" label="元数据"></VTabItem>
|
||||||
<VTabItem id="inject-code" label="代码注入">
|
<VTabItem id="inject-code" label="代码注入">
|
||||||
<FormKit
|
<FormKit id="inject-code" :actions="false" :preserve="true" type="form">
|
||||||
id="inject-code"
|
|
||||||
:actions="false"
|
|
||||||
:model-value="formState.post"
|
|
||||||
:preserve="true"
|
|
||||||
type="form"
|
|
||||||
>
|
|
||||||
<FormKit label="CSS" type="textarea"></FormKit>
|
<FormKit label="CSS" type="textarea"></FormKit>
|
||||||
<FormKit label="JavaScript" type="textarea"></FormKit>
|
<FormKit label="JavaScript" type="textarea"></FormKit>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
|
@ -166,14 +314,32 @@ const handleVisibleChange = (visible: boolean) => {
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VSpace>
|
<VSpace>
|
||||||
<VButton
|
<VButton
|
||||||
:loading="formState.saving"
|
v-if="formState.post.status?.phase === 'PUBLISHED'"
|
||||||
type="secondary"
|
:loading="publishCanceling"
|
||||||
@click="formState.saving = !formState.saving"
|
type="danger"
|
||||||
|
@click="handlePublishCanceling"
|
||||||
>
|
>
|
||||||
保存
|
取消发布
|
||||||
</VButton>
|
</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>
|
</VButton>
|
||||||
</VSpace>
|
</VSpace>
|
||||||
</template>
|
</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>
|
<script lang="ts" setup>
|
||||||
import { ref } from "vue";
|
// core libs
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
|
||||||
|
// components
|
||||||
import {
|
import {
|
||||||
IconAddCircle,
|
IconAddCircle,
|
||||||
IconBookRead,
|
IconBookRead,
|
||||||
|
@ -8,13 +11,22 @@ import {
|
||||||
IconSettings,
|
IconSettings,
|
||||||
VButton,
|
VButton,
|
||||||
VCard,
|
VCard,
|
||||||
|
VEmpty,
|
||||||
VPageHeader,
|
VPageHeader,
|
||||||
VSpace,
|
VSpace,
|
||||||
VTag,
|
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import TagEditingModal from "./components/TagEditingModal.vue";
|
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 = [
|
const viewTypes = [
|
||||||
{
|
{
|
||||||
name: "list",
|
name: "list",
|
||||||
|
@ -27,22 +39,82 @@ const viewTypes = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const viewType = ref("list");
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<TagEditingModal v-model:visible="editingModal" />
|
<TagEditingModal
|
||||||
|
v-model:visible="editingModal"
|
||||||
|
:tag="selectedTag"
|
||||||
|
@close="onEditingModalClose"
|
||||||
|
@next="handleSelectNext"
|
||||||
|
@previous="handleSelectPrevious"
|
||||||
|
/>
|
||||||
<VPageHeader title="文章标签">
|
<VPageHeader title="文章标签">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconBookRead class="mr-2 self-center" />
|
<IconBookRead class="mr-2 self-center" />
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<VSpace>
|
<VButton type="secondary" @click="editingModal = true">
|
||||||
<VButton type="secondary" @click="editingModal = true">
|
<template #icon>
|
||||||
<template #icon>
|
<IconAddCircle class="h-full w-full" />
|
||||||
<IconAddCircle class="h-full w-full" />
|
</template>
|
||||||
</template>
|
新建
|
||||||
新建
|
</VButton>
|
||||||
</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</template>
|
</template>
|
||||||
</VPageHeader>
|
</VPageHeader>
|
||||||
<div class="m-0 md:m-4">
|
<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"
|
class="relative flex flex-col items-start sm:flex-row sm:items-center"
|
||||||
>
|
>
|
||||||
<div class="flex w-full flex-1 sm:w-auto">
|
<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>
|
||||||
<div class="flex flex-row gap-2">
|
<div class="flex flex-row gap-2">
|
||||||
<div
|
<div
|
||||||
|
@ -71,71 +145,120 @@ const viewType = ref("list");
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<ul
|
<VEmpty
|
||||||
v-if="viewType === 'list'"
|
v-if="!tags.length && !loading"
|
||||||
class="box-border h-full w-full divide-y divide-gray-100"
|
message="你可以尝试刷新或者新建标签"
|
||||||
role="list"
|
title="当前没有标签"
|
||||||
>
|
>
|
||||||
<li v-for="i in 10" :key="i">
|
<template #actions>
|
||||||
<div
|
<VSpace>
|
||||||
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
|
<VButton @click="handleFetchTags">刷新</VButton>
|
||||||
>
|
<VButton type="primary" @click="editingModal = true">
|
||||||
<div class="relative flex flex-row items-center">
|
<template #icon>
|
||||||
<div class="flex-1">
|
<IconAddCircle class="h-full w-full" />
|
||||||
<div class="flex flex-col sm:flex-row">
|
</template>
|
||||||
<VTag>主题</VTag>
|
新建标签
|
||||||
</div>
|
</VButton>
|
||||||
<div class="mt-1 flex">
|
</VSpace>
|
||||||
<span class="text-xs text-gray-500">
|
</template>
|
||||||
https://halo.run/tags/themes
|
</VEmpty>
|
||||||
</span>
|
<div v-else>
|
||||||
</div>
|
<ul
|
||||||
</div>
|
v-if="viewType === 'list'"
|
||||||
<div class="flex">
|
class="box-border h-full w-full divide-y divide-gray-100"
|
||||||
<div
|
role="list"
|
||||||
class="inline-flex flex-col flex-col-reverse items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
|
>
|
||||||
>
|
<li v-for="(tag, index) in tags" :key="index">
|
||||||
<div
|
<div
|
||||||
class="cursor-pointer text-sm text-gray-500 hover:text-gray-900"
|
:class="{
|
||||||
>
|
'bg-gray-100': selectedTag?.metadata.name === tag.metadata.name,
|
||||||
20 篇文章
|
}"
|
||||||
|
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
</ul>
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-wrap gap-3 p-4" role="list">
|
<div v-else class="flex flex-wrap gap-3 p-4" role="list">
|
||||||
<VTag v-for="i in 100" :key="i">主题(10)</VTag>
|
<PostTag
|
||||||
|
v-for="(tag, index) in tags"
|
||||||
|
:key="index"
|
||||||
|
:tag="tag"
|
||||||
|
@click="handleOpenEditingModal(tag)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</VCard>
|
</VCard>
|
||||||
</div>
|
</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>
|
<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<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
tag: unknown | null;
|
tag: Tag | null;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
visible: false,
|
visible: false,
|
||||||
tag: undefined,
|
tag: null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: "update:visible", visible: boolean): void;
|
(event: "update:visible", visible: boolean): void;
|
||||||
(event: "close"): 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) => {
|
const onVisibleChange = (visible: boolean) => {
|
||||||
emit("update:visible", visible);
|
emit("update:visible", visible);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
emit("close");
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VModal
|
<VModal
|
||||||
|
:title="modalTitle"
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:width="600"
|
:width="600"
|
||||||
title="编辑文章标签"
|
|
||||||
@update:visible="onVisibleChange"
|
@update:visible="onVisibleChange"
|
||||||
>
|
>
|
||||||
<FormKit id="tag-form" type="form">
|
<template #actions>
|
||||||
<FormKit label="名称" type="text" validation="required"></FormKit>
|
<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
|
<FormKit
|
||||||
|
v-model="formState.spec.displayName"
|
||||||
|
label="名称"
|
||||||
|
type="text"
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.spec.slug"
|
||||||
help="通常作为标签访问地址标识"
|
help="通常作为标签访问地址标识"
|
||||||
label="别名"
|
label="别名"
|
||||||
type="text"
|
type="text"
|
||||||
validation="required"
|
validation="required"
|
||||||
></FormKit>
|
></FormKit>
|
||||||
<FormKit help="需要主题适配以支持" label="颜色" type="color"></FormKit>
|
<FormKit
|
||||||
<FormKit help="需要主题适配以支持" label="封面图" type="text"></FormKit>
|
v-model="formState.spec.color"
|
||||||
|
help="需要主题适配以支持"
|
||||||
|
label="颜色"
|
||||||
|
type="color"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.spec.cover"
|
||||||
|
help="需要主题适配以支持"
|
||||||
|
label="封面图"
|
||||||
|
type="text"
|
||||||
|
></FormKit>
|
||||||
</FormKit>
|
</FormKit>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VSpace>
|
<VSpace>
|
||||||
<VButton type="secondary" @click="$formkit.submit('tag-form')">
|
<VButton
|
||||||
提交 ⌘ + ↵
|
:loading="saving"
|
||||||
|
type="secondary"
|
||||||
|
@click="$formkit.submit('tag-form')"
|
||||||
|
>
|
||||||
|
保存
|
||||||
</VButton>
|
</VButton>
|
||||||
<VButton @click="onVisibleChange(false)">取消 Esc</VButton>
|
<VButton @click="onVisibleChange(false)">取消 Esc</VButton>
|
||||||
</VSpace>
|
</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>
|
<script lang="ts" name="RecentPublishedWidget" setup>
|
||||||
import { VCard, VSpace } from "@halo-dev/components";
|
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>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VCard
|
<VCard
|
||||||
|
@ -18,23 +34,19 @@ import { posts } from "@/modules/contents/posts/posts-mock";
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<p class="truncate text-sm font-medium text-gray-900">
|
<p class="truncate text-sm font-medium text-gray-900">
|
||||||
{{ post.title }}
|
{{ post.spec.title }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-1 flex">
|
<div class="mt-1 flex">
|
||||||
<VSpace>
|
<VSpace>
|
||||||
<span class="text-xs text-gray-500">
|
<span class="text-xs text-gray-500"> 阅读 0 </span>
|
||||||
阅读 {{ post.visits }}
|
<span class="text-xs text-gray-500"> 评论 0 </span>
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
评论 {{ post.commentCount }}
|
|
||||||
</span>
|
|
||||||
</VSpace>
|
</VSpace>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<time class="text-sm text-gray-500" datetime="2020-01-07 20:00">
|
<time class="text-sm text-gray-500">
|
||||||
2020-01-07 20:00
|
{{ post.metadata.creationTimestamp }}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</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