feat: add support for theme management (halo-dev/console#592)

Signed-off-by: Ryan Wang <i@ryanc.cc>

<!--  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/2280

/hold until https://github.com/halo-dev/halo/pull/2280 merge

#### 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)`.
-->
None

#### Screenshots:

|<img width="1564" alt="image" src="https://user-images.githubusercontent.com/21301288/182314023-546d5fe1-03bb-4c7e-af36-6730ab051931.png">|<img width="1564" alt="image" src="https://user-images.githubusercontent.com/21301288/182314339-6c13b60a-cddc-449d-a06f-faaa53f76335.png">|
| ---- | ---- |
|<img width="1564" alt="image" src="https://user-images.githubusercontent.com/21301288/182314487-4c551208-938c-4582-bce6-7f589f278e8f.png">|      |



<!--
如果此 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:

None

#### 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/3445/head
Ryan Wang 2022-08-03 11:30:13 +08:00 committed by GitHub
parent 75cb96a24e
commit a580d89e39
7 changed files with 278 additions and 186 deletions

View File

@ -34,7 +34,7 @@
"@formkit/vue": "1.0.0-beta.10",
"@halo-dev/admin-api": "^1.1.0",
"@halo-dev/admin-shared": "workspace:*",
"@halo-dev/api-client": "^0.0.6",
"@halo-dev/api-client": "^0.0.7",
"@halo-dev/components": "workspace:*",
"@vueuse/components": "^8.9.4",
"@vueuse/core": "^8.9.4",

View File

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

View File

@ -9,6 +9,7 @@ import {
V1alpha1RoleBindingApi,
V1alpha1SettingApi,
V1alpha1UserApi,
ThemeHaloRunV1alpha1ThemeApi,
} from "@halo-dev/api-client";
import type { AxiosInstance } from "axios";
import axios from "axios";
@ -58,6 +59,7 @@ function setupApiClient(axios: AxiosInstance) {
),
plugin: new PluginHaloRunV1alpha1PluginApi(undefined, apiUrl, axios),
user: new V1alpha1UserApi(undefined, apiUrl, axios),
theme: new ThemeHaloRunV1alpha1ThemeApi(undefined, apiUrl, axios),
// TODO optional
// link: new CoreHaloRunV1alpha1LinkApi(undefined, apiUrl, axios),

View File

@ -14,7 +14,7 @@ importers:
'@formkit/vue': 1.0.0-beta.10
'@halo-dev/admin-api': ^1.1.0
'@halo-dev/admin-shared': workspace:*
'@halo-dev/api-client': ^0.0.6
'@halo-dev/api-client': ^0.0.7
'@halo-dev/components': workspace:*
'@rushstack/eslint-patch': ^1.1.4
'@tailwindcss/aspect-ratio': ^0.4.0
@ -84,7 +84,7 @@ importers:
'@formkit/vue': 1.0.0-beta.10_h3koegrx2cxvlj7ceqfsnru344
'@halo-dev/admin-api': 1.1.0
'@halo-dev/admin-shared': link:packages/shared
'@halo-dev/api-client': 0.0.6
'@halo-dev/api-client': 0.0.7
'@halo-dev/components': link:packages/components
'@vueuse/components': 8.9.4_vue@3.2.37
'@vueuse/core': 8.9.4_vue@3.2.37
@ -174,12 +174,12 @@ importers:
packages/shared:
specifiers:
'@halo-dev/api-client': ^0.0.6
'@halo-dev/api-client': ^0.0.7
'@halo-dev/components': workspace:*
axios: ^0.27.2
vite-plugin-dts: ^1.4.0
dependencies:
'@halo-dev/api-client': 0.0.6
'@halo-dev/api-client': 0.0.7
'@halo-dev/components': link:../components
axios: 0.27.2
devDependencies:
@ -1873,8 +1873,8 @@ packages:
- debug
dev: false
/@halo-dev/api-client/0.0.6:
resolution: {integrity: sha512-JDWGlTq+pHVrZsmqDCXAowZQFcNL3M6+guL37yrKbhylUgIutYpCMU/d2Qogc+c43FzFBq84igP84T5XtuknjQ==}
/@halo-dev/api-client/0.0.7:
resolution: {integrity: sha512-UP36IYuSuDh/rRX+fwZAiPE+h/bhBLgh4XVfqVFaynHc3fLQhaUchqzqvep0HYAPHW4E7EDfQaNiFLXHUrH1bA==}
dev: false
/@halo-dev/logger/1.1.0:

View File

@ -1,184 +1,39 @@
<script lang="ts" setup>
import {
IconArrowRight,
IconExchange,
IconEye,
IconGitHub,
IconPalette,
VAlert,
VButton,
VCard,
VModal,
VPageHeader,
VSpace,
VTabbar,
VTag,
} from "@halo-dev/components";
import { onMounted, ref } from "vue";
import ThemeListModal from "./components/ThemeListModal.vue";
import { ref } from "vue";
import { RouterLink } from "vue-router";
import type { Metadata } from "@halo-dev/api-client";
import type { Theme } from "@halo-dev/api-client";
import { useThemeLifeCycle } from "./composables/use-theme";
interface ThemeAuthor {
name: string;
website: string;
}
interface ThemeSpec {
displayName: string;
author: ThemeAuthor;
description?: string;
logo?: string;
website?: string;
repo?: string;
version: string;
require: string;
}
interface Theme {
metadata: Metadata;
spec: ThemeSpec;
kind: string;
apiVersion: string;
}
const themes = ref<Theme[]>([]);
const currentTheme = ref<Theme>({} as Theme);
const selectedTheme = ref<Theme>({} as Theme);
const themesModal = ref(false);
const themeActiveId = ref("detail");
const tabActiveId = ref("detail");
const themeListRef = ref();
const handleChangeTheme = (theme: Theme) => {
currentTheme.value = theme;
themesModal.value = false;
};
const handleFetchThemes = async () => {
themes.value = await new Promise((resolve) => {
resolve([
{
apiVersion: "theme.halo.run/v1alpha1",
kind: "Theme",
metadata: {
name: "default",
},
spec: {
displayName: "Default",
author: {
name: "halo-dev",
website: "https://halo.run",
},
description: "Halo 2.0 的默认主题",
logo: "https://halo.run/logo",
website: "https://github.com/halo-sigs/theme-default.git",
repo: "https://github.com/halo-sigs/theme-default.git",
version: "1.0.0",
require: "2.0.0",
},
},
{
apiVersion: "theme.halo.run/v1alpha1",
kind: "Theme",
metadata: {
name: "gtvg",
},
spec: {
displayName: "GTVG",
author: {
name: "guqing",
website: "https://guqing.xyz",
},
description: "测试主题",
logo: "https://guqing.xyz/logo.png",
website: "https://github.com/guqing/halo-theme-test.git",
repo: "https://github.com/guqing/halo-theme-test.git",
version: "1.0.0",
require: "2.0.0",
},
},
]);
});
currentTheme.value = themes.value[0];
};
onMounted(handleFetchThemes);
const { isActivated, activatedTheme, handleActiveTheme } =
useThemeLifeCycle(selectedTheme);
</script>
<template>
<VModal
<ThemeListModal
ref="themeListRef"
v-model:activated-theme="activatedTheme"
v-model:selected-theme="selectedTheme"
v-model:visible="themesModal"
:body-class="['!p-0']"
:width="888"
title="已安装的主题"
>
<ul class="flex flex-col divide-y divide-gray-100" role="list">
<li
v-for="(theme, index) in themes"
:key="index"
:class="{
'bg-gray-50': theme.metadata.name === currentTheme.metadata?.name,
}"
class="relative cursor-pointer py-4 transition-all hover:bg-gray-100"
@click="handleChangeTheme(theme)"
>
<div class="flex items-center">
<div
v-show="theme.metadata.name === currentTheme.metadata?.name"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="w-40 px-4">
<div
class="group aspect-w-4 aspect-h-3 block w-full overflow-hidden rounded border bg-gray-100"
>
<img
:src="theme.spec.logo"
alt=""
class="pointer-events-none object-cover group-hover:opacity-75"
/>
</div>
</div>
<div class="flex-1">
<VSpace align="start" direction="column" spacing="xs">
<div class="flex items-center gap-2">
<span class="text-lg font-medium text-gray-900">
{{ theme.spec.displayName }}
</span>
<VTag>当前启用</VTag>
</div>
<div>
<span class="text-sm text-gray-400">
{{ theme.spec.version }}
</span>
</div>
</VSpace>
</div>
<div class="px-4">
<VSpace spacing="lg">
<div>
<span class="text-sm text-gray-400 hover:text-blue-600">
{{ theme.spec.author.name }}
</span>
</div>
<div v-if="theme.spec.website">
<a
:href="theme.spec.website"
class="text-gray-900 hover:text-blue-600"
target="_blank"
>
<IconGitHub />
</a>
</div>
<div>
<IconArrowRight class="text-gray-900" />
</div>
</VSpace>
</div>
</div>
</li>
</ul>
<template #footer>
<VButton @click="themesModal = false">关闭</VButton>
</template>
</VModal>
<VPageHeader :title="currentTheme.spec?.displayName">
<VPageHeader :title="selectedTheme.spec?.displayName">
<template #icon>
<IconPalette class="mr-2 self-center" />
</template>
@ -190,7 +45,14 @@ onMounted(handleFetchThemes);
</template>
切换主题
</VButton>
<VButton size="sm" type="primary"> 启用</VButton>
<VButton
v-if="!isActivated"
size="sm"
type="primary"
@click="handleActiveTheme"
>
启用
</VButton>
<VButton :route="{ name: 'ThemeVisual' }" type="secondary">
<template #icon>
<IconEye class="h-full w-full" />
@ -204,7 +66,7 @@ onMounted(handleFetchThemes);
<VCard :body-class="['!p-0']">
<template #header>
<VTabbar
v-model:active-id="themeActiveId"
v-model:active-id="tabActiveId"
:items="[
{ id: 'detail', label: '详情' },
{ id: 'settings', label: '基础设置' },
@ -214,29 +76,31 @@ onMounted(handleFetchThemes);
></VTabbar>
</template>
<div v-if="themeActiveId === 'detail'">
<div v-if="tabActiveId === 'detail'">
<div class="px-4 py-4 sm:px-6">
<div class="flex flex-row gap-3">
<div v-if="currentTheme.spec?.logo">
<div v-if="selectedTheme.spec?.logo">
<div
class="h-12 w-12 overflow-hidden rounded border bg-white hover:shadow-sm"
>
<img
:alt="currentTheme.spec?.displayName"
:src="currentTheme.spec?.logo"
:alt="selectedTheme.spec?.displayName"
:src="selectedTheme.spec?.logo"
class="h-full w-full"
/>
</div>
</div>
<div>
<h3 class="text-lg font-medium leading-6 text-gray-900">
{{ currentTheme.spec?.displayName }}
{{ selectedTheme.spec?.displayName }}
</h3>
<p class="mt-1 flex max-w-2xl items-center gap-2">
<span class="text-sm text-gray-500">
{{ currentTheme.spec?.version }}
{{ selectedTheme.spec?.version }}
</span>
<VTag> 当前启用</VTag>
<VTag>
{{ isActivated ? "当前启用" : "未启用" }}
</VTag>
</p>
</div>
</div>
@ -248,7 +112,7 @@ onMounted(handleFetchThemes);
>
<dt class="text-sm font-medium text-gray-900">ID</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ currentTheme.metadata?.name }}
{{ selectedTheme.metadata?.name }}
</dd>
</div>
<div
@ -256,7 +120,7 @@ onMounted(handleFetchThemes);
>
<dt class="text-sm font-medium text-gray-900">作者</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ currentTheme.spec?.author?.name }}
{{ selectedTheme.spec?.author?.name }}
</dd>
</div>
<div
@ -264,8 +128,8 @@ onMounted(handleFetchThemes);
>
<dt class="text-sm font-medium text-gray-900">网站</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
<a :href="currentTheme.spec?.website" target="_blank">
{{ currentTheme.spec?.website }}
<a :href="selectedTheme.spec?.website" target="_blank">
{{ selectedTheme.spec?.website }}
</a>
</dd>
</div>
@ -274,8 +138,8 @@ onMounted(handleFetchThemes);
>
<dt class="text-sm font-medium text-gray-900">源码仓库</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
<a :href="currentTheme.spec?.website" target="_blank">
{{ currentTheme.spec?.website }}
<a :href="selectedTheme.spec?.repo" target="_blank">
{{ selectedTheme.spec?.repo }}
</a>
</dd>
</div>
@ -284,7 +148,7 @@ onMounted(handleFetchThemes);
>
<dt class="text-sm font-medium text-gray-900">当前版本</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ currentTheme.spec?.version }}
{{ selectedTheme.spec?.version }}
</dd>
</div>
<div
@ -292,7 +156,7 @@ onMounted(handleFetchThemes);
>
<dt class="text-sm font-medium text-gray-900">Halo 版本要求</dt>
<dd class="mt-1 text-sm text-gray-900 sm:col-span-3 sm:mt-0">
{{ currentTheme.spec?.require }}
{{ selectedTheme.spec?.require }}
</dd>
</div>
<div
@ -354,7 +218,7 @@ onMounted(handleFetchThemes);
</div>
</div>
<div v-if="themeActiveId === 'settings'" class="p-4 sm:px-6">
<div v-if="tabActiveId === 'settings'" class="p-4 sm:px-6">
<div class="w-1/3">
<FormKit id="theme-setting-form" :actions="false" type="form">
<FormKit label="侧边栏宽度" type="text"></FormKit>

View File

@ -0,0 +1,143 @@
<script lang="ts" setup>
import {
IconArrowRight,
IconGitHub,
VButton,
VModal,
VSpace,
VTag,
} from "@halo-dev/components";
import type { PropType } from "vue";
import { onMounted, ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
defineProps({
visible: {
type: Boolean,
default: false,
},
selectedTheme: {
type: Object as PropType<Theme | null>,
default: null,
},
activatedTheme: {
type: Object as PropType<Theme | null>,
default: null,
},
});
const emit = defineEmits(["update:visible", "close", "update:selectedTheme"]);
const themes = ref<Theme[]>([]);
const handleFetchThemes = async () => {
try {
const { data } =
await apiClient.extension.theme.listthemeHaloRunV1alpha1Theme();
themes.value = data.items;
} catch (e) {
console.error("Failed to fetch themes", e);
}
};
const handleVisibleChange = (visible: boolean) => {
emit("update:visible", visible);
if (!visible) {
emit("close");
}
};
const handleSelectTheme = (theme: Theme) => {
emit("update:selectedTheme", theme);
handleVisibleChange(false);
};
onMounted(handleFetchThemes);
defineExpose({
handleFetchThemes,
});
</script>
<template>
<VModal
:body-class="['!p-0']"
:visible="visible"
:width="888"
title="已安装的主题"
@update:visible="handleVisibleChange"
>
<ul class="flex flex-col divide-y divide-gray-100" role="list">
<li
v-for="(theme, index) in themes"
:key="index"
:class="{
'bg-gray-50': theme.metadata.name === selectedTheme?.metadata?.name,
}"
class="relative cursor-pointer py-4 transition-all hover:bg-gray-100"
@click="handleSelectTheme(theme)"
>
<div class="flex items-center">
<div
v-show="theme.metadata.name === selectedTheme?.metadata?.name"
class="absolute inset-y-0 left-0 w-0.5 bg-primary"
></div>
<div class="w-40 px-4">
<div
class="group aspect-w-4 aspect-h-3 block w-full overflow-hidden rounded border bg-gray-100"
>
<img
:src="theme.spec.logo"
alt=""
class="pointer-events-none object-cover group-hover:opacity-75"
/>
</div>
</div>
<div class="flex-1">
<VSpace align="start" direction="column" spacing="xs">
<div class="flex items-center gap-2">
<span class="text-lg font-medium text-gray-900">
{{ theme.spec.displayName }}
</span>
<VTag
v-if="theme.metadata.name === activatedTheme?.metadata?.name"
>
当前启用
</VTag>
</div>
<div>
<span class="text-sm text-gray-400">
{{ theme.spec.version }}
</span>
</div>
</VSpace>
</div>
<div class="px-4">
<VSpace spacing="lg">
<div>
<span class="text-sm text-gray-400 hover:text-blue-600">
{{ theme.spec.author.name }}
</span>
</div>
<div v-if="theme.spec.repo">
<a
:href="theme.spec.repo"
class="text-gray-900 hover:text-blue-600"
target="_blank"
>
<IconGitHub />
</a>
</div>
<div>
<IconArrowRight class="text-gray-900" />
</div>
</VSpace>
</div>
</div>
</li>
</ul>
<template #footer>
<VButton @click="handleVisibleChange(false)"></VButton>
</template>
</VModal>
</template>

View File

@ -0,0 +1,83 @@
import type { ComputedRef, Ref } from "vue";
import { computed, onMounted, ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@halo-dev/admin-shared";
import { useDialog } from "@halo-dev/components";
interface useThemeLifeCycleReturn {
activatedTheme: Ref<Theme>;
isActivated: ComputedRef<boolean>;
handleActiveTheme: () => void;
}
export function useThemeLifeCycle(theme: Ref<Theme>): useThemeLifeCycleReturn {
const activatedTheme = ref<Theme>({} as Theme);
const isActivated = computed(() => {
return activatedTheme.value?.metadata?.name === theme.value?.metadata?.name;
});
const dialog = useDialog();
const handleFetchActivatedTheme = async () => {
try {
const { data } = await apiClient.extension.configMap.getv1alpha1ConfigMap(
"system"
);
if (!data.data?.theme) {
// Todo: show error
return;
}
const themeConfig = JSON.parse(data.data.theme);
const { data: themeData } =
await apiClient.extension.theme.getthemeHaloRunV1alpha1Theme(
themeConfig.active
);
theme.value = themeData;
activatedTheme.value = themeData;
} catch (e) {
console.error("Failed to fetch active theme", e);
}
};
const handleActiveTheme = async () => {
dialog.info({
title: "是否确认启用当前主题",
description: theme.value.spec.displayName,
onConfirm: async () => {
try {
const { data: systemConfigMap } =
await apiClient.extension.configMap.getv1alpha1ConfigMap("system");
if (systemConfigMap.data) {
const themeConfigToUpdate = JSON.parse(
systemConfigMap.data?.theme || "{}"
);
themeConfigToUpdate.active = theme.value?.metadata?.name;
systemConfigMap.data["theme"] = JSON.stringify(themeConfigToUpdate);
await apiClient.extension.configMap.updatev1alpha1ConfigMap(
"system",
systemConfigMap
);
}
} catch (e) {
console.error("Failed to active theme", e);
} finally {
await handleFetchActivatedTheme();
}
},
});
};
onMounted(handleFetchActivatedTheme);
return {
activatedTheme,
isActivated,
handleActiveTheme,
};
}