feat: add setup post/singlePage/category custom templates support (#671)

#### What type of PR is this?

/kind feature
/milestone 2.0

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

支持为文章/自定义页面/分类设置自定义模板。适配 https://github.com/halo-dev/halo/pull/2638

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

Fixes https://github.com/halo-dev/halo/issues/2569

#### Screenshots:

<img width="625" alt="image" src="https://user-images.githubusercontent.com/21301288/198823380-991a702d-aae7-4587-b0f8-81fcb018a1f6.png">

#### Special notes for your reviewer:

/cc @halo-dev/sig-halo-console 

测试方式:

1. Halo 需要切换到 https://github.com/halo-dev/halo/pull/2638 PR 的分支。
2. 根据 https://github.com/halo-dev/halo/pull/2638 PR 中的描述修改主题配置 `theme.yaml`,添加所需测试的模板配置。
3. 检查 Console 对应的设置项(分类编辑、文章设置、自定义页面)中的自定义模板选择框是否包含配置的模板。
4. 选择配置的模板后保存。检查主题端对应页面是否一致。

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

```release-note
支持为文章/自定义页面/分类设置自定义模板。
```
pull/678/head
Ryan Wang 2022-11-02 14:40:16 +08:00 committed by GitHub
parent 3d638fde37
commit ff26058fc0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 159 additions and 82 deletions

View File

@ -33,7 +33,7 @@
"@formkit/inputs": "^1.0.0-beta.11",
"@formkit/themes": "^1.0.0-beta.11",
"@formkit/vue": "^1.0.0-beta.11",
"@halo-dev/api-client": "^0.0.39",
"@halo-dev/api-client": "^0.0.40",
"@halo-dev/components": "workspace:*",
"@halo-dev/console-shared": "workspace:*",
"@halo-dev/richtext-editor": "^0.0.0-alpha.8",

View File

@ -13,7 +13,7 @@ importers:
'@formkit/inputs': ^1.0.0-beta.11
'@formkit/themes': ^1.0.0-beta.11
'@formkit/vue': ^1.0.0-beta.11
'@halo-dev/api-client': ^0.0.39
'@halo-dev/api-client': ^0.0.40
'@halo-dev/components': workspace:*
'@halo-dev/console-shared': workspace:*
'@halo-dev/richtext-editor': ^0.0.0-alpha.8
@ -108,7 +108,7 @@ importers:
'@formkit/inputs': 1.0.0-beta.11
'@formkit/themes': 1.0.0-beta.11_tailwindcss@3.2.1
'@formkit/vue': 1.0.0-beta.11_vjnbgdptsk6bkj7ab5a6mk2cwm
'@halo-dev/api-client': 0.0.39
'@halo-dev/api-client': 0.0.40
'@halo-dev/components': link:packages/components
'@halo-dev/console-shared': link:packages/shared
'@halo-dev/richtext-editor': 0.0.0-alpha.8_vue@3.2.41
@ -1953,8 +1953,8 @@ packages:
- windicss
dev: false
/@halo-dev/api-client/0.0.39:
resolution: {integrity: sha512-GuTTJDOj0PPMXo3KTiNYGACRUXqJKnjnApK303eNPWqVodgR3mJVLFTXwa+euAJOkcSG3KkB5OtUFAkZeHRbPA==}
/@halo-dev/api-client/0.0.40:
resolution: {integrity: sha512-SsB+PZkoCwp0cGKGQY+x2oDWiZDwHYH9nytN28b+kY+HnWmNIWgMT64ZnHoHxOnk44m9S9byxwkkvlOamby0AQ==}
dev: false
/@halo-dev/richtext-editor/0.0.0-alpha.8_vue@3.2.41:

View File

@ -12,7 +12,7 @@ import {
IconPages,
IconUserSettings,
} from "@halo-dev/components";
import { computed, markRaw, onMounted, ref, watch, type Component } from "vue";
import { computed, markRaw, ref, watch, type Component } from "vue";
import Fuse from "fuse.js";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
@ -356,6 +356,8 @@ watch(
() => props.visible,
(visible) => {
if (visible) {
handleBuildSearchIndex();
setTimeout(() => {
globalSearchInput.value?.focus();
}, 100);
@ -369,10 +371,6 @@ watch(
}
);
onMounted(() => {
handleBuildSearchIndex();
});
const onVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
};

View File

@ -1,5 +1,5 @@
import type { DirectiveBinding } from "vue";
import { createApp } from "vue";
import type { DirectiveBinding } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import router from "./router";
@ -17,6 +17,7 @@ import type { User } from "@halo-dev/api-client";
import { hasPermission } from "@/utils/permission";
import { useRoleStore } from "@/stores/role";
import type { RouteRecordRaw } from "vue-router";
import { useThemeStore } from "./stores/theme";
const app = createApp(App);
@ -175,9 +176,12 @@ async function loadPluginModules() {
}
}
let currentUser: User | undefined = undefined;
async function loadCurrentUser() {
const { data: user } = await apiClient.user.getCurrentUserDetail();
app.provide<User>("currentUser", user);
currentUser = user;
const { data: currentPermissions } = await apiClient.user.getPermissions({
name: "-",
@ -204,6 +208,11 @@ async function loadCurrentUser() {
);
}
async function loadActivatedTheme() {
const themeStore = useThemeStore();
await themeStore.fetchActivatedTheme();
}
(async function () {
await initApp();
})();
@ -218,13 +227,19 @@ async function initApp() {
try {
loadCoreModules();
await loadCurrentUser();
if (!currentUser) {
return;
}
await loadActivatedTheme();
try {
await loadPluginModules();
} catch (e) {
console.error("Failed to load plugins", e);
}
await loadCurrentUser();
} catch (e) {
console.error(e);
} finally {

View File

@ -5,6 +5,7 @@ import type { SinglePageRequest } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client";
import { v4 as uuid } from "uuid";
import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme";
const initialFormState: SinglePageRequest = {
page: {
@ -160,6 +161,9 @@ watchEffect(() => {
formState.value = cloneDeep(props.singlePage);
}
});
// custom templates
const { templates } = useThemeCustomTemplates("page");
</script>
<template>
@ -265,8 +269,9 @@ watchEffect(() => {
></FormKit>
<FormKit
v-model="formState.page.spec.template"
:options="templates"
label="自定义模板"
type="text"
type="select"
name="template"
></FormKit>
<FormKit

View File

@ -15,6 +15,7 @@ import cloneDeep from "lodash.clonedeep";
import { reset } from "@formkit/core";
import { setFocus } from "@/formkit/utils/focus";
import { v4 as uuid } from "uuid";
import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme";
const props = withDefaults(
defineProps<{
@ -116,6 +117,9 @@ watch(
}
}
);
// custom templates
const { templates } = useThemeCustomTemplates("category");
</script>
<template>
<VModal
@ -147,6 +151,13 @@ watch(
type="text"
validation="required"
></FormKit>
<FormKit
v-model="formState.spec.template"
:options="templates"
label="自定义模板"
type="select"
name="template"
></FormKit>
<FormKit
v-model="formState.spec.cover"
help="需要主题适配以支持"

View File

@ -1,8 +1,13 @@
import { describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import { mount } from "@vue/test-utils";
import CategoryEditingModal from "../CategoryEditingModal.vue";
import { createPinia, setActivePinia } from "pinia";
describe("CategoryEditingModal", function () {
beforeEach(() => {
setActivePinia(createPinia());
});
it("should render", function () {
expect(mount(CategoryEditingModal)).toBeDefined();
});

View File

@ -5,6 +5,7 @@ import type { PostRequest } from "@halo-dev/api-client";
import cloneDeep from "lodash.clonedeep";
import { apiClient } from "@/utils/api-client";
import { v4 as uuid } from "uuid";
import { useThemeCustomTemplates } from "@/modules/interface/themes/composables/use-theme";
const initialFormState: PostRequest = {
post: {
@ -163,6 +164,9 @@ watchEffect(() => {
formState.value = cloneDeep(props.post);
}
});
// custom templates
const { templates } = useThemeCustomTemplates("post");
</script>
<template>
<VModal
@ -277,9 +281,10 @@ watchEffect(() => {
></FormKit>
<FormKit
v-model="formState.post.spec.template"
:options="templates"
label="自定义模板"
name="template"
type="text"
type="select"
></FormKit>
<FormKit
v-model="formState.post.spec.cover"

View File

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

View File

@ -24,6 +24,8 @@ import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { usePermission } from "@/utils/permission";
import { onBeforeRouteLeave } from "vue-router";
import { useThemeStore } from "@/stores/theme";
import { storeToRefs } from "pinia";
const { currentUserHasPermission } = usePermission();
@ -31,12 +33,10 @@ const props = withDefaults(
defineProps<{
visible: boolean;
selectedTheme: Theme | null;
activatedTheme: Theme | null;
}>(),
{
visible: false,
selectedTheme: null,
activatedTheme: null,
}
);
@ -58,6 +58,8 @@ const modalTitle = computed(() => {
return activeTab.value === "installed" ? "已安装的主题" : "未安装的主题";
});
const { activatedTheme } = storeToRefs(useThemeStore());
const handleFetchThemes = async () => {
try {
clearInterval(refreshInterval.value);

View File

@ -1,12 +1,13 @@
import type { ComputedRef, Ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { computed, ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { Dialog } from "@halo-dev/components";
import { useThemeStore } from "@/stores/theme";
import { storeToRefs } from "pinia";
interface useThemeLifeCycleReturn {
loading: Ref<boolean>;
activatedTheme: Ref<Theme | undefined>;
isActivated: ComputedRef<boolean>;
handleActiveTheme: () => void;
}
@ -14,43 +15,16 @@ interface useThemeLifeCycleReturn {
export function useThemeLifeCycle(
theme: Ref<Theme | undefined>
): useThemeLifeCycleReturn {
const activatedTheme = ref<Theme | undefined>();
const loading = ref(false);
const themeStore = useThemeStore();
const { activatedTheme } = storeToRefs(themeStore);
const isActivated = computed(() => {
return activatedTheme.value?.metadata.name === theme.value?.metadata.name;
return activatedTheme?.value?.metadata.name === theme.value?.metadata.name;
});
const handleFetchActivatedTheme = async () => {
try {
loading.value = true;
const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap(
{
name: "system",
}
);
if (!data.data?.theme) {
// Todo: show error
return;
}
const themeConfig = JSON.parse(data.data.theme);
const { data: themeData } =
await apiClient.extension.theme.getthemeHaloRunV1alpha1Theme({
name: themeConfig.active,
});
theme.value = themeData;
activatedTheme.value = themeData;
} catch (e) {
console.error("Failed to fetch active theme", e);
} finally {
loading.value = false;
}
};
const handleActiveTheme = async () => {
Dialog.info({
title: "是否确认启用当前主题",
@ -77,18 +51,48 @@ export function useThemeLifeCycle(
} catch (e) {
console.error("Failed to active theme", e);
} finally {
await handleFetchActivatedTheme();
themeStore.fetchActivatedTheme();
}
},
});
};
onMounted(handleFetchActivatedTheme);
return {
loading,
activatedTheme,
isActivated,
handleActiveTheme,
};
}
export function useThemeCustomTemplates(type: "post" | "page" | "category") {
const themeStore = useThemeStore();
const templates = computed(() => {
const defaultTemplate = [
{
label: "默认模板",
value: "",
},
];
if (!themeStore.activatedTheme) {
return defaultTemplate;
}
const { customTemplates } = themeStore.activatedTheme.spec;
if (!customTemplates?.[type]) {
return defaultTemplate;
}
return [
...defaultTemplate,
...(customTemplates[type]?.map((template) => {
return {
value: template.file,
label: template.name || template.file,
};
}) || []),
];
});
return {
templates,
};
}

View File

@ -1,6 +1,6 @@
<script lang="ts" setup>
// core libs
import { nextTick, type ComputedRef, type Ref } from "vue";
import { nextTick, onMounted, type ComputedRef, type Ref } from "vue";
import { computed, provide, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
@ -28,6 +28,8 @@ import {
import ThemeListModal from "../components/ThemeListModal.vue";
import type { SettingForm, Theme } from "@halo-dev/api-client";
import { usePermission } from "@/utils/permission";
import { useThemeStore } from "@/stores/theme";
import { storeToRefs } from "pinia";
const { currentUserHasPermission } = usePermission();
@ -55,7 +57,7 @@ const selectedTheme = ref<Theme | undefined>();
const themesModal = ref(false);
const activeTab = ref("");
const { loading, isActivated, activatedTheme, handleActiveTheme } =
const { loading, isActivated, handleActiveTheme } =
useThemeLifeCycle(selectedTheme);
const settingName = computed(() => selectedTheme.value?.spec.settingName);
@ -143,11 +145,18 @@ const handleTriggerTabChange = () => {
watch([() => route.name, () => route.params], async () => {
handleTriggerTabChange();
});
onMounted(() => {
const themeStore = useThemeStore();
const { activatedTheme } = storeToRefs(themeStore);
selectedTheme.value = activatedTheme?.value;
});
</script>
<template>
<BasicLayout>
<ThemeListModal
v-model:activated-theme="activatedTheme"
v-model:selected-theme="selectedTheme"
v-model:visible="themesModal"
/>

View File

@ -1,16 +0,0 @@
import { defineStore } from "pinia";
export const useCounterStore = defineStore({
id: "counter",
state: () => ({
counter: 0,
}),
getters: {
doubleCount: (state) => state.counter * 2,
},
actions: {
increment() {
this.counter++;
},
},
});

39
src/stores/theme.ts Normal file
View File

@ -0,0 +1,39 @@
import { apiClient } from "@/utils/api-client";
import type { Theme } from "@halo-dev/api-client";
import { defineStore } from "pinia";
interface ThemeStoreState {
activatedTheme?: Theme;
}
export const useThemeStore = defineStore("theme", {
state: (): ThemeStoreState => ({
activatedTheme: undefined,
}),
actions: {
async fetchActivatedTheme() {
try {
const { data } =
await apiClient.extension.configMap.getv1alpha1ConfigMap({
name: "system",
});
if (!data.data?.theme) {
// Todo: show error
return;
}
const themeConfig = JSON.parse(data.data.theme);
const { data: themeData } =
await apiClient.extension.theme.getthemeHaloRunV1alpha1Theme({
name: themeConfig.active,
});
this.activatedTheme = themeData;
} catch (e) {
console.error("Failed to fetch active theme", e);
}
},
},
});