feat: add import and export theme configuration (#6071)

#### What type of PR is this?

/kind feature
/area ui
/milestone 2.17.x

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

为主题增加导入及导出配置的功能。

<img width="1666" alt="image" src="https://github.com/halo-dev/halo/assets/31335418/3574b33e-0293-427c-9036-5d82948aa7f2">

#### How to test it?

测试主题导入及导出功能是否正常可用。
测试当前主题下的不同版本导入导出后数据是否正常。
测试在当前主题下使用其他主题导出的配置是否会报错。

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

Fixes #1073 

#### Does this PR introduce a user-facing change?
```release-note
为单个主题配置增加导入与导出的功能。
```
pull/6143/head
Takagi 2024-06-19 18:55:00 +08:00 committed by GitHub
parent 0f87ed8370
commit b445b505be
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 213 additions and 15 deletions

View File

@ -1,11 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
// types import { apiClient } from "@/utils/api-client";
import type { Ref } from "vue"; import type { Theme } from "@halo-dev/api-client";
// core libs
import { inject, ref } from "vue";
import { useThemeLifeCycle } from "./composables/use-theme";
// components
import { import {
Dialog, Dialog,
IconMore, IconMore,
@ -19,10 +14,10 @@ import {
VStatusDot, VStatusDot,
VTag, VTag,
} from "@halo-dev/components"; } from "@halo-dev/components";
import type { Theme } from "@halo-dev/api-client"; import type { Ref } from "vue";
import { inject, ref } from "vue";
import { apiClient } from "@/utils/api-client";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { useThemeConfigFile, useThemeLifeCycle } from "./composables/use-theme";
const { t } = useI18n(); const { t } = useI18n();
@ -78,6 +73,9 @@ const handleReloadTheme = async () => {
}, },
}); });
}; };
const { handleExportThemeConfiguration, openSelectImportFileDialog } =
useThemeConfigFile(selectedTheme);
</script> </script>
<template> <template>
@ -126,6 +124,12 @@ const handleReloadTheme = async () => {
<VDropdownItem @click="themesModal = true"> <VDropdownItem @click="themesModal = true">
{{ $t("core.common.buttons.upgrade") }} {{ $t("core.common.buttons.upgrade") }}
</VDropdownItem> </VDropdownItem>
<VDropdownItem @click="handleExportThemeConfiguration">
{{ $t("core.theme.operations.export_configuration.button") }}
</VDropdownItem>
<VDropdownItem @click="openSelectImportFileDialog()">
{{ $t("core.theme.operations.import_configuration.button") }}
</VDropdownItem>
<VDropdownDivider /> <VDropdownDivider />
<VDropdownItem type="danger" @click="handleReloadTheme"> <VDropdownItem type="danger" @click="handleReloadTheme">
{{ $t("core.theme.operations.reload.button") }} {{ $t("core.theme.operations.reload.button") }}

View File

@ -1,10 +1,11 @@
import { apiClient } from "@/utils/api-client";
import { useThemeStore } from "@console/stores/theme";
import type { Theme } from "@halo-dev/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import { useFileDialog } from "@vueuse/core";
import { storeToRefs } from "pinia";
import type { ComputedRef, Ref } from "vue"; import type { ComputedRef, Ref } from "vue";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import type { Theme } from "@halo-dev/api-client";
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import { useThemeStore } from "@console/stores/theme";
import { storeToRefs } from "pinia";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
interface useThemeLifeCycleReturn { interface useThemeLifeCycleReturn {
@ -138,3 +139,166 @@ export function useThemeCustomTemplates(type: "post" | "page" | "category") {
templates, templates,
}; };
} }
interface ExportData {
themeName: string;
version: string;
settingName: string;
configMapName: string;
configs: { [key: string]: string };
}
export function useThemeConfigFile(theme: Ref<Theme | undefined>) {
const { t } = useI18n();
const handleExportThemeConfiguration = async () => {
if (!theme.value) {
console.error("No selected or activated theme");
return;
}
const { data } = await apiClient.theme.fetchThemeConfig({
name: theme?.value?.metadata.name as string,
});
if (!data) {
console.error("Failed to fetch theme config");
return;
}
const themeName = theme.value.metadata.name;
const exportData = {
themeName: themeName,
version: theme.value.spec.version,
settingName: theme.value.spec.settingName,
configMapName: theme.value.spec.configMapName,
configs: data.data,
} as ExportData;
const exportStr = JSON.stringify(exportData, null, 2);
const blob = new Blob([exportStr], { type: "application/json" });
const temporaryExportUrl = URL.createObjectURL(blob);
const temporaryLinkTag = document.createElement("a");
temporaryLinkTag.href = temporaryExportUrl;
temporaryLinkTag.download = `export-${themeName}-config-${Date.now().toString()}.json`;
document.body.appendChild(temporaryLinkTag);
temporaryLinkTag.click();
document.body.removeChild(temporaryLinkTag);
URL.revokeObjectURL(temporaryExportUrl);
};
const {
open: openSelectImportFileDialog,
onChange: handleImportThemeConfiguration,
} = useFileDialog({
accept: "application/json",
multiple: false,
directory: false,
reset: true,
});
handleImportThemeConfiguration(async (files) => {
if (files === null || files.length === 0) {
return;
}
const configText = await files[0].text();
const configJson = JSON.parse(configText || "{}");
if (!configJson.configs) {
return;
}
if (!configJson.themeName || !configJson.version) {
Toast.error(
t("core.theme.operations.import_configuration.invalid_format")
);
return;
}
if (!theme.value) {
console.error("No selected or activated theme");
return;
}
if (configJson.themeName !== theme.value.metadata.name) {
Toast.error(
t("core.theme.operations.import_configuration.mismatched_theme")
);
return;
}
if (configJson.version !== theme.value.spec.version) {
Dialog.warning({
title: t(
"core.theme.operations.import_configuration.version_mismatch.title"
),
description: t(
"core.theme.operations.import_configuration.version_mismatch.description"
),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
onConfirm: () => {
handleSaveConfigMap(configJson.configs);
},
onCancel() {
return;
},
});
return;
}
handleSaveConfigMap(configJson.configs);
});
const handleSaveConfigMap = async (importData: Record<string, string>) => {
if (!theme.value) {
return;
}
const { data } = await apiClient.theme.fetchThemeConfig({
name: theme.value.metadata.name as string,
});
if (!data || !data.data) {
return;
}
const combinedConfigData = combinedConfigMap(data.data, importData);
await apiClient.theme.updateThemeConfig({
name: theme.value.metadata.name,
configMap: {
...data,
data: combinedConfigData,
},
});
Toast.success(t("core.common.toast.save_success"));
};
/**
* combined benchmark configuration and import configuration
*
* benchmark: { a: "{\"a\": 1}", b: "{\"b\": 2}" }
* expand: { a: "{\"c\": 3}", b: "{\"d\": 4}" }
* => { a: "{\"a\": 1, \"c\": 3}", b: "{\"b\": 2, \"d\": 4}" }
*
* benchmark: { a: "{\"a\": 1}", b: "{\"b\": 2}", d: "{\"d\": 4}"
* expand: { a: "{\"a\": 2}", b: "{\"b\": 3, \"d\": 4}", c: "{\"c\": 5}" }
* => { a: "{\"a\": 2}", b: "{\"b\": 3, \"d\": 4}", d: "{\"d\": 4}" }
*
*/
const combinedConfigMap = (
benchmarkConfigMap: { [key: string]: string },
importConfigMap: { [key: string]: string }
): { [key: string]: string } => {
const result = benchmarkConfigMap;
for (const key in result) {
const benchmarkValueJson = JSON.parse(benchmarkConfigMap[key] || "{}");
const expandValueJson = JSON.parse(importConfigMap[key] || "{}");
const combinedValue = {
...benchmarkValueJson,
...expandValueJson,
};
result[key] = JSON.stringify(combinedValue);
}
return result;
};
return {
handleExportThemeConfiguration,
openSelectImportFileDialog,
};
}

View File

@ -721,6 +721,18 @@ core:
description: >- description: >-
This feature allows you to refresh the cache to view the latest web This feature allows you to refresh the cache to view the latest web
results after modifying template files at runtime. results after modifying template files at runtime.
export_configuration:
button: Export theme configuration
import_configuration:
button: Import theme configuration
version_mismatch:
title: Version mismatch
description: >-
The imported configuration file version does not match the
current theme version, which may lead to compatibility issues.
Do you want to continue importing?
invalid_format: Invalid theme configuration file
mismatched_theme: Configuration file does not match the selected theme
list_modal: list_modal:
tabs: tabs:
installed: Installed installed: Installed

View File

@ -691,6 +691,15 @@ core:
button: 清理模板缓存 button: 清理模板缓存
title: 清除模板缓存 title: 清除模板缓存
description: 此功能适用于在运行时修改模板文件后,刷新缓存以查看最新网页结果。 description: 此功能适用于在运行时修改模板文件后,刷新缓存以查看最新网页结果。
export_configuration:
button: 导出主题配置
import_configuration:
button: 导入主题配置
version_mismatch:
title: 版本不匹配
description: 导入的配置文件版本与当前主题版本不匹配,这可能会导致兼容性问题。是否继续导入?
invalid_format: 无效的主题配置文件
mismatched_theme: 配置文件与所选主题不匹配
list_modal: list_modal:
tabs: tabs:
installed: 已安装 installed: 已安装

View File

@ -671,6 +671,15 @@ core:
button: 清除模板快取 button: 清除模板快取
title: 清除模板快取 title: 清除模板快取
description: 此功能適用於在運行時修改模板檔案後,刷新快取以查看最新網頁結果。 description: 此功能適用於在運行時修改模板檔案後,刷新快取以查看最新網頁結果。
export_configuration:
button: 匯出主題配置
import_configuration:
button: 匯入主題配置
version_mismatch:
title: 版本不匹配
description: 匯入的配置檔版本與目前主題版本不匹配,這可能會導致相容性問題。是否繼續匯入?
invalid_format: 無效的主題配置文件
mismatched_theme: 配置文件與所選主題不匹配
list_modal: list_modal:
tabs: tabs:
installed: 已安裝 installed: 已安裝