mirror of https://github.com/halo-dev/halo
feat: single page management (halo-dev/console#606)
parent
5b66860504
commit
0e9d3c86a1
|
@ -33,7 +33,7 @@
|
||||||
"@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-shared": "workspace:*",
|
"@halo-dev/admin-shared": "workspace:*",
|
||||||
"@halo-dev/api-client": "^0.0.15",
|
"@halo-dev/api-client": "^0.0.17",
|
||||||
"@halo-dev/components": "workspace:*",
|
"@halo-dev/components": "workspace:*",
|
||||||
"@halo-dev/richtext-editor": "^0.0.0-alpha.6",
|
"@halo-dev/richtext-editor": "^0.0.0-alpha.6",
|
||||||
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
"@tiptap/extension-character-count": "2.0.0-beta.31",
|
||||||
|
|
|
@ -38,7 +38,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.15",
|
"@halo-dev/api-client": "^0.0.17",
|
||||||
"@halo-dev/components": "workspace:*",
|
"@halo-dev/components": "workspace:*",
|
||||||
"axios": "^0.27.2",
|
"axios": "^0.27.2",
|
||||||
"lodash.merge": "^4.6.2"
|
"lodash.merge": "^4.6.2"
|
||||||
|
|
|
@ -10,6 +10,7 @@ import {
|
||||||
ContentHaloRunV1alpha1ReplyApi,
|
ContentHaloRunV1alpha1ReplyApi,
|
||||||
ContentHaloRunV1alpha1SnapshotApi,
|
ContentHaloRunV1alpha1SnapshotApi,
|
||||||
ContentHaloRunV1alpha1TagApi,
|
ContentHaloRunV1alpha1TagApi,
|
||||||
|
ContentHaloRunV1alpha1SinglePageApi,
|
||||||
PluginHaloRunV1alpha1PluginApi,
|
PluginHaloRunV1alpha1PluginApi,
|
||||||
PluginHaloRunV1alpha1ReverseProxyApi,
|
PluginHaloRunV1alpha1ReverseProxyApi,
|
||||||
StorageHaloRunV1alpha1AttachmentApi,
|
StorageHaloRunV1alpha1AttachmentApi,
|
||||||
|
@ -78,6 +79,11 @@ function setupApiClient(axios: AxiosInstance) {
|
||||||
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),
|
post: new ContentHaloRunV1alpha1PostApi(undefined, apiUrl, axios),
|
||||||
|
singlePage: new ContentHaloRunV1alpha1SinglePageApi(
|
||||||
|
undefined,
|
||||||
|
apiUrl,
|
||||||
|
axios
|
||||||
|
),
|
||||||
category: new ContentHaloRunV1alpha1CategoryApi(undefined, apiUrl, axios),
|
category: new ContentHaloRunV1alpha1CategoryApi(undefined, apiUrl, axios),
|
||||||
tag: new ContentHaloRunV1alpha1TagApi(undefined, apiUrl, axios),
|
tag: new ContentHaloRunV1alpha1TagApi(undefined, apiUrl, axios),
|
||||||
snapshot: new ContentHaloRunV1alpha1SnapshotApi(undefined, apiUrl, axios),
|
snapshot: new ContentHaloRunV1alpha1SnapshotApi(undefined, apiUrl, axios),
|
||||||
|
|
|
@ -13,7 +13,7 @@ importers:
|
||||||
'@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-shared': workspace:*
|
'@halo-dev/admin-shared': workspace:*
|
||||||
'@halo-dev/api-client': ^0.0.15
|
'@halo-dev/api-client': ^0.0.17
|
||||||
'@halo-dev/components': workspace:*
|
'@halo-dev/components': workspace:*
|
||||||
'@halo-dev/richtext-editor': ^0.0.0-alpha.6
|
'@halo-dev/richtext-editor': ^0.0.0-alpha.6
|
||||||
'@iconify-json/vscode-icons': ^1.1.11
|
'@iconify-json/vscode-icons': ^1.1.11
|
||||||
|
@ -92,7 +92,7 @@ importers:
|
||||||
'@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-shared': link:packages/shared
|
'@halo-dev/admin-shared': link:packages/shared
|
||||||
'@halo-dev/api-client': 0.0.15
|
'@halo-dev/api-client': 0.0.17
|
||||||
'@halo-dev/components': link:packages/components
|
'@halo-dev/components': link:packages/components
|
||||||
'@halo-dev/richtext-editor': 0.0.0-alpha.6_vue@3.2.37
|
'@halo-dev/richtext-editor': 0.0.0-alpha.6_vue@3.2.37
|
||||||
'@tiptap/extension-character-count': 2.0.0-beta.31
|
'@tiptap/extension-character-count': 2.0.0-beta.31
|
||||||
|
@ -194,14 +194,14 @@ importers:
|
||||||
|
|
||||||
packages/shared:
|
packages/shared:
|
||||||
specifiers:
|
specifiers:
|
||||||
'@halo-dev/api-client': ^0.0.15
|
'@halo-dev/api-client': ^0.0.17
|
||||||
'@halo-dev/components': workspace:*
|
'@halo-dev/components': workspace:*
|
||||||
'@types/lodash.merge': ^4.6.7
|
'@types/lodash.merge': ^4.6.7
|
||||||
axios: ^0.27.2
|
axios: ^0.27.2
|
||||||
lodash.merge: ^4.6.2
|
lodash.merge: ^4.6.2
|
||||||
vite-plugin-dts: ^1.4.1
|
vite-plugin-dts: ^1.4.1
|
||||||
dependencies:
|
dependencies:
|
||||||
'@halo-dev/api-client': 0.0.15
|
'@halo-dev/api-client': 0.0.17
|
||||||
'@halo-dev/components': link:../components
|
'@halo-dev/components': link:../components
|
||||||
axios: 0.27.2
|
axios: 0.27.2
|
||||||
lodash.merge: 4.6.2
|
lodash.merge: 4.6.2
|
||||||
|
@ -2129,8 +2129,8 @@ packages:
|
||||||
- windicss
|
- windicss
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@halo-dev/api-client/0.0.15:
|
/@halo-dev/api-client/0.0.17:
|
||||||
resolution: {integrity: sha512-RCQXU2s5IJJ3pUORg0n2b/CYbDox/Jm4aXB2J9ZbKod3vdS3HM6X33iVXc/pyuxRjucijly3WyLkiTZ0bkwLbg==}
|
resolution: {integrity: sha512-+OCYSioOxkaxN71t1mHOACw/pkF84nGd04jtS5Ak4FbrtBb4T2sJ+P76r8BwwG2uCk3lss1PkeDdP9/QxfUfxQ==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@halo-dev/richtext-editor/0.0.0-alpha.6_vue@3.2.37:
|
/@halo-dev/richtext-editor/0.0.0-alpha.6_vue@3.2.37:
|
||||||
|
|
|
@ -7,8 +7,9 @@ import type {
|
||||||
} from "@halo-dev/api-client";
|
} from "@halo-dev/api-client";
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
import { ref, watch } from "vue";
|
import { ref, watch } from "vue";
|
||||||
import { apiClient } from "@halo-dev/admin-shared";
|
import { apiClient, type AttachmentLike } from "@halo-dev/admin-shared";
|
||||||
import { useDialog } from "@halo-dev/components";
|
import { useDialog } from "@halo-dev/components";
|
||||||
|
import type { Content, Editor } from "@halo-dev/richtext-editor";
|
||||||
|
|
||||||
interface useAttachmentControlReturn {
|
interface useAttachmentControlReturn {
|
||||||
attachments: Ref<AttachmentList>;
|
attachments: Ref<AttachmentList>;
|
||||||
|
@ -33,6 +34,10 @@ interface useAttachmentControlReturn {
|
||||||
handleReset: () => void;
|
handleReset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface useAttachmentSelectReturn {
|
||||||
|
onAttachmentSelect: (attachments: AttachmentLike[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
export function useAttachmentControl(filterOptions?: {
|
export function useAttachmentControl(filterOptions?: {
|
||||||
policy?: Ref<Policy | undefined>;
|
policy?: Ref<Policy | undefined>;
|
||||||
group?: Ref<Group | undefined>;
|
group?: Ref<Group | undefined>;
|
||||||
|
@ -212,3 +217,41 @@ export function useAttachmentControl(filterOptions?: {
|
||||||
handleReset,
|
handleReset,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useAttachmentSelect(
|
||||||
|
editor: Ref<Editor | undefined>
|
||||||
|
): useAttachmentSelectReturn {
|
||||||
|
const onAttachmentSelect = (attachments: AttachmentLike[]) => {
|
||||||
|
const images: Content[] = attachments.map((attachment) => {
|
||||||
|
const attrs: { src?: string; alt?: string } = {};
|
||||||
|
if (typeof attachment === "string") {
|
||||||
|
attrs.src = attachment;
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
attrs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if ("url" in attachment) {
|
||||||
|
attrs.src = attachment.url;
|
||||||
|
attrs.alt = attachment.type;
|
||||||
|
}
|
||||||
|
if ("spec" in attachment) {
|
||||||
|
attrs.src = attachment.status?.permalink;
|
||||||
|
attrs.alt = attachment.spec.displayName;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "image",
|
||||||
|
attrs,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
editor.value
|
||||||
|
?.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContent([...images, { type: "paragraph", content: "" }])
|
||||||
|
.run();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onAttachmentSelect,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import {
|
||||||
|
VEmpty,
|
||||||
|
VSpace,
|
||||||
|
VButton,
|
||||||
|
IconAddCircle,
|
||||||
|
IconSettings,
|
||||||
|
VTag,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import type { PagesPublicState } from "@halo-dev/admin-shared";
|
||||||
|
import { useExtensionPointsState } from "@/composables/usePlugins";
|
||||||
|
|
||||||
|
const pagesPublicState = ref<PagesPublicState>({
|
||||||
|
functionalPages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
useExtensionPointsState("PAGES", pagesPublicState);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VEmpty
|
||||||
|
v-if="!pagesPublicState.functionalPages.length"
|
||||||
|
message="当前没有功能页面,功能页面通常由各个插件提供,你可以尝试安装新插件以获得支持"
|
||||||
|
title="当前没有功能页面"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<VSpace>
|
||||||
|
<VButton :route="{ name: 'Plugins' }" type="primary">
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle class="h-full w-full" />
|
||||||
|
</template>
|
||||||
|
安装插件
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VEmpty>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="box-border h-full w-full divide-y divide-gray-100"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(page, index) in pagesPublicState.functionalPages"
|
||||||
|
:key="index"
|
||||||
|
v-permission="page.permissions"
|
||||||
|
@click="$router.push({ path: page.path })"
|
||||||
|
>
|
||||||
|
<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-row">
|
||||||
|
<span
|
||||||
|
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
|
||||||
|
>
|
||||||
|
{{ page.name }}
|
||||||
|
</span>
|
||||||
|
<VTag>{{ page.url }}</VTag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div
|
||||||
|
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
|
||||||
|
>
|
||||||
|
<span class="cursor-pointer">
|
||||||
|
<IconSettings />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
|
@ -1,351 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import {
|
|
||||||
IconAddCircle,
|
|
||||||
IconArrowDown,
|
|
||||||
IconPages,
|
|
||||||
IconSettings,
|
|
||||||
VButton,
|
|
||||||
VCard,
|
|
||||||
VEmpty,
|
|
||||||
VPageHeader,
|
|
||||||
VPagination,
|
|
||||||
VSpace,
|
|
||||||
VTabbar,
|
|
||||||
VTag,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import { ref } from "vue";
|
|
||||||
import type { PagesPublicState } from "@halo-dev/admin-shared";
|
|
||||||
import { useExtensionPointsState } from "@/composables/usePlugins";
|
|
||||||
import { useUserFetch } from "@/modules/system/users/composables/use-user";
|
|
||||||
|
|
||||||
const pagesRef = ref([
|
|
||||||
{
|
|
||||||
title: "关于我们",
|
|
||||||
url: "/about",
|
|
||||||
views: "31231",
|
|
||||||
commentCount: "32",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "案例中心",
|
|
||||||
url: "/case",
|
|
||||||
views: "11431",
|
|
||||||
commentCount: "35",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "我们的产品",
|
|
||||||
url: "/products",
|
|
||||||
views: "11431",
|
|
||||||
commentCount: "35",
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const activeId = ref("functional");
|
|
||||||
const checkAll = ref(false);
|
|
||||||
|
|
||||||
const { users } = useUserFetch({ fetchOnMounted: true });
|
|
||||||
|
|
||||||
const pagesPublicState = ref<PagesPublicState>({
|
|
||||||
functionalPages: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
useExtensionPointsState("PAGES", pagesPublicState);
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<VPageHeader title="页面">
|
|
||||||
<template #icon>
|
|
||||||
<IconPages class="mr-2 self-center" />
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<VButton type="secondary">
|
|
||||||
<template #icon>
|
|
||||||
<IconAddCircle class="h-full w-full" />
|
|
||||||
</template>
|
|
||||||
新建
|
|
||||||
</VButton>
|
|
||||||
</template>
|
|
||||||
</VPageHeader>
|
|
||||||
|
|
||||||
<div class="m-0 md:m-4">
|
|
||||||
<VCard :body-class="['!p-0']">
|
|
||||||
<template #header>
|
|
||||||
<VTabbar
|
|
||||||
v-model:active-id="activeId"
|
|
||||||
:items="[
|
|
||||||
{ id: 'functional', label: '功能页面' },
|
|
||||||
{ id: 'custom', label: '自定义页面' },
|
|
||||||
]"
|
|
||||||
class="w-full !rounded-none"
|
|
||||||
type="outline"
|
|
||||||
></VTabbar>
|
|
||||||
</template>
|
|
||||||
<div v-if="activeId === 'functional'">
|
|
||||||
<VEmpty
|
|
||||||
v-if="!pagesPublicState.functionalPages.length"
|
|
||||||
message="当前没有功能页面,功能页面通常由各个插件提供,你可以尝试安装新插件以获得支持"
|
|
||||||
title="当前没有功能页面"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<VSpace>
|
|
||||||
<VButton :route="{ name: 'Plugins' }" type="primary">
|
|
||||||
<template #icon>
|
|
||||||
<IconAddCircle class="h-full w-full" />
|
|
||||||
</template>
|
|
||||||
安装插件
|
|
||||||
</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</template>
|
|
||||||
</VEmpty>
|
|
||||||
<ul
|
|
||||||
v-else
|
|
||||||
class="box-border h-full w-full divide-y divide-gray-100"
|
|
||||||
role="list"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="(page, index) in pagesPublicState.functionalPages"
|
|
||||||
:key="index"
|
|
||||||
v-permission="page.permissions"
|
|
||||||
@click="$router.push({ path: page.path })"
|
|
||||||
>
|
|
||||||
<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-row">
|
|
||||||
<span
|
|
||||||
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
|
|
||||||
>
|
|
||||||
{{ page.name }}
|
|
||||||
</span>
|
|
||||||
<VTag>{{ page.url }}</VTag>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<span class="cursor-pointer">
|
|
||||||
<IconSettings />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<div v-if="activeId === 'custom'">
|
|
||||||
<VCard
|
|
||||||
:body-class="['!p-0']"
|
|
||||||
class="rounded-none border-none shadow-none"
|
|
||||||
>
|
|
||||||
<template #header>
|
|
||||||
<div class="block w-full bg-gray-50 px-4 py-3">
|
|
||||||
<div
|
|
||||||
class="relative flex flex-col items-start sm:flex-row sm:items-center"
|
|
||||||
>
|
|
||||||
<div class="mr-4 hidden items-center sm:flex">
|
|
||||||
<input
|
|
||||||
v-model="checkAll"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex w-full flex-1 sm:w-auto">
|
|
||||||
<FormKit
|
|
||||||
v-if="!checkAll"
|
|
||||||
placeholder="输入关键词搜索"
|
|
||||||
type="text"
|
|
||||||
></FormKit>
|
|
||||||
<VSpace v-else>
|
|
||||||
<VButton type="default">设置</VButton>
|
|
||||||
<VButton type="danger">删除</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex sm:mt-0">
|
|
||||||
<VSpace spacing="lg">
|
|
||||||
<FloatingDropdown>
|
|
||||||
<div
|
|
||||||
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
|
|
||||||
>
|
|
||||||
<span class="mr-0.5">作者</span>
|
|
||||||
<span>
|
|
||||||
<IconArrowDown />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<template #popper>
|
|
||||||
<div class="h-96 w-80 p-4">
|
|
||||||
<div class="bg-white">
|
|
||||||
<!--TODO: Auto Focus-->
|
|
||||||
<FormKit
|
|
||||||
placeholder="输入关键词搜索"
|
|
||||||
type="text"
|
|
||||||
></FormKit>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<ul class="divide-y divide-gray-200" role="list">
|
|
||||||
<li
|
|
||||||
v-for="(user, index) in users"
|
|
||||||
:key="index"
|
|
||||||
v-close-popper
|
|
||||||
class="cursor-pointer py-4 hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div class="flex items-center space-x-4">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<input
|
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<img
|
|
||||||
:alt="user.spec.displayName"
|
|
||||||
:src="user.spec.avatar"
|
|
||||||
class="h-10 w-10 rounded"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<p
|
|
||||||
class="truncate text-sm font-medium text-gray-900"
|
|
||||||
>
|
|
||||||
{{ user.spec.displayName }}
|
|
||||||
</p>
|
|
||||||
<p class="truncate text-sm text-gray-500">
|
|
||||||
@{{ user.metadata.name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<VTag>{{ index + 1 }} 篇</VTag>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FloatingDropdown>
|
|
||||||
<FloatingDropdown>
|
|
||||||
<div
|
|
||||||
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
|
|
||||||
>
|
|
||||||
<span class="mr-0.5">排序</span>
|
|
||||||
<span>
|
|
||||||
<IconArrowDown />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<template #popper>
|
|
||||||
<div class="w-72 p-4">
|
|
||||||
<ul class="space-y-1">
|
|
||||||
<li
|
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">较近发布</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">较晚发布</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">浏览量最多</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">浏览量最少</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">评论量最多</span>
|
|
||||||
</li>
|
|
||||||
<li
|
|
||||||
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
<span class="truncate">评论量最少</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</FloatingDropdown>
|
|
||||||
</VSpace>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<ul
|
|
||||||
class="box-border h-full w-full divide-y divide-gray-100"
|
|
||||||
role="list"
|
|
||||||
>
|
|
||||||
<li v-for="(page, index) in pagesRef" :key="index">
|
|
||||||
<div
|
|
||||||
:class="{
|
|
||||||
'bg-gray-100': checkAll,
|
|
||||||
}"
|
|
||||||
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-show="checkAll"
|
|
||||||
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
|
|
||||||
></div>
|
|
||||||
<div class="relative flex flex-row items-center">
|
|
||||||
<div class="mr-4 hidden items-center sm:flex">
|
|
||||||
<input
|
|
||||||
v-model="checkAll"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
|
||||||
type="checkbox"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="flex flex-row">
|
|
||||||
<span
|
|
||||||
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
|
|
||||||
>
|
|
||||||
{{ page.title }}
|
|
||||||
</span>
|
|
||||||
<VTag>{{ page.url }}</VTag>
|
|
||||||
</div>
|
|
||||||
<div class="mt-1 flex">
|
|
||||||
<VSpace>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
访问量 {{ page.views }}
|
|
||||||
</span>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
评论 {{ page.commentCount }}
|
|
||||||
</span>
|
|
||||||
</VSpace>
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
class="hidden h-6 w-6 rounded-full ring-2 ring-white sm:inline-block"
|
|
||||||
src="https://ryanc.cc/avatar"
|
|
||||||
/>
|
|
||||||
<time class="text-sm text-gray-500" datetime="2020-01-07">
|
|
||||||
2020-01-07
|
|
||||||
</time>
|
|
||||||
<span class="cursor-pointer">
|
|
||||||
<IconSettings />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<template #footer>
|
|
||||||
<div class="bg-white sm:flex sm:items-center sm:justify-end">
|
|
||||||
<VPagination :page="1" :size="10" :total="20" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
</VCard>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
|
@ -0,0 +1,209 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
VPageHeader,
|
||||||
|
IconPages,
|
||||||
|
VSpace,
|
||||||
|
VButton,
|
||||||
|
IconSave,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
|
||||||
|
import PostPreviewModal from "../posts/components/PostPreviewModal.vue";
|
||||||
|
import AttachmentSelectorModal from "../attachments/components/AttachmentSelectorModal.vue";
|
||||||
|
import {
|
||||||
|
allExtensions,
|
||||||
|
RichTextEditor,
|
||||||
|
useEditor,
|
||||||
|
} from "@halo-dev/richtext-editor";
|
||||||
|
import type { SinglePageRequest } from "@halo-dev/api-client";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { apiClient } from "@halo-dev/admin-shared";
|
||||||
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
|
import cloneDeep from "lodash.clonedeep";
|
||||||
|
import { useAttachmentSelect } from "../attachments/composables/use-attachment";
|
||||||
|
|
||||||
|
const initialFormState: SinglePageRequest = {
|
||||||
|
page: {
|
||||||
|
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: "",
|
||||||
|
},
|
||||||
|
htmlMetas: [],
|
||||||
|
},
|
||||||
|
apiVersion: "content.halo.run/v1alpha1",
|
||||||
|
kind: "SinglePage",
|
||||||
|
metadata: {
|
||||||
|
name: uuid(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
raw: "",
|
||||||
|
content: "",
|
||||||
|
rawType: "HTML",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const formState = ref<SinglePageRequest>(cloneDeep(initialFormState));
|
||||||
|
const saving = ref(false);
|
||||||
|
const settingModal = ref(false);
|
||||||
|
const previewModal = ref(false);
|
||||||
|
const attachemntSelectorModal = ref(false);
|
||||||
|
|
||||||
|
const isUpdateMode = computed(() => {
|
||||||
|
return !!formState.value.page.metadata.creationTimestamp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Editor
|
||||||
|
const editor = useEditor({
|
||||||
|
content: formState.value.content.raw,
|
||||||
|
extensions: [...allExtensions],
|
||||||
|
autofocus: "start",
|
||||||
|
onUpdate: () => {
|
||||||
|
formState.value.content.raw = editor.value?.getHTML() + "";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => formState.value.content.raw,
|
||||||
|
(newValue) => {
|
||||||
|
const isSame = editor.value?.getHTML() === newValue;
|
||||||
|
|
||||||
|
if (isSame) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.value?.commands.setContent(newValue as string, false);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const { onAttachmentSelect } = useAttachmentSelect(editor);
|
||||||
|
|
||||||
|
const routeQueryName = useRouteQuery<string>("name");
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true;
|
||||||
|
|
||||||
|
// Set rendered content
|
||||||
|
formState.value.content.content = formState.value.content.raw;
|
||||||
|
|
||||||
|
//Set default title and slug
|
||||||
|
if (!formState.value.page.spec.title) {
|
||||||
|
formState.value.page.spec.title = "无标题页面";
|
||||||
|
}
|
||||||
|
if (!formState.value.page.spec.slug) {
|
||||||
|
formState.value.page.spec.slug = uuid();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isUpdateMode.value) {
|
||||||
|
const { data } = await apiClient.post.updateDraftSinglePage({
|
||||||
|
name: formState.value.page.metadata.name,
|
||||||
|
singlePageRequest: formState.value,
|
||||||
|
});
|
||||||
|
formState.value.page = data;
|
||||||
|
} else {
|
||||||
|
const { data } = await apiClient.post.draftSinglePage({
|
||||||
|
singlePageRequest: formState.value,
|
||||||
|
});
|
||||||
|
formState.value.page = data;
|
||||||
|
routeQueryName.value = data.metadata.name;
|
||||||
|
}
|
||||||
|
await handleFetchContent();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save single page", error);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFetchContent = async () => {
|
||||||
|
if (!formState.value.page.spec.headSnapshot) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { data } = await apiClient.content.obtainSnapshotContent({
|
||||||
|
snapshotName: formState.value.page.spec.headSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
formState.value.content = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSettingSaved = (page: SinglePageRequest) => {
|
||||||
|
// Set route query parameter
|
||||||
|
if (!isUpdateMode.value) {
|
||||||
|
routeQueryName.value = page.page.metadata.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
formState.value = page;
|
||||||
|
settingModal.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (routeQueryName.value) {
|
||||||
|
const { data: singlePage } =
|
||||||
|
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
|
||||||
|
name: routeQueryName.value,
|
||||||
|
});
|
||||||
|
formState.value.page = singlePage;
|
||||||
|
|
||||||
|
// fetch single page content
|
||||||
|
await handleFetchContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePageSettingModal
|
||||||
|
v-model:visible="settingModal"
|
||||||
|
:single-page="formState"
|
||||||
|
@saved="onSettingSaved"
|
||||||
|
/>
|
||||||
|
<PostPreviewModal v-model:visible="previewModal" />
|
||||||
|
<AttachmentSelectorModal
|
||||||
|
v-model:visible="attachemntSelectorModal"
|
||||||
|
@select="onAttachmentSelect"
|
||||||
|
/>
|
||||||
|
<VPageHeader title="自定义页面">
|
||||||
|
<template #icon>
|
||||||
|
<IconPages class="mr-2 self-center" />
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<VSpace>
|
||||||
|
<VButton
|
||||||
|
size="sm"
|
||||||
|
type="default"
|
||||||
|
@click="attachemntSelectorModal = true"
|
||||||
|
>
|
||||||
|
附件库
|
||||||
|
</VButton>
|
||||||
|
<VButton size="sm" type="default" @click="previewModal = true">
|
||||||
|
预览
|
||||||
|
</VButton>
|
||||||
|
<VButton :loading="saving" size="sm" type="default" @click="handleSave">
|
||||||
|
保存
|
||||||
|
</VButton>
|
||||||
|
<VButton type="secondary" @click="settingModal = true">
|
||||||
|
<template #icon>
|
||||||
|
<IconSave class="h-full w-full" />
|
||||||
|
</template>
|
||||||
|
发布
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VPageHeader>
|
||||||
|
<div class="editor border-t" style="height: calc(100vh - 3.5rem)">
|
||||||
|
<RichTextEditor v-if="editor" :editor="editor"> </RichTextEditor>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,488 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
IconArrowDown,
|
||||||
|
IconArrowLeft,
|
||||||
|
IconArrowRight,
|
||||||
|
IconSettings,
|
||||||
|
IconEye,
|
||||||
|
IconEyeOff,
|
||||||
|
IconTeam,
|
||||||
|
IconCloseCircle,
|
||||||
|
IconAddCircle,
|
||||||
|
VButton,
|
||||||
|
VCard,
|
||||||
|
VPagination,
|
||||||
|
VSpace,
|
||||||
|
useDialog,
|
||||||
|
VEmpty,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import SinglePageSettingModal from "./components/SinglePageSettingModal.vue";
|
||||||
|
import UserDropdownSelector from "@/components/dropdown-selector/UserDropdownSelector.vue";
|
||||||
|
import { onMounted, ref, watchEffect } from "vue";
|
||||||
|
import type {
|
||||||
|
ListedSinglePageList,
|
||||||
|
SinglePage,
|
||||||
|
SinglePageRequest,
|
||||||
|
User,
|
||||||
|
} from "@halo-dev/api-client";
|
||||||
|
import { apiClient } from "@halo-dev/admin-shared";
|
||||||
|
import { formatDatetime } from "@/utils/date";
|
||||||
|
import { RouterLink } from "vue-router";
|
||||||
|
import cloneDeep from "lodash.clonedeep";
|
||||||
|
|
||||||
|
enum SinglePagePhase {
|
||||||
|
DRAFT = "未发布",
|
||||||
|
PENDING_APPROVAL = "待审核",
|
||||||
|
PUBLISHED = "已发布",
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = useDialog();
|
||||||
|
|
||||||
|
const singlePages = ref<ListedSinglePageList>({
|
||||||
|
page: 1,
|
||||||
|
size: 20,
|
||||||
|
total: 0,
|
||||||
|
items: [],
|
||||||
|
first: true,
|
||||||
|
last: false,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrevious: false,
|
||||||
|
});
|
||||||
|
const loading = ref(false);
|
||||||
|
const settingModal = ref(false);
|
||||||
|
const selectedSinglePage = ref<SinglePage>();
|
||||||
|
const selectedSinglePageWithContent = ref<SinglePageRequest>();
|
||||||
|
const checkAll = ref(false);
|
||||||
|
|
||||||
|
const handleFetchSinglePages = async () => {
|
||||||
|
try {
|
||||||
|
loading.value = true;
|
||||||
|
const { data } = await apiClient.post.listSinglePages({
|
||||||
|
page: singlePages.value.page,
|
||||||
|
size: singlePages.value.size,
|
||||||
|
});
|
||||||
|
singlePages.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch single pages", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePaginationChange = ({
|
||||||
|
page,
|
||||||
|
size,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
}) => {
|
||||||
|
singlePages.value.page = page;
|
||||||
|
singlePages.value.size = size;
|
||||||
|
handleFetchSinglePages();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenSettingModal = async (singlePage: SinglePage) => {
|
||||||
|
const { data } =
|
||||||
|
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
|
||||||
|
name: singlePage.metadata.name,
|
||||||
|
});
|
||||||
|
selectedSinglePage.value = data;
|
||||||
|
settingModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSettingModalClose = () => {
|
||||||
|
selectedSinglePage.value = undefined;
|
||||||
|
selectedSinglePageWithContent.value = undefined;
|
||||||
|
handleFetchSinglePages();
|
||||||
|
};
|
||||||
|
|
||||||
|
watchEffect(async () => {
|
||||||
|
if (
|
||||||
|
!selectedSinglePage.value ||
|
||||||
|
!selectedSinglePage.value.spec.headSnapshot
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: content } = await apiClient.content.obtainSnapshotContent({
|
||||||
|
snapshotName: selectedSinglePage.value.spec.headSnapshot,
|
||||||
|
});
|
||||||
|
|
||||||
|
selectedSinglePageWithContent.value = {
|
||||||
|
page: selectedSinglePage.value,
|
||||||
|
content: content,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSelectPrevious = async () => {
|
||||||
|
const { items, hasPrevious } = singlePages.value;
|
||||||
|
const index = items.findIndex(
|
||||||
|
(singlePage) =>
|
||||||
|
singlePage.page.metadata.name === selectedSinglePage.value?.metadata.name
|
||||||
|
);
|
||||||
|
if (index > 0) {
|
||||||
|
const { data } =
|
||||||
|
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
|
||||||
|
name: items[index - 1].page.metadata.name,
|
||||||
|
});
|
||||||
|
selectedSinglePage.value = data;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index === 0 && hasPrevious) {
|
||||||
|
singlePages.value.page--;
|
||||||
|
await handleFetchSinglePages();
|
||||||
|
selectedSinglePage.value =
|
||||||
|
singlePages.value.items[singlePages.value.items.length - 1].page;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectNext = async () => {
|
||||||
|
const { items, hasNext } = singlePages.value;
|
||||||
|
const index = items.findIndex(
|
||||||
|
(singlePage) =>
|
||||||
|
singlePage.page.metadata.name === selectedSinglePage.value?.metadata.name
|
||||||
|
);
|
||||||
|
if (index < items.length - 1) {
|
||||||
|
const { data } =
|
||||||
|
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
|
||||||
|
name: items[index + 1].page.metadata.name,
|
||||||
|
});
|
||||||
|
selectedSinglePage.value = data;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index === items.length - 1 && hasNext) {
|
||||||
|
singlePages.value.page++;
|
||||||
|
await handleFetchSinglePages();
|
||||||
|
selectedSinglePage.value = singlePages.value.items[0].page;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (singlePage: SinglePage) => {
|
||||||
|
dialog.warning({
|
||||||
|
title: "是否确认删除该自定义页面?",
|
||||||
|
confirmType: "danger",
|
||||||
|
onConfirm: async () => {
|
||||||
|
const singlePageToUpdate = cloneDeep(singlePage);
|
||||||
|
singlePageToUpdate.spec.deleted = true;
|
||||||
|
await apiClient.extension.singlePage.updatecontentHaloRunV1alpha1SinglePage(
|
||||||
|
{
|
||||||
|
name: singlePage.metadata.name,
|
||||||
|
singlePage: singlePageToUpdate,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await handleFetchSinglePages();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const finalStatus = (singlePage: SinglePage) => {
|
||||||
|
if (singlePage.status?.phase) {
|
||||||
|
return SinglePagePhase[singlePage.status.phase];
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(handleFetchSinglePages);
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const selectedUser = ref<User>();
|
||||||
|
|
||||||
|
const handleSelectUser = (user?: User) => {
|
||||||
|
selectedUser.value = user;
|
||||||
|
handleFetchSinglePages();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SinglePageSettingModal
|
||||||
|
v-model:visible="settingModal"
|
||||||
|
:single-page="selectedSinglePageWithContent"
|
||||||
|
@close="onSettingModalClose"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<div class="modal-header-action" @click="handleSelectPrevious">
|
||||||
|
<IconArrowLeft />
|
||||||
|
</div>
|
||||||
|
<div class="modal-header-action" @click="handleSelectNext">
|
||||||
|
<IconArrowRight />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</SinglePageSettingModal>
|
||||||
|
<VCard :body-class="['!p-0']" class="rounded-none border-none shadow-none">
|
||||||
|
<template #header>
|
||||||
|
<div class="block w-full bg-gray-50 px-4 py-3">
|
||||||
|
<div
|
||||||
|
class="relative flex flex-col items-start sm:flex-row sm:items-center"
|
||||||
|
>
|
||||||
|
<div class="mr-4 hidden items-center sm:flex">
|
||||||
|
<input
|
||||||
|
v-model="checkAll"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full flex-1 sm:w-auto">
|
||||||
|
<div v-if="!checkAll" class="flex items-center gap-2">
|
||||||
|
<FormKit placeholder="输入关键词搜索" type="text"></FormKit>
|
||||||
|
<div
|
||||||
|
v-if="selectedUser"
|
||||||
|
class="group flex cursor-pointer items-center justify-center gap-1 rounded-full bg-gray-200 px-2 py-1 hover:bg-gray-300"
|
||||||
|
>
|
||||||
|
<span class="text-xs text-gray-600 group-hover:text-gray-900">
|
||||||
|
作者:{{ selectedUser?.spec.displayName }}
|
||||||
|
</span>
|
||||||
|
<IconCloseCircle
|
||||||
|
class="h-4 w-4 text-gray-600"
|
||||||
|
@click="handleSelectUser(undefined)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VSpace v-else>
|
||||||
|
<VButton type="default">设置</VButton>
|
||||||
|
<VButton type="danger">删除</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex sm:mt-0">
|
||||||
|
<VSpace spacing="lg">
|
||||||
|
<UserDropdownSelector
|
||||||
|
v-model:selected="selectedUser"
|
||||||
|
@select="handleSelectUser"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</UserDropdownSelector>
|
||||||
|
<FloatingDropdown>
|
||||||
|
<div
|
||||||
|
class="flex cursor-pointer select-none items-center text-sm text-gray-700 hover:text-black"
|
||||||
|
>
|
||||||
|
<span class="mr-0.5">排序</span>
|
||||||
|
<span>
|
||||||
|
<IconArrowDown />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<template #popper>
|
||||||
|
<div class="w-72 p-4">
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li
|
||||||
|
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span class="truncate">较近发布</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span class="truncate">较晚发布</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span class="truncate">浏览量最多</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span class="truncate">浏览量最少</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span class="truncate">评论量最多</span>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="flex cursor-pointer items-center rounded px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||||
|
>
|
||||||
|
<span class="truncate">评论量最少</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FloatingDropdown>
|
||||||
|
</VSpace>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<VEmpty
|
||||||
|
v-if="!singlePages.items.length && !loading"
|
||||||
|
message="你可以尝试刷新或者新建页面"
|
||||||
|
title="当前没有页面"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<VSpace>
|
||||||
|
<VButton @click="handleFetchSinglePages">刷新</VButton>
|
||||||
|
<VButton :route="{ name: 'SinglePageEditor' }" type="primary">
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle class="h-full w-full" />
|
||||||
|
</template>
|
||||||
|
新建页面
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VEmpty>
|
||||||
|
<ul
|
||||||
|
v-else
|
||||||
|
class="box-border h-full w-full divide-y divide-gray-100"
|
||||||
|
role="list"
|
||||||
|
>
|
||||||
|
<li v-for="(singlePage, index) in singlePages.items" :key="index">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'bg-gray-100': checkAll,
|
||||||
|
}"
|
||||||
|
class="relative block cursor-pointer px-4 py-3 transition-all hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-show="checkAll"
|
||||||
|
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
|
||||||
|
></div>
|
||||||
|
<div class="relative flex flex-row items-center">
|
||||||
|
<div class="mr-4 hidden items-center sm:flex">
|
||||||
|
<input
|
||||||
|
v-model="checkAll"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-indigo-600"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex flex-row items-center">
|
||||||
|
<RouterLink
|
||||||
|
:to="{
|
||||||
|
name: 'SinglePageEditor',
|
||||||
|
query: { name: singlePage.page.metadata.name },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mr-0 truncate text-sm font-medium text-gray-900 sm:mr-2"
|
||||||
|
>
|
||||||
|
{{ singlePage.page.spec.title }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
<FloatingTooltip
|
||||||
|
v-if="singlePage.page.status?.inProgress"
|
||||||
|
class="hidden items-center sm:flex"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
:to="{
|
||||||
|
name: 'SinglePageEditor',
|
||||||
|
query: { name: singlePage.page.metadata.name },
|
||||||
|
}"
|
||||||
|
class="flex items-center"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="inline-flex h-1.5 w-1.5 rounded-full bg-orange-600"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="inline-block h-1.5 w-1.5 animate-ping rounded-full bg-orange-600"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</RouterLink>
|
||||||
|
<template #popper> 当前有内容已保存,但还未发布。 </template>
|
||||||
|
</FloatingTooltip>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 flex">
|
||||||
|
<span class="text-xs text-gray-500">
|
||||||
|
{{ singlePage.page.status?.permalink }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<div
|
||||||
|
class="inline-flex flex-col items-end gap-4 sm:flex-row sm:items-center sm:gap-6"
|
||||||
|
>
|
||||||
|
<RouterLink
|
||||||
|
v-for="(
|
||||||
|
contributor, contributorIndex
|
||||||
|
) in singlePage.contributors"
|
||||||
|
:key="contributorIndex"
|
||||||
|
:to="{
|
||||||
|
name: 'UserDetail',
|
||||||
|
params: { name: contributor.name },
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-tooltip="contributor.displayName"
|
||||||
|
:alt="contributor.name"
|
||||||
|
:src="contributor.avatar"
|
||||||
|
:title="contributor.displayName"
|
||||||
|
class="hidden h-6 w-6 rounded-full ring-2 ring-white sm:inline-block"
|
||||||
|
/>
|
||||||
|
</RouterLink>
|
||||||
|
<span class="text-sm text-gray-500">
|
||||||
|
{{ finalStatus(singlePage.page) }}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<IconEye
|
||||||
|
v-if="singlePage.page.spec.visible === 'PUBLIC'"
|
||||||
|
v-tooltip="`公开访问`"
|
||||||
|
class="cursor-pointer text-sm transition-all hover:text-blue-600"
|
||||||
|
/>
|
||||||
|
<IconEyeOff
|
||||||
|
v-if="singlePage.page.spec.visible === 'PRIVATE'"
|
||||||
|
v-tooltip="`私有访问`"
|
||||||
|
class="cursor-pointer text-sm transition-all hover:text-blue-600"
|
||||||
|
/>
|
||||||
|
<IconTeam
|
||||||
|
v-if="singlePage.page.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(singlePage.page.metadata.creationTimestamp)
|
||||||
|
}}
|
||||||
|
</time>
|
||||||
|
<span>
|
||||||
|
<FloatingDropdown>
|
||||||
|
<IconSettings
|
||||||
|
class="cursor-pointer transition-all hover:text-blue-600"
|
||||||
|
/>
|
||||||
|
<template #popper>
|
||||||
|
<div class="w-48 p-2">
|
||||||
|
<VSpace class="w-full" direction="column">
|
||||||
|
<VButton
|
||||||
|
v-close-popper
|
||||||
|
block
|
||||||
|
type="secondary"
|
||||||
|
@click="handleOpenSettingModal(singlePage.page)"
|
||||||
|
>
|
||||||
|
设置
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
v-close-popper
|
||||||
|
block
|
||||||
|
type="danger"
|
||||||
|
@click="handleDelete(singlePage.page)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</FloatingDropdown>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="bg-white sm:flex sm:items-center sm:justify-end">
|
||||||
|
<VPagination
|
||||||
|
:page="singlePages.page"
|
||||||
|
:size="singlePages.size"
|
||||||
|
:total="singlePages.total"
|
||||||
|
@change="handlePaginationChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</VCard>
|
||||||
|
</template>
|
|
@ -0,0 +1,292 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { VButton, VModal, VSpace, VTabItem, VTabs } from "@halo-dev/components";
|
||||||
|
import { computed, ref, watchEffect } from "vue";
|
||||||
|
import type { SinglePageRequest } from "@halo-dev/api-client";
|
||||||
|
import cloneDeep from "lodash.clonedeep";
|
||||||
|
import { apiClient } from "@halo-dev/admin-shared";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
|
const initialFormState: SinglePageRequest = {
|
||||||
|
page: {
|
||||||
|
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: "",
|
||||||
|
},
|
||||||
|
htmlMetas: [],
|
||||||
|
},
|
||||||
|
apiVersion: "content.halo.run/v1alpha1",
|
||||||
|
kind: "SinglePage",
|
||||||
|
metadata: {
|
||||||
|
name: uuid(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
raw: "",
|
||||||
|
content: "",
|
||||||
|
rawType: "HTML",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
visible: boolean;
|
||||||
|
singlePage?: SinglePageRequest;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
visible: false,
|
||||||
|
singlePage: undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "update:visible", visible: boolean): void;
|
||||||
|
(event: "close"): void;
|
||||||
|
(event: "saved", singlePage: SinglePageRequest): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const activeTab = ref("general");
|
||||||
|
const formState = ref<SinglePageRequest>(cloneDeep(initialFormState));
|
||||||
|
const saving = ref(false);
|
||||||
|
const publishing = ref(false);
|
||||||
|
const publishCanceling = ref(false);
|
||||||
|
|
||||||
|
const isUpdateMode = computed(() => {
|
||||||
|
return !!formState.value.page.metadata.creationTimestamp;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onVisibleChange = (visible: boolean) => {
|
||||||
|
emit("update:visible", visible);
|
||||||
|
if (!visible) {
|
||||||
|
emit("close");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
saving.value = true;
|
||||||
|
|
||||||
|
// Set rendered content
|
||||||
|
formState.value.content.content = formState.value.content.raw;
|
||||||
|
|
||||||
|
if (isUpdateMode.value) {
|
||||||
|
const { data } = await apiClient.post.updateDraftSinglePage({
|
||||||
|
name: formState.value.page.metadata.name,
|
||||||
|
singlePageRequest: formState.value,
|
||||||
|
});
|
||||||
|
formState.value.page = data;
|
||||||
|
emit("saved", formState.value);
|
||||||
|
} else {
|
||||||
|
const { data } = await apiClient.post.draftSinglePage({
|
||||||
|
singlePageRequest: formState.value,
|
||||||
|
});
|
||||||
|
formState.value.page = data;
|
||||||
|
emit("saved", formState.value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to save single page", error);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePublish = async () => {
|
||||||
|
try {
|
||||||
|
publishing.value = true;
|
||||||
|
|
||||||
|
// Save single page
|
||||||
|
await handleSave();
|
||||||
|
|
||||||
|
// Get latest version single page
|
||||||
|
const { data: latestData } =
|
||||||
|
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
|
||||||
|
name: formState.value.page.metadata.name,
|
||||||
|
});
|
||||||
|
formState.value.page = latestData;
|
||||||
|
|
||||||
|
// Publish single page
|
||||||
|
const { data } = await apiClient.post.publishSinglePage({
|
||||||
|
name: formState.value.page.metadata.name,
|
||||||
|
});
|
||||||
|
formState.value.page = data;
|
||||||
|
emit("saved", formState.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to publish single page", error);
|
||||||
|
} finally {
|
||||||
|
publishing.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelPublish = async () => {
|
||||||
|
try {
|
||||||
|
publishCanceling.value = true;
|
||||||
|
|
||||||
|
// Update published spec = false
|
||||||
|
const singlePageToUpdate = cloneDeep(formState.value);
|
||||||
|
singlePageToUpdate.page.spec.published = false;
|
||||||
|
await apiClient.post.updateDraftSinglePage({
|
||||||
|
name: singlePageToUpdate.page.metadata.name,
|
||||||
|
singlePageRequest: singlePageToUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get latest version single page
|
||||||
|
const { data: latestData } =
|
||||||
|
await apiClient.extension.singlePage.getcontentHaloRunV1alpha1SinglePage({
|
||||||
|
name: formState.value.page.metadata.name,
|
||||||
|
});
|
||||||
|
formState.value.page = latestData;
|
||||||
|
emit("saved", formState.value);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to cancel publish single page", error);
|
||||||
|
} finally {
|
||||||
|
publishCanceling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (props.singlePage) {
|
||||||
|
formState.value = cloneDeep(props.singlePage);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<VModal
|
||||||
|
:visible="visible"
|
||||||
|
:width="700"
|
||||||
|
title="页面设置"
|
||||||
|
@update:visible="onVisibleChange"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<slot name="actions"></slot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<VTabs v-model:active-id="activeTab" type="outline">
|
||||||
|
<VTabItem id="general" label="常规">
|
||||||
|
<FormKit id="basic" :actions="false" :preserve="true" type="form">
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.title"
|
||||||
|
label="标题"
|
||||||
|
type="text"
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.slug"
|
||||||
|
label="别名"
|
||||||
|
name="slug"
|
||||||
|
type="text"
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.excerpt.autoGenerate"
|
||||||
|
:options="[
|
||||||
|
{ label: '是', value: true },
|
||||||
|
{ label: '否', value: false },
|
||||||
|
]"
|
||||||
|
label="自动生成摘要"
|
||||||
|
type="radio"
|
||||||
|
>
|
||||||
|
</FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-if="!formState.page.spec.excerpt.autoGenerate"
|
||||||
|
v-model="formState.page.spec.excerpt.raw"
|
||||||
|
label="自定义摘要"
|
||||||
|
type="textarea"
|
||||||
|
></FormKit>
|
||||||
|
</FormKit>
|
||||||
|
</VTabItem>
|
||||||
|
<VTabItem id="advanced" label="高级">
|
||||||
|
<FormKit id="advanced" :actions="false" :preserve="true" type="form">
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.allowComment"
|
||||||
|
:options="[
|
||||||
|
{ label: '是', value: true },
|
||||||
|
{ label: '否', value: false },
|
||||||
|
]"
|
||||||
|
label="禁止评论"
|
||||||
|
type="radio"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.pinned"
|
||||||
|
:options="[
|
||||||
|
{ label: '是', value: true },
|
||||||
|
{ label: '否', value: false },
|
||||||
|
]"
|
||||||
|
label="是否置顶"
|
||||||
|
name="pinned"
|
||||||
|
type="radio"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.visible"
|
||||||
|
:options="[
|
||||||
|
{ label: '公开', value: 'PUBLIC' },
|
||||||
|
{ label: '内部成员可访问', value: 'INTERNAL' },
|
||||||
|
{ label: '私有', value: 'PRIVATE' },
|
||||||
|
]"
|
||||||
|
label="可见性"
|
||||||
|
name="visible"
|
||||||
|
type="select"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.publishTime"
|
||||||
|
label="发表时间"
|
||||||
|
type="datetime-local"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.template"
|
||||||
|
label="自定义模板"
|
||||||
|
type="text"
|
||||||
|
></FormKit>
|
||||||
|
<FormKit
|
||||||
|
v-model="formState.page.spec.cover"
|
||||||
|
label="封面图"
|
||||||
|
type="text"
|
||||||
|
></FormKit>
|
||||||
|
</FormKit>
|
||||||
|
</VTabItem>
|
||||||
|
<!--TODO: add SEO/Metas/Inject Code form-->
|
||||||
|
</VTabs>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<VSpace>
|
||||||
|
<VButton :loading="publishing" type="secondary" @click="handlePublish">
|
||||||
|
{{
|
||||||
|
formState.page.status?.phase === "PUBLISHED" ? "重新发布" : "发布"
|
||||||
|
}}
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
:loading="saving"
|
||||||
|
size="sm"
|
||||||
|
type="secondary"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
仅保存
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
v-if="formState.page.status?.phase === 'PUBLISHED'"
|
||||||
|
:loading="publishCanceling"
|
||||||
|
type="danger"
|
||||||
|
size="sm"
|
||||||
|
@click="handleCancelPublish"
|
||||||
|
>
|
||||||
|
取消发布
|
||||||
|
</VButton>
|
||||||
|
<VButton size="sm" type="default" @click="onVisibleChange(false)">
|
||||||
|
关闭
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VModal>
|
||||||
|
</template>
|
|
@ -0,0 +1,99 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, watchEffect } from "vue";
|
||||||
|
import {
|
||||||
|
VCard,
|
||||||
|
IconAddCircle,
|
||||||
|
VPageHeader,
|
||||||
|
VTabbar,
|
||||||
|
IconPages,
|
||||||
|
VButton,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import { BasicLayout } from "@halo-dev/admin-shared";
|
||||||
|
import { useRoute, useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
interface PageTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
route: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabs = ref<PageTab[]>([
|
||||||
|
{
|
||||||
|
id: "functional",
|
||||||
|
label: "功能页面",
|
||||||
|
route: {
|
||||||
|
name: "FunctionalPages",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "single",
|
||||||
|
label: "自定义页面",
|
||||||
|
route: {
|
||||||
|
name: "SinglePages",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const activeTab = ref(tabs.value[0].id);
|
||||||
|
|
||||||
|
const onTabChange = (routeName: string) => {
|
||||||
|
const tab = tabs.value.find((tab) => {
|
||||||
|
return tab.route.name === routeName;
|
||||||
|
});
|
||||||
|
if (tab) {
|
||||||
|
activeTab.value = tab.id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeTab.value = tabs.value[0].id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTabChange = (id: string) => {
|
||||||
|
const tab = tabs.value.find((item) => item.id === id);
|
||||||
|
if (tab) {
|
||||||
|
router.push(tab.route);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
onTabChange(route.name as string);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<VPageHeader title="页面">
|
||||||
|
<template #icon>
|
||||||
|
<IconPages class="mr-2 self-center" />
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<VButton :route="{ name: 'SinglePageEditor' }" type="secondary">
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle class="h-full w-full" />
|
||||||
|
</template>
|
||||||
|
新建
|
||||||
|
</VButton>
|
||||||
|
</template>
|
||||||
|
</VPageHeader>
|
||||||
|
<div class="m-0 md:m-4">
|
||||||
|
<VCard :body-class="['!p-0']">
|
||||||
|
<template #header>
|
||||||
|
<VTabbar
|
||||||
|
v-model:active-id="activeTab"
|
||||||
|
:items="tabs"
|
||||||
|
class="w-full !rounded-none"
|
||||||
|
type="outline"
|
||||||
|
@change="handleTabChange"
|
||||||
|
></VTabbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<RouterView :key="activeTab" />
|
||||||
|
</div>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
|
@ -1,5 +1,8 @@
|
||||||
import { BasicLayout, definePlugin } from "@halo-dev/admin-shared";
|
import { BasicLayout, BlankLayout, definePlugin } from "@halo-dev/admin-shared";
|
||||||
import PageList from "./PageList.vue";
|
import PageLayout from "./layouts/PageLayout.vue";
|
||||||
|
import FunctionalPageList from "./FunctionalPageList.vue";
|
||||||
|
import SinglePageList from "./SinglePageList.vue";
|
||||||
|
import SinglePageEditor from "./SinglePageEditor.vue";
|
||||||
import { IconPages } from "@halo-dev/components";
|
import { IconPages } from "@halo-dev/components";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
|
@ -8,13 +11,50 @@ export default definePlugin({
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: "/pages",
|
path: "/pages",
|
||||||
component: BasicLayout,
|
component: BlankLayout,
|
||||||
name: "BasePages",
|
name: "BasePages",
|
||||||
|
redirect: {
|
||||||
|
name: "FunctionalPages",
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "",
|
path: "functional",
|
||||||
name: "Pages",
|
component: PageLayout,
|
||||||
component: PageList,
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "FunctionalPages",
|
||||||
|
component: FunctionalPageList,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "single",
|
||||||
|
component: BlankLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
component: PageLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "SinglePages",
|
||||||
|
component: SinglePageList,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "editor",
|
||||||
|
component: BasicLayout,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
name: "SinglePageEditor",
|
||||||
|
component: SinglePageEditor,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -18,17 +18,17 @@ import AttachmentSelectorModal from "../attachments/components/AttachmentSelecto
|
||||||
import type { PostRequest } from "@halo-dev/api-client";
|
import type { PostRequest } from "@halo-dev/api-client";
|
||||||
import { computed, onMounted, ref, watch } from "vue";
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
import cloneDeep from "lodash.clonedeep";
|
import cloneDeep from "lodash.clonedeep";
|
||||||
import { apiClient, type AttachmentLike } from "@halo-dev/admin-shared";
|
import { apiClient } from "@halo-dev/admin-shared";
|
||||||
import { useRouteQuery } from "@vueuse/router";
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
import { v4 as uuid } from "uuid";
|
import { v4 as uuid } from "uuid";
|
||||||
import {
|
import {
|
||||||
allExtensions,
|
allExtensions,
|
||||||
RichTextEditor,
|
RichTextEditor,
|
||||||
useEditor,
|
useEditor,
|
||||||
type Content,
|
|
||||||
} from "@halo-dev/richtext-editor";
|
} from "@halo-dev/richtext-editor";
|
||||||
import ExtensionCharacterCount from "@tiptap/extension-character-count";
|
import ExtensionCharacterCount from "@tiptap/extension-character-count";
|
||||||
import { formatDatetime } from "@/utils/date";
|
import { formatDatetime } from "@/utils/date";
|
||||||
|
import { useAttachmentSelect } from "../attachments/composables/use-attachment";
|
||||||
|
|
||||||
const initialFormState: PostRequest = {
|
const initialFormState: PostRequest = {
|
||||||
post: {
|
post: {
|
||||||
|
@ -107,35 +107,7 @@ watch(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const onAttachmentSelect = (attachments: AttachmentLike[]) => {
|
const { onAttachmentSelect } = useAttachmentSelect(editor);
|
||||||
const images: Content[] = attachments.map((attachment) => {
|
|
||||||
const attrs: { src?: string; alt?: string } = {};
|
|
||||||
if (typeof attachment === "string") {
|
|
||||||
attrs.src = attachment;
|
|
||||||
return {
|
|
||||||
type: "image",
|
|
||||||
attrs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if ("url" in attachment) {
|
|
||||||
attrs.src = attachment.url;
|
|
||||||
attrs.alt = attachment.type;
|
|
||||||
}
|
|
||||||
if ("spec" in attachment) {
|
|
||||||
attrs.src = attachment.status?.permalink;
|
|
||||||
attrs.alt = attachment.spec.displayName;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
type: "image",
|
|
||||||
attrs,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
editor.value
|
|
||||||
?.chain()
|
|
||||||
.focus()
|
|
||||||
.insertContent([...images, { type: "paragraph", content: "" }])
|
|
||||||
.run();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleGenerateTableOfContent = () => {
|
const handleGenerateTableOfContent = () => {
|
||||||
if (!editor.value) {
|
if (!editor.value) {
|
||||||
|
|
Loading…
Reference in New Issue