mirror of https://github.com/halo-dev/halo
Refactor theme list modal to support extend (#4505)
#### What type of PR is this? /area console /kind feature /milestone 2.9.x #### What this PR does / why we need it: 重构主题管理的界面,支持扩展选项卡,同时做了一些 UI 的变更。 https://github.com/halo-dev/halo/pull/4505/files#diff-e8824d75ad964eebf685ad33b915b77d3146cd04587ec53328bbb5b5602ff094 #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/4496 #### Special notes for your reviewer: 需要测试主题的本地上传和远程下载的安装的更新。 #### Does this PR introduce a user-facing change? ```release-note 重构 Console 端主题管理界面,支持通过插件扩展选项卡。 ```pull/4521/head
parent
d0f223e4d2
commit
a819296945
|
@ -0,0 +1,54 @@
|
||||||
|
# 主题管理界面选项卡扩展点
|
||||||
|
|
||||||
|
## 原由
|
||||||
|
|
||||||
|
目前在 Halo 的主题管理中原生支持本地上传和远程下载的方式安装主题,此扩展点用于扩展主题管理界面的选项卡,以支持更多的安装方式。
|
||||||
|
|
||||||
|
## 定义方式
|
||||||
|
|
||||||
|
> 此示例为添加一个安装选项卡用于从 GitHub 上下载主题。
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePlugin } from "@halo-dev/console-shared";
|
||||||
|
import { markRaw } from "vue";
|
||||||
|
import GitHubDownloadTab from "./components/GitHubDownloadTab.vue";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
extensionPoints: {
|
||||||
|
"theme:list:tabs:create": () => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "github",
|
||||||
|
label: "GitHub",
|
||||||
|
component: markRaw(GitHubDownload),
|
||||||
|
props: {
|
||||||
|
foo: "bar",
|
||||||
|
},
|
||||||
|
priority: 11,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
扩展点类型:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
"theme:list:tabs:create"?: () =>
|
||||||
|
| ThemeListTab[]
|
||||||
|
| Promise<ThemeListTab[]>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`ThemeListTab`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface ThemeListTab {
|
||||||
|
id: string; // 选项卡的唯一标识
|
||||||
|
label: string; // 选项卡的名称
|
||||||
|
component: Raw<Component>; // 选项卡面板的组件
|
||||||
|
props?: Record<string, unknown>; // 选项卡组件的 props
|
||||||
|
permissions?: string[]; // 权限
|
||||||
|
priority: number; // 优先级
|
||||||
|
}
|
||||||
|
```
|
|
@ -9,3 +9,4 @@ export * from "./states/comment-subject-ref";
|
||||||
export * from "./states/backup";
|
export * from "./states/backup";
|
||||||
export * from "./states/plugin-installation-tabs";
|
export * from "./states/plugin-installation-tabs";
|
||||||
export * from "./states/entity";
|
export * from "./states/entity";
|
||||||
|
export * from "./states/theme-list-tabs";
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
import type { Component, Raw } from "vue";
|
||||||
|
|
||||||
|
export interface ThemeListTab {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
component: Raw<Component>;
|
||||||
|
props?: Record<string, unknown>;
|
||||||
|
permissions?: string[];
|
||||||
|
priority: number;
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
|
||||||
import type { BackupTab } from "@/states/backup";
|
import type { BackupTab } from "@/states/backup";
|
||||||
import type { PluginInstallationTab } from "@/states/plugin-installation-tabs";
|
import type { PluginInstallationTab } from "@/states/plugin-installation-tabs";
|
||||||
import type { EntityDropdownItem } from "@/states/entity";
|
import type { EntityDropdownItem } from "@/states/entity";
|
||||||
|
import type { ThemeListTab } from "@/states/theme-list-tabs";
|
||||||
import type { Backup, ListedPost, Plugin } from "@halo-dev/api-client";
|
import type { Backup, ListedPost, Plugin } from "@halo-dev/api-client";
|
||||||
|
|
||||||
export interface RouteRecordAppend {
|
export interface RouteRecordAppend {
|
||||||
|
@ -50,6 +51,8 @@ export interface ExtensionPoint {
|
||||||
"backup:list-item:operation:create"?: () =>
|
"backup:list-item:operation:create"?: () =>
|
||||||
| EntityDropdownItem<Backup>[]
|
| EntityDropdownItem<Backup>[]
|
||||||
| Promise<EntityDropdownItem<Backup>[]>;
|
| Promise<EntityDropdownItem<Backup>[]>;
|
||||||
|
|
||||||
|
"theme:list:tabs:create"?: () => ThemeListTab[] | Promise<ThemeListTab[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginModule {
|
export interface PluginModule {
|
||||||
|
|
|
@ -609,27 +609,18 @@ core:
|
||||||
remote_download:
|
remote_download:
|
||||||
title: Remote download address detected, do you want to download?
|
title: Remote download address detected, do you want to download?
|
||||||
description: "Please carefully verify whether this address can be trusted: {url}"
|
description: "Please carefully verify whether this address can be trusted: {url}"
|
||||||
upload_modal:
|
|
||||||
titles:
|
|
||||||
install: Install theme
|
|
||||||
upgrade: Upgrade theme ({display_name})
|
|
||||||
operations:
|
|
||||||
existed_during_installation:
|
existed_during_installation:
|
||||||
title: The theme already exists.
|
title: The theme already exists.
|
||||||
description: The currently installed theme already exists, do you want to upgrade?
|
description: The currently installed theme already exists, do you want to upgrade?
|
||||||
tabs:
|
|
||||||
local: Local
|
|
||||||
remote:
|
|
||||||
title: Remote
|
|
||||||
fields:
|
|
||||||
url: Remote URL
|
|
||||||
list_modal:
|
list_modal:
|
||||||
titles:
|
|
||||||
installed_themes: Installed Themes
|
|
||||||
not_installed_themes: Not installed Themes
|
|
||||||
tabs:
|
tabs:
|
||||||
installed: Installed
|
installed: Installed
|
||||||
not_installed: Not installed
|
not_installed: Not installed
|
||||||
|
local_upload: Local install / upgrade
|
||||||
|
remote_download:
|
||||||
|
title: Remote
|
||||||
|
fields:
|
||||||
|
url: Remote URL
|
||||||
empty:
|
empty:
|
||||||
title: There are no installed themes currently.
|
title: There are no installed themes currently.
|
||||||
message: There are currently no installed themes, you can try refreshing or installing a new theme.
|
message: There are currently no installed themes, you can try refreshing or installing a new theme.
|
||||||
|
@ -1220,6 +1211,7 @@ core:
|
||||||
add: Add
|
add: Add
|
||||||
submit: Submit
|
submit: Submit
|
||||||
detail: Detail
|
detail: Detail
|
||||||
|
select: Select
|
||||||
radio:
|
radio:
|
||||||
"yes": Yes
|
"yes": Yes
|
||||||
"no": No
|
"no": No
|
||||||
|
|
|
@ -609,27 +609,18 @@ core:
|
||||||
remote_download:
|
remote_download:
|
||||||
title: 检测到了远程下载地址,是否需要下载?
|
title: 检测到了远程下载地址,是否需要下载?
|
||||||
description: 请仔细鉴别此地址是否可信:{url}
|
description: 请仔细鉴别此地址是否可信:{url}
|
||||||
upload_modal:
|
|
||||||
titles:
|
|
||||||
install: 安装主题
|
|
||||||
upgrade: 升级主题({display_name})
|
|
||||||
operations:
|
|
||||||
existed_during_installation:
|
existed_during_installation:
|
||||||
title: 主题已存在
|
title: 主题已存在
|
||||||
description: 当前安装的主题已存在,是否升级?
|
description: 当前安装的主题已存在,是否升级?
|
||||||
tabs:
|
|
||||||
local: 本地上传
|
|
||||||
remote:
|
|
||||||
title: 远程下载
|
|
||||||
fields:
|
|
||||||
url: 下载地址
|
|
||||||
list_modal:
|
list_modal:
|
||||||
titles:
|
|
||||||
installed_themes: 已安装的主题
|
|
||||||
not_installed_themes: 未安装的主题
|
|
||||||
tabs:
|
tabs:
|
||||||
installed: 已安装
|
installed: 已安装
|
||||||
not_installed: 未安装
|
not_installed: 本地未安装
|
||||||
|
local_upload: 上传安装 / 升级
|
||||||
|
remote_download:
|
||||||
|
label: 远程下载
|
||||||
|
fields:
|
||||||
|
url: 下载地址
|
||||||
empty:
|
empty:
|
||||||
title: 当前没有已安装的主题
|
title: 当前没有已安装的主题
|
||||||
message: 当前没有已安装的主题,你可以尝试刷新或者安装新主题
|
message: 当前没有已安装的主题,你可以尝试刷新或者安装新主题
|
||||||
|
@ -1220,6 +1211,7 @@ core:
|
||||||
add: 添加
|
add: 添加
|
||||||
submit: 提交
|
submit: 提交
|
||||||
detail: 详情
|
detail: 详情
|
||||||
|
select: 选择
|
||||||
radio:
|
radio:
|
||||||
"yes": 是
|
"yes": 是
|
||||||
"no": 否
|
"no": 否
|
||||||
|
|
|
@ -609,27 +609,18 @@ core:
|
||||||
remote_download:
|
remote_download:
|
||||||
title: 偵測到遠端下載地址,是否需要下載?
|
title: 偵測到遠端下載地址,是否需要下載?
|
||||||
description: 請仔細鑑別此地址是否可信:{url}
|
description: 請仔細鑑別此地址是否可信:{url}
|
||||||
upload_modal:
|
|
||||||
titles:
|
|
||||||
install: 安裝主題
|
|
||||||
upgrade: 升級主題({display_name})
|
|
||||||
operations:
|
|
||||||
existed_during_installation:
|
existed_during_installation:
|
||||||
title: 主題已存在
|
title: 主題已存在
|
||||||
description: 當前安裝的主題已存在,是否升級?
|
description: 當前安裝的主題已存在,是否升級?
|
||||||
tabs:
|
|
||||||
local: 本地上傳
|
|
||||||
remote:
|
|
||||||
title: 遠端下載
|
|
||||||
fields:
|
|
||||||
url: 下載地址
|
|
||||||
list_modal:
|
list_modal:
|
||||||
titles:
|
|
||||||
installed_themes: 已安裝的主題
|
|
||||||
not_installed_themes: 未安裝的主題
|
|
||||||
tabs:
|
tabs:
|
||||||
installed: 已安裝
|
installed: 已安裝
|
||||||
not_installed: 未安裝
|
not_installed: 本地未安裝
|
||||||
|
local_upload: 上傳安裝 / 升級
|
||||||
|
remote_download:
|
||||||
|
label: 遠端下載
|
||||||
|
fields:
|
||||||
|
url: 下載地址
|
||||||
empty:
|
empty:
|
||||||
title: 當前沒有已安裝的主題
|
title: 當前沒有已安裝的主題
|
||||||
message: 當前沒有已安裝的主題,你可以嘗試刷新或者安裝新主題
|
message: 當前沒有已安裝的主題,你可以嘗試刷新或者安裝新主題
|
||||||
|
@ -1220,6 +1211,7 @@ core:
|
||||||
add: 添加
|
add: 添加
|
||||||
submit: 提交
|
submit: 提交
|
||||||
detail: 詳情
|
detail: 詳情
|
||||||
|
select: 選擇
|
||||||
radio:
|
radio:
|
||||||
"yes": 是
|
"yes": 是
|
||||||
"no": 否
|
"no": 否
|
||||||
|
|
|
@ -17,7 +17,6 @@ import {
|
||||||
VDescription,
|
VDescription,
|
||||||
VDescriptionItem,
|
VDescriptionItem,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import ThemeUploadModal from "./components/ThemeUploadModal.vue";
|
|
||||||
|
|
||||||
// types
|
// types
|
||||||
import type { Ref } from "vue";
|
import type { Ref } from "vue";
|
||||||
|
@ -29,7 +28,7 @@ import { useI18n } from "vue-i18n";
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
|
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
|
||||||
const upgradeModal = ref(false);
|
const themesModal = inject<Ref<boolean>>("themesModal");
|
||||||
|
|
||||||
const { isActivated, getFailedMessage, handleResetSettingConfig } =
|
const { isActivated, getFailedMessage, handleResetSettingConfig } =
|
||||||
useThemeLifeCycle(selectedTheme);
|
useThemeLifeCycle(selectedTheme);
|
||||||
|
@ -59,12 +58,6 @@ const handleReloadTheme = async () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const onUpgradeModalClose = () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 200);
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -110,7 +103,7 @@ const onUpgradeModalClose = () => {
|
||||||
<IconMore />
|
<IconMore />
|
||||||
</div>
|
</div>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<VDropdownItem @click="upgradeModal = true">
|
<VDropdownItem @click="themesModal = true">
|
||||||
{{ $t("core.common.buttons.upgrade") }}
|
{{ $t("core.common.buttons.upgrade") }}
|
||||||
</VDropdownItem>
|
</VDropdownItem>
|
||||||
<VDropdownDivider />
|
<VDropdownDivider />
|
||||||
|
@ -168,9 +161,4 @@ const onUpgradeModalClose = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
<ThemeUploadModal
|
|
||||||
v-model:visible="upgradeModal"
|
|
||||||
:upgrade-theme="selectedTheme"
|
|
||||||
@close="onUpgradeModalClose"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,284 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
VTag,
|
||||||
|
VStatusDot,
|
||||||
|
Dialog,
|
||||||
|
Toast,
|
||||||
|
VDropdownItem,
|
||||||
|
VDropdown,
|
||||||
|
VDropdownDivider,
|
||||||
|
VButton,
|
||||||
|
VSpace,
|
||||||
|
IconMore,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import type { Theme } from "@halo-dev/api-client";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import { toRefs, ref, inject, type Ref } from "vue";
|
||||||
|
import { useThemeLifeCycle } from "../composables/use-theme";
|
||||||
|
import { usePermission } from "@/utils/permission";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
|
||||||
|
const { currentUserHasPermission } = usePermission();
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
theme: Theme;
|
||||||
|
installed?: boolean;
|
||||||
|
isSelected?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
installed: true,
|
||||||
|
isSelected: false,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: "upgrade"): void;
|
||||||
|
(event: "preview"): void;
|
||||||
|
(event: "select", theme: Theme): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const { theme } = toRefs(props);
|
||||||
|
|
||||||
|
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
|
||||||
|
|
||||||
|
const {
|
||||||
|
isActivated,
|
||||||
|
getFailedMessage,
|
||||||
|
handleActiveTheme,
|
||||||
|
handleResetSettingConfig,
|
||||||
|
} = useThemeLifeCycle(theme);
|
||||||
|
|
||||||
|
const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
|
||||||
|
Dialog.warning({
|
||||||
|
title: `${
|
||||||
|
deleteExtensions
|
||||||
|
? t("core.theme.operations.uninstall_and_delete_config.title")
|
||||||
|
: t("core.theme.operations.uninstall.title")
|
||||||
|
}`,
|
||||||
|
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
||||||
|
confirmText: t("core.common.buttons.confirm"),
|
||||||
|
cancelText: t("core.common.buttons.cancel"),
|
||||||
|
onConfirm: async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.extension.theme.deletethemeHaloRunV1alpha1Theme({
|
||||||
|
name: theme.metadata.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete theme setting and configMap
|
||||||
|
if (deleteExtensions) {
|
||||||
|
const { settingName, configMapName } = theme.spec;
|
||||||
|
|
||||||
|
if (settingName) {
|
||||||
|
await apiClient.extension.setting.deletev1alpha1Setting(
|
||||||
|
{
|
||||||
|
name: settingName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mute: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configMapName) {
|
||||||
|
await apiClient.extension.configMap.deletev1alpha1ConfigMap(
|
||||||
|
{
|
||||||
|
name: configMapName,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mute: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.uninstall_success"));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to uninstall theme", e);
|
||||||
|
} finally {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["installed-themes"] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Creating theme
|
||||||
|
const creating = ref(false);
|
||||||
|
|
||||||
|
const handleCreateTheme = async () => {
|
||||||
|
try {
|
||||||
|
creating.value = true;
|
||||||
|
|
||||||
|
const { data } =
|
||||||
|
await apiClient.extension.theme.createthemeHaloRunV1alpha1Theme({
|
||||||
|
theme: props.theme,
|
||||||
|
});
|
||||||
|
|
||||||
|
// create theme settings
|
||||||
|
apiClient.theme.reload({ name: data.metadata.name });
|
||||||
|
|
||||||
|
activeTabId.value = "installed";
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.install_success"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to create theme", error);
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["installed-themes"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["not-installed-themes"] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group relative flex grid-cols-1 flex-col overflow-hidden rounded bg-white p-0 shadow-sm transition-all duration-500 hover:shadow-md hover:ring-1 sm:grid sm:grid-cols-7 sm:p-2"
|
||||||
|
:class="{ 'ring-1': isSelected }"
|
||||||
|
>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<div class="relative block">
|
||||||
|
<div class="aspect-h-9 aspect-w-16">
|
||||||
|
<div
|
||||||
|
class="transform-gpu rounded-none bg-cover bg-center bg-no-repeat sm:rounded"
|
||||||
|
:style="{
|
||||||
|
backgroundImage: `url(${theme.spec.logo})`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-full w-full items-center justify-center rounded-none backdrop-blur-3xl sm:rounded"
|
||||||
|
>
|
||||||
|
<img class="h-16 w-16 rounded" :src="theme.spec.logo" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="relative col-span-5 grid grid-cols-1 content-between p-2 sm:px-4 sm:py-1"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="inline-flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
class="relative block cursor-pointer text-sm font-medium text-black transition-all hover:text-gray-600 hover:underline sm:text-base"
|
||||||
|
@click="emit('select', theme)"
|
||||||
|
>
|
||||||
|
{{ theme.spec.displayName }}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-500 sm:text-sm">
|
||||||
|
{{ theme.spec.version }}
|
||||||
|
</span>
|
||||||
|
<VTag v-if="isActivated" theme="primary">
|
||||||
|
{{ $t("core.common.status.activated") }}
|
||||||
|
</VTag>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<VStatusDot
|
||||||
|
v-if="getFailedMessage()"
|
||||||
|
v-tooltip="getFailedMessage()"
|
||||||
|
state="warning"
|
||||||
|
animate
|
||||||
|
/>
|
||||||
|
<VStatusDot
|
||||||
|
v-if="theme.metadata.deletionTimestamp"
|
||||||
|
v-tooltip="$t('core.common.status.deleting')"
|
||||||
|
state="warning"
|
||||||
|
animate
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="mt-2 line-clamp-1 text-xs font-normal text-gray-500 sm:text-sm"
|
||||||
|
>
|
||||||
|
{{ theme.spec.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="mt-4 flex w-full flex-1 items-center justify-between gap-2 sm:mt-0"
|
||||||
|
>
|
||||||
|
<div v-if="theme.spec.author" class="inline-flex items-center gap-1.5">
|
||||||
|
<a
|
||||||
|
v-if="theme.spec.author.website"
|
||||||
|
class="text-xs text-gray-700 hover:text-gray-900"
|
||||||
|
:href="theme.spec.author.website"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
{{ theme.spec.author.name }}
|
||||||
|
</a>
|
||||||
|
<span v-else class="text-xs text-gray-700">
|
||||||
|
{{ theme.spec.author.name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<VSpace v-if="installed">
|
||||||
|
<VButton
|
||||||
|
v-if="
|
||||||
|
!isActivated &&
|
||||||
|
currentUserHasPermission(['system:themes:manage'])
|
||||||
|
"
|
||||||
|
size="sm"
|
||||||
|
@click="handleActiveTheme(true)"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.activate") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton size="sm" @click="emit('select', theme)">
|
||||||
|
{{ $t("core.common.buttons.select") }}
|
||||||
|
</VButton>
|
||||||
|
<VDropdown
|
||||||
|
v-if="currentUserHasPermission(['system:themes:manage'])"
|
||||||
|
>
|
||||||
|
<VButton size="sm">
|
||||||
|
<IconMore />
|
||||||
|
</VButton>
|
||||||
|
<template #popper>
|
||||||
|
<VDropdownItem @click="emit('preview')">
|
||||||
|
{{ $t("core.common.buttons.preview") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
<VDropdownDivider />
|
||||||
|
<VDropdown placement="right" :triggers="['click']">
|
||||||
|
<VDropdownItem type="danger">
|
||||||
|
{{ $t("core.common.buttons.uninstall") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
<template #popper>
|
||||||
|
<VDropdownItem
|
||||||
|
type="danger"
|
||||||
|
@click="handleUninstall(theme)"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.uninstall") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
<VDropdownItem
|
||||||
|
type="danger"
|
||||||
|
@click="handleUninstall(theme, true)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"core.theme.operations.uninstall_and_delete_config.button"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</VDropdownItem>
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
<VDropdownItem type="danger" @click="handleResetSettingConfig">
|
||||||
|
{{ $t("core.common.buttons.reset") }}
|
||||||
|
</VDropdownItem>
|
||||||
|
</template>
|
||||||
|
</VDropdown>
|
||||||
|
</VSpace>
|
||||||
|
<VButton
|
||||||
|
v-if="
|
||||||
|
!installed && currentUserHasPermission(['system:themes:manage'])
|
||||||
|
"
|
||||||
|
size="sm"
|
||||||
|
:loading="creating"
|
||||||
|
@click="handleCreateTheme"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.install") }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,111 +1,91 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { VButton, VModal, VTabbar } from "@halo-dev/components";
|
||||||
import {
|
import {
|
||||||
IconAddCircle,
|
computed,
|
||||||
IconGitHub,
|
ref,
|
||||||
VButton,
|
watch,
|
||||||
VEmpty,
|
provide,
|
||||||
VModal,
|
inject,
|
||||||
VSpace,
|
markRaw,
|
||||||
VEntity,
|
nextTick,
|
||||||
VEntityField,
|
onMounted,
|
||||||
VTabItem,
|
type Ref,
|
||||||
VTabs,
|
} from "vue";
|
||||||
VLoading,
|
|
||||||
Toast,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import LazyImage from "@/components/image/LazyImage.vue";
|
|
||||||
import ThemePreviewModal from "./preview/ThemePreviewModal.vue";
|
|
||||||
import ThemeUploadModal from "./ThemeUploadModal.vue";
|
|
||||||
import ThemeListItem from "./components/ThemeListItem.vue";
|
|
||||||
import { computed, ref, nextTick, watch } from "vue";
|
|
||||||
import type { Theme } from "@halo-dev/api-client";
|
import type { Theme } from "@halo-dev/api-client";
|
||||||
import { apiClient } from "@/utils/api-client";
|
|
||||||
import { useI18n } from "vue-i18n";
|
import { useI18n } from "vue-i18n";
|
||||||
import { useQuery } from "@tanstack/vue-query";
|
|
||||||
import { useRouteQuery } from "@vueuse/router";
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
|
import InstalledThemes from "./list-tabs/InstalledThemes.vue";
|
||||||
|
import NotInstalledThemes from "./list-tabs/NotInstalledThemes.vue";
|
||||||
|
import LocalUpload from "./list-tabs/LocalUpload.vue";
|
||||||
|
import RemoteDownload from "./list-tabs/RemoteDownload.vue";
|
||||||
|
import { usePluginModuleStore } from "@/stores/plugin";
|
||||||
|
import type { PluginModule, ThemeListTab } from "@halo-dev/console-shared";
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
selectedTheme?: Theme;
|
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
visible: false,
|
visible: false,
|
||||||
selectedTheme: undefined,
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => selectedTheme.value,
|
||||||
|
(value, oldValue) => {
|
||||||
|
if (value && oldValue) {
|
||||||
|
emit("select", value);
|
||||||
|
onVisibleChange(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: "update:visible", visible: boolean): void;
|
(event: "update:visible", visible: boolean): void;
|
||||||
(event: "close"): void;
|
(event: "close"): void;
|
||||||
(event: "update:selectedTheme", theme?: Theme): void;
|
(event: "select", theme: Theme | undefined): void;
|
||||||
(event: "select", theme: Theme | null): void;
|
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const activeTab = ref("installed");
|
const tabs = ref<ThemeListTab[]>([
|
||||||
const themeUploadVisible = ref(false);
|
{
|
||||||
const creating = ref(false);
|
id: "installed",
|
||||||
|
label: t("core.theme.list_modal.tabs.installed"),
|
||||||
|
component: markRaw(InstalledThemes),
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "local-upload",
|
||||||
|
label: t("core.theme.list_modal.tabs.local_upload"),
|
||||||
|
component: markRaw(LocalUpload),
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "remote-download",
|
||||||
|
label: t("core.theme.list_modal.tabs.remote_download.label"),
|
||||||
|
component: markRaw(RemoteDownload),
|
||||||
|
priority: 30,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "not_installed",
|
||||||
|
label: t("core.theme.list_modal.tabs.not_installed"),
|
||||||
|
component: markRaw(NotInstalledThemes),
|
||||||
|
priority: 40,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const activeTabId = ref();
|
||||||
|
|
||||||
|
provide<Ref<string>>("activeTabId", activeTabId);
|
||||||
|
|
||||||
const modalTitle = computed(() => {
|
const modalTitle = computed(() => {
|
||||||
return activeTab.value === "installed"
|
const tab = tabs.value.find((tab) => tab.id === activeTabId.value);
|
||||||
? t("core.theme.list_modal.titles.installed_themes")
|
return tab?.label;
|
||||||
: t("core.theme.list_modal.titles.not_installed_themes");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
|
||||||
data: themes,
|
|
||||||
isLoading,
|
|
||||||
isFetching,
|
|
||||||
refetch,
|
|
||||||
} = useQuery<Theme[]>({
|
|
||||||
queryKey: ["themes", activeTab],
|
|
||||||
queryFn: async () => {
|
|
||||||
const { data } = await apiClient.theme.listThemes({
|
|
||||||
page: 0,
|
|
||||||
size: 0,
|
|
||||||
uninstalled: activeTab.value !== "installed",
|
|
||||||
});
|
|
||||||
return data.items;
|
|
||||||
},
|
|
||||||
refetchInterval(data) {
|
|
||||||
if (activeTab.value !== "installed") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deletingThemes = data?.filter(
|
|
||||||
(theme) => !!theme.metadata.deletionTimestamp
|
|
||||||
);
|
|
||||||
|
|
||||||
return deletingThemes?.length ? 1000 : false;
|
|
||||||
},
|
|
||||||
enabled: computed(() => props.visible),
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCreateTheme = async (theme: Theme) => {
|
|
||||||
try {
|
|
||||||
creating.value = true;
|
|
||||||
|
|
||||||
const { data } =
|
|
||||||
await apiClient.extension.theme.createthemeHaloRunV1alpha1Theme({
|
|
||||||
theme,
|
|
||||||
});
|
|
||||||
|
|
||||||
// create theme settings
|
|
||||||
apiClient.theme.reload({ name: data.metadata.name });
|
|
||||||
|
|
||||||
activeTab.value = "installed";
|
|
||||||
|
|
||||||
Toast.success(t("core.common.toast.install_success"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to create theme", error);
|
|
||||||
} finally {
|
|
||||||
creating.value = false;
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onVisibleChange = (visible: boolean) => {
|
const onVisibleChange = (visible: boolean) => {
|
||||||
emit("update:visible", visible);
|
emit("update:visible", visible);
|
||||||
if (!visible) {
|
if (!visible) {
|
||||||
|
@ -113,38 +93,6 @@ const onVisibleChange = (visible: boolean) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectTheme = (theme: Theme) => {
|
|
||||||
emit("update:selectedTheme", theme);
|
|
||||||
emit("select", theme);
|
|
||||||
onVisibleChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
defineExpose({
|
|
||||||
handleFetchThemes: refetch,
|
|
||||||
});
|
|
||||||
|
|
||||||
// preview
|
|
||||||
const previewVisible = ref(false);
|
|
||||||
const selectedPreviewTheme = ref<Theme>();
|
|
||||||
|
|
||||||
const handleOpenPreview = (theme: Theme) => {
|
|
||||||
selectedPreviewTheme.value = theme;
|
|
||||||
previewVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// upgrade
|
|
||||||
const themeToUpgrade = ref<Theme>();
|
|
||||||
|
|
||||||
const handleOpenUpgradeModal = (theme: Theme) => {
|
|
||||||
themeToUpgrade.value = theme;
|
|
||||||
themeUploadVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenInstallModal = () => {
|
|
||||||
themeToUpgrade.value = undefined;
|
|
||||||
themeUploadVisible.value = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
// handle remote wordpress url from route
|
// handle remote wordpress url from route
|
||||||
const remoteDownloadUrl = useRouteQuery<string>("remote-download-url");
|
const remoteDownloadUrl = useRouteQuery<string>("remote-download-url");
|
||||||
watch(
|
watch(
|
||||||
|
@ -152,215 +100,64 @@ watch(
|
||||||
(visible) => {
|
(visible) => {
|
||||||
if (visible && remoteDownloadUrl.value) {
|
if (visible && remoteDownloadUrl.value) {
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
handleOpenInstallModal();
|
activeTabId.value = "remote-download";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { pluginModules } = usePluginModuleStore();
|
||||||
|
onMounted(() => {
|
||||||
|
const tabsFromPlugins: ThemeListTab[] = [];
|
||||||
|
pluginModules.forEach((pluginModule: PluginModule) => {
|
||||||
|
const { extensionPoints } = pluginModule;
|
||||||
|
if (!extensionPoints?.["theme:list:tabs:create"]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = extensionPoints["theme:list:tabs:create"]() as ThemeListTab[];
|
||||||
|
tabsFromPlugins.push(...items);
|
||||||
|
});
|
||||||
|
|
||||||
|
tabs.value = tabs.value.concat(tabsFromPlugins).sort((a, b) => {
|
||||||
|
return a.priority - b.priority;
|
||||||
|
});
|
||||||
|
|
||||||
|
activeTabId.value = tabs.value[0].id;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<VModal
|
<VModal
|
||||||
:body-class="['!p-0']"
|
|
||||||
:visible="visible"
|
:visible="visible"
|
||||||
:width="888"
|
:width="920"
|
||||||
height="calc(100vh - 20px)"
|
height="calc(100vh - 20px)"
|
||||||
:title="modalTitle"
|
:title="modalTitle"
|
||||||
@update:visible="onVisibleChange"
|
@update:visible="onVisibleChange"
|
||||||
>
|
>
|
||||||
<VTabs
|
<VTabbar
|
||||||
v-model:active-id="activeTab"
|
v-model:active-id="activeTabId"
|
||||||
type="outline"
|
:items="
|
||||||
class="mx-[16px] my-[12px]"
|
tabs.map((tab) => {
|
||||||
>
|
return { label: tab.label, id: tab.id };
|
||||||
<VTabItem
|
})
|
||||||
id="installed"
|
|
||||||
:label="$t('core.theme.list_modal.tabs.installed')"
|
|
||||||
class="-mx-[16px]"
|
|
||||||
>
|
|
||||||
<VLoading v-if="isLoading" />
|
|
||||||
<Transition v-else-if="!themes?.length" appear name="fade">
|
|
||||||
<VEmpty
|
|
||||||
:message="$t('core.theme.list_modal.empty.message')"
|
|
||||||
:title="$t('core.theme.list_modal.empty.title')"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<VSpace>
|
|
||||||
<VButton :loading="isFetching" @click="refetch()">
|
|
||||||
{{ $t("core.common.buttons.refresh") }}
|
|
||||||
</VButton>
|
|
||||||
<VButton
|
|
||||||
v-permission="['system:themes:manage']"
|
|
||||||
type="primary"
|
|
||||||
@click="handleOpenInstallModal()"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<IconAddCircle class="h-full w-full" />
|
|
||||||
</template>
|
|
||||||
{{ $t("core.theme.common.buttons.install") }}
|
|
||||||
</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</template>
|
|
||||||
</VEmpty>
|
|
||||||
</Transition>
|
|
||||||
<Transition v-else appear name="fade">
|
|
||||||
<ul
|
|
||||||
class="box-border h-full w-full divide-y divide-gray-100"
|
|
||||||
role="list"
|
|
||||||
>
|
|
||||||
<li
|
|
||||||
v-for="(theme, index) in themes"
|
|
||||||
:key="index"
|
|
||||||
@click="handleSelectTheme(theme)"
|
|
||||||
>
|
|
||||||
<ThemeListItem
|
|
||||||
:theme="theme"
|
|
||||||
:is-selected="
|
|
||||||
theme.metadata.name === selectedTheme?.metadata?.name
|
|
||||||
"
|
"
|
||||||
@reload="refetch"
|
type="outline"
|
||||||
@preview="handleOpenPreview(theme)"
|
/>
|
||||||
@upgrade="handleOpenUpgradeModal(theme)"
|
|
||||||
|
<div class="mt-2">
|
||||||
|
<template v-for="tab in tabs" :key="tab.id">
|
||||||
|
<component
|
||||||
|
:is="tab.component"
|
||||||
|
v-bind="tab.props"
|
||||||
|
v-if="tab.id === activeTabId"
|
||||||
/>
|
/>
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Transition>
|
|
||||||
</VTabItem>
|
|
||||||
<VTabItem
|
|
||||||
id="uninstalled"
|
|
||||||
:label="$t('core.theme.list_modal.tabs.not_installed')"
|
|
||||||
class="-mx-[16px]"
|
|
||||||
>
|
|
||||||
<VLoading v-if="isLoading" />
|
|
||||||
<Transition v-else-if="!themes?.length" appear name="fade">
|
|
||||||
<VEmpty
|
|
||||||
:title="$t('core.theme.list_modal.not_installed_empty.title')"
|
|
||||||
>
|
|
||||||
<template #actions>
|
|
||||||
<VSpace>
|
|
||||||
<VButton :loading="isFetching" @click="refetch">
|
|
||||||
{{ $t("core.common.buttons.refresh") }}
|
|
||||||
</VButton>
|
|
||||||
</VSpace>
|
|
||||||
</template>
|
</template>
|
||||||
</VEmpty>
|
|
||||||
</Transition>
|
|
||||||
<Transition v-else appear name="fade">
|
|
||||||
<ul
|
|
||||||
class="box-border h-full w-full divide-y divide-gray-100"
|
|
||||||
role="list"
|
|
||||||
>
|
|
||||||
<li v-for="(theme, index) in themes" :key="index">
|
|
||||||
<VEntity>
|
|
||||||
<template #start>
|
|
||||||
<VEntityField>
|
|
||||||
<template #description>
|
|
||||||
<div class="w-32">
|
|
||||||
<div
|
|
||||||
class="group aspect-h-3 aspect-w-4 block w-full overflow-hidden rounded border bg-gray-100"
|
|
||||||
>
|
|
||||||
<LazyImage
|
|
||||||
:key="theme.metadata.name"
|
|
||||||
:src="theme.spec.logo"
|
|
||||||
:alt="theme.spec.displayName"
|
|
||||||
classes="pointer-events-none object-cover group-hover:opacity-75"
|
|
||||||
>
|
|
||||||
<template #loading>
|
|
||||||
<div
|
|
||||||
class="flex h-full items-center justify-center object-cover"
|
|
||||||
>
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
{{ $t("core.common.status.loading") }}...
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
|
||||||
<template #error>
|
|
||||||
<div
|
|
||||||
class="flex h-full items-center justify-center object-cover"
|
|
||||||
>
|
|
||||||
<span class="text-xs text-red-400">
|
|
||||||
{{ $t("core.common.status.loading_error") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</LazyImage>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
<VEntityField
|
|
||||||
:title="theme.spec.displayName"
|
|
||||||
:description="theme.spec.version"
|
|
||||||
>
|
|
||||||
</VEntityField>
|
|
||||||
</template>
|
|
||||||
<template #end>
|
|
||||||
<VEntityField>
|
|
||||||
<template #description>
|
|
||||||
<a
|
|
||||||
class="text-sm text-gray-400 hover:text-blue-600"
|
|
||||||
:href="theme.spec.author.website"
|
|
||||||
target="_blank"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
{{ theme.spec.author.name }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
<VEntityField>
|
|
||||||
<template #description>
|
|
||||||
<a
|
|
||||||
:href="theme.spec.repo"
|
|
||||||
class="text-gray-900 hover:text-blue-600"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<IconGitHub />
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
<VEntityField v-permission="['system:themes:manage']">
|
|
||||||
<template #description>
|
|
||||||
<VButton
|
|
||||||
size="sm"
|
|
||||||
:disabled="creating"
|
|
||||||
@click="handleCreateTheme(theme)"
|
|
||||||
>
|
|
||||||
{{ $t("core.common.buttons.install") }}
|
|
||||||
</VButton>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
</template>
|
|
||||||
</VEntity>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</Transition>
|
|
||||||
</VTabItem>
|
|
||||||
</VTabs>
|
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VSpace>
|
|
||||||
<VButton
|
|
||||||
v-permission="['system:themes:manage']"
|
|
||||||
type="secondary"
|
|
||||||
@click="handleOpenInstallModal()"
|
|
||||||
>
|
|
||||||
{{ $t("core.theme.common.buttons.install") }}
|
|
||||||
</VButton>
|
|
||||||
<VButton @click="onVisibleChange(false)">
|
<VButton @click="onVisibleChange(false)">
|
||||||
{{ $t("core.common.buttons.close") }}
|
{{ $t("core.common.buttons.close") }}
|
||||||
</VButton>
|
</VButton>
|
||||||
</VSpace>
|
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
|
|
||||||
<ThemeUploadModal
|
|
||||||
v-if="visible"
|
|
||||||
v-model:visible="themeUploadVisible"
|
|
||||||
:upgrade-theme="themeToUpgrade"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ThemePreviewModal
|
|
||||||
v-if="visible"
|
|
||||||
v-model:visible="previewVisible"
|
|
||||||
:theme="selectedPreviewTheme"
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,283 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
Toast,
|
|
||||||
VButton,
|
|
||||||
VModal,
|
|
||||||
VTabItem,
|
|
||||||
VTabs,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import UppyUpload from "@/components/upload/UppyUpload.vue";
|
|
||||||
import { computed, ref, watch, nextTick } from "vue";
|
|
||||||
import type { Theme } from "@halo-dev/api-client";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
import { useQueryClient } from "@tanstack/vue-query";
|
|
||||||
import { useThemeStore } from "@/stores/theme";
|
|
||||||
import { apiClient } from "@/utils/api-client";
|
|
||||||
import { useRouteQuery } from "@vueuse/router";
|
|
||||||
import { submitForm } from "@formkit/core";
|
|
||||||
import type { ErrorResponse, UppyFile } from "@uppy/core";
|
|
||||||
import AppDownloadAlert from "@/components/common/AppDownloadAlert.vue";
|
|
||||||
|
|
||||||
const { t } = useI18n();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const themeStore = useThemeStore();
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
visible: boolean;
|
|
||||||
upgradeTheme?: Theme;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
visible: false,
|
|
||||||
upgradeTheme: undefined,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: "update:visible", visible: boolean): void;
|
|
||||||
(event: "close"): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const uploadVisible = ref(false);
|
|
||||||
|
|
||||||
const modalTitle = computed(() => {
|
|
||||||
return props.upgradeTheme
|
|
||||||
? t("core.theme.upload_modal.titles.upgrade", {
|
|
||||||
display_name: props.upgradeTheme.spec.displayName,
|
|
||||||
})
|
|
||||||
: t("core.theme.upload_modal.titles.install");
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleVisibleChange = (visible: boolean) => {
|
|
||||||
emit("update:visible", visible);
|
|
||||||
if (!visible) {
|
|
||||||
emit("close");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const endpoint = computed(() => {
|
|
||||||
if (props.upgradeTheme) {
|
|
||||||
return `/apis/api.console.halo.run/v1alpha1/themes/${props.upgradeTheme.metadata.name}/upgrade`;
|
|
||||||
}
|
|
||||||
return "/apis/api.console.halo.run/v1alpha1/themes/install";
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.visible,
|
|
||||||
(newValue) => {
|
|
||||||
if (newValue) {
|
|
||||||
uploadVisible.value = true;
|
|
||||||
} else {
|
|
||||||
const uploadVisibleTimer = setTimeout(() => {
|
|
||||||
uploadVisible.value = false;
|
|
||||||
clearTimeout(uploadVisibleTimer);
|
|
||||||
}, 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const onUploaded = () => {
|
|
||||||
Toast.success(
|
|
||||||
t(
|
|
||||||
props.upgradeTheme
|
|
||||||
? "core.common.toast.upgrade_success"
|
|
||||||
: "core.common.toast.install_success"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
|
||||||
themeStore.fetchActivatedTheme();
|
|
||||||
|
|
||||||
handleVisibleChange(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ThemeInstallationErrorResponse {
|
|
||||||
detail: string;
|
|
||||||
instance: string;
|
|
||||||
themeName: string;
|
|
||||||
requestId: string;
|
|
||||||
status: number;
|
|
||||||
timestamp: string;
|
|
||||||
title: string;
|
|
||||||
type: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const THEME_ALREADY_EXISTS_TYPE = "https://halo.run/probs/theme-alreay-exists";
|
|
||||||
|
|
||||||
const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
|
|
||||||
const body = response.body as ThemeInstallationErrorResponse;
|
|
||||||
|
|
||||||
if (body.type === THEME_ALREADY_EXISTS_TYPE) {
|
|
||||||
handleCatchExistsException(body, file.data as File);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCatchExistsException = async (
|
|
||||||
error: ThemeInstallationErrorResponse,
|
|
||||||
file?: File
|
|
||||||
) => {
|
|
||||||
Dialog.info({
|
|
||||||
title: t(
|
|
||||||
"core.theme.upload_modal.operations.existed_during_installation.title"
|
|
||||||
),
|
|
||||||
description: t(
|
|
||||||
"core.theme.upload_modal.operations.existed_during_installation.description"
|
|
||||||
),
|
|
||||||
confirmText: t("core.common.buttons.confirm"),
|
|
||||||
cancelText: t("core.common.buttons.cancel"),
|
|
||||||
onConfirm: async () => {
|
|
||||||
if (activeTabId.value === "local") {
|
|
||||||
if (!file) {
|
|
||||||
throw new Error("File is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
await apiClient.theme.upgradeTheme({
|
|
||||||
name: error.themeName,
|
|
||||||
file: file,
|
|
||||||
});
|
|
||||||
} else if (activeTabId.value === "remote") {
|
|
||||||
await apiClient.theme.upgradeThemeFromUri({
|
|
||||||
name: error.themeName,
|
|
||||||
upgradeFromUriRequest: {
|
|
||||||
uri: remoteDownloadUrl.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
throw new Error("Unknown tab id");
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.success(t("core.common.toast.upgrade_success"));
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
|
||||||
themeStore.fetchActivatedTheme();
|
|
||||||
|
|
||||||
handleVisibleChange(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// remote download
|
|
||||||
const activeTabId = ref("local");
|
|
||||||
const remoteDownloadUrl = ref("");
|
|
||||||
const downloading = ref(false);
|
|
||||||
|
|
||||||
const handleDownloadTheme = async () => {
|
|
||||||
try {
|
|
||||||
downloading.value = true;
|
|
||||||
|
|
||||||
if (props.upgradeTheme) {
|
|
||||||
await apiClient.theme.upgradeThemeFromUri({
|
|
||||||
name: props.upgradeTheme.metadata.name,
|
|
||||||
upgradeFromUriRequest: {
|
|
||||||
uri: remoteDownloadUrl.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await apiClient.theme.installThemeFromUri({
|
|
||||||
installFromUriRequest: {
|
|
||||||
uri: remoteDownloadUrl.value,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.success(
|
|
||||||
t(
|
|
||||||
props.upgradeTheme
|
|
||||||
? "core.common.toast.upgrade_success"
|
|
||||||
: "core.common.toast.install_success"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
|
||||||
themeStore.fetchActivatedTheme();
|
|
||||||
|
|
||||||
handleVisibleChange(false);
|
|
||||||
|
|
||||||
// eslint-disable-next-line
|
|
||||||
} catch (error: any) {
|
|
||||||
const data = error?.response.data as ThemeInstallationErrorResponse;
|
|
||||||
if (data?.type === THEME_ALREADY_EXISTS_TYPE) {
|
|
||||||
handleCatchExistsException(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error("Failed to download theme", error);
|
|
||||||
} finally {
|
|
||||||
routeRemoteDownloadUrl.value = null;
|
|
||||||
downloading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// handle remote download url from route
|
|
||||||
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
|
|
||||||
"remote-download-url"
|
|
||||||
);
|
|
||||||
watch(
|
|
||||||
() => props.visible,
|
|
||||||
(visible) => {
|
|
||||||
if (routeRemoteDownloadUrl.value && visible) {
|
|
||||||
activeTabId.value = "remote";
|
|
||||||
remoteDownloadUrl.value = routeRemoteDownloadUrl.value as string;
|
|
||||||
nextTick(() => {
|
|
||||||
submitForm("theme-remote-download-form");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
</script>
|
|
||||||
<template>
|
|
||||||
<VModal
|
|
||||||
:visible="visible"
|
|
||||||
:width="600"
|
|
||||||
:title="modalTitle"
|
|
||||||
:centered="false"
|
|
||||||
@update:visible="handleVisibleChange"
|
|
||||||
>
|
|
||||||
<VTabs v-model:active-id="activeTabId" type="outline" class="!rounded-none">
|
|
||||||
<VTabItem id="local" :label="$t('core.theme.upload_modal.tabs.local')">
|
|
||||||
<div class="pb-3">
|
|
||||||
<AppDownloadAlert />
|
|
||||||
</div>
|
|
||||||
<UppyUpload
|
|
||||||
v-if="uploadVisible"
|
|
||||||
:restrictions="{
|
|
||||||
maxNumberOfFiles: 1,
|
|
||||||
allowedFileTypes: ['.zip'],
|
|
||||||
}"
|
|
||||||
:endpoint="endpoint"
|
|
||||||
auto-proceed
|
|
||||||
@uploaded="onUploaded"
|
|
||||||
@error="onError"
|
|
||||||
/>
|
|
||||||
</VTabItem>
|
|
||||||
<VTabItem
|
|
||||||
id="remote"
|
|
||||||
:label="$t('core.theme.upload_modal.tabs.remote.title')"
|
|
||||||
>
|
|
||||||
<FormKit
|
|
||||||
id="theme-remote-download-form"
|
|
||||||
name="theme-remote-download-form"
|
|
||||||
type="form"
|
|
||||||
:preserve="true"
|
|
||||||
@submit="handleDownloadTheme"
|
|
||||||
>
|
|
||||||
<FormKit
|
|
||||||
v-model="remoteDownloadUrl"
|
|
||||||
:label="$t('core.theme.upload_modal.tabs.remote.fields.url')"
|
|
||||||
type="text"
|
|
||||||
></FormKit>
|
|
||||||
</FormKit>
|
|
||||||
|
|
||||||
<div class="pt-5">
|
|
||||||
<VButton
|
|
||||||
:loading="downloading"
|
|
||||||
type="secondary"
|
|
||||||
@click="$formkit.submit('theme-remote-download-form')"
|
|
||||||
>
|
|
||||||
{{ $t("core.common.buttons.download") }}
|
|
||||||
</VButton>
|
|
||||||
</div>
|
|
||||||
</VTabItem>
|
|
||||||
</VTabs>
|
|
||||||
</VModal>
|
|
||||||
</template>
|
|
|
@ -1,224 +0,0 @@
|
||||||
<script lang="ts" setup>
|
|
||||||
import {
|
|
||||||
IconGitHub,
|
|
||||||
VTag,
|
|
||||||
VEntity,
|
|
||||||
VEntityField,
|
|
||||||
VStatusDot,
|
|
||||||
Dialog,
|
|
||||||
Toast,
|
|
||||||
VDropdownItem,
|
|
||||||
VDropdown,
|
|
||||||
VDropdownDivider,
|
|
||||||
} from "@halo-dev/components";
|
|
||||||
import LazyImage from "@/components/image/LazyImage.vue";
|
|
||||||
import type { Theme } from "@halo-dev/api-client";
|
|
||||||
import { apiClient } from "@/utils/api-client";
|
|
||||||
import { toRefs } from "vue";
|
|
||||||
import { useThemeLifeCycle } from "../../composables/use-theme";
|
|
||||||
import { usePermission } from "@/utils/permission";
|
|
||||||
import { useI18n } from "vue-i18n";
|
|
||||||
|
|
||||||
const { currentUserHasPermission } = usePermission();
|
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
theme: Theme;
|
|
||||||
isSelected?: boolean;
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
isSelected: false,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: "reload"): void;
|
|
||||||
(event: "upgrade"): void;
|
|
||||||
(event: "preview"): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { theme } = toRefs(props);
|
|
||||||
|
|
||||||
const {
|
|
||||||
isActivated,
|
|
||||||
getFailedMessage,
|
|
||||||
handleActiveTheme,
|
|
||||||
handleResetSettingConfig,
|
|
||||||
} = useThemeLifeCycle(theme);
|
|
||||||
|
|
||||||
const handleUninstall = async (theme: Theme, deleteExtensions?: boolean) => {
|
|
||||||
Dialog.warning({
|
|
||||||
title: `${
|
|
||||||
deleteExtensions
|
|
||||||
? t("core.theme.operations.uninstall_and_delete_config.title")
|
|
||||||
: t("core.theme.operations.uninstall.title")
|
|
||||||
}`,
|
|
||||||
description: t("core.common.dialog.descriptions.cannot_be_recovered"),
|
|
||||||
confirmText: t("core.common.buttons.confirm"),
|
|
||||||
cancelText: t("core.common.buttons.cancel"),
|
|
||||||
onConfirm: async () => {
|
|
||||||
try {
|
|
||||||
await apiClient.extension.theme.deletethemeHaloRunV1alpha1Theme({
|
|
||||||
name: theme.metadata.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
// delete theme setting and configMap
|
|
||||||
if (deleteExtensions) {
|
|
||||||
const { settingName, configMapName } = theme.spec;
|
|
||||||
|
|
||||||
if (settingName) {
|
|
||||||
await apiClient.extension.setting.deletev1alpha1Setting(
|
|
||||||
{
|
|
||||||
name: settingName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mute: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configMapName) {
|
|
||||||
await apiClient.extension.configMap.deletev1alpha1ConfigMap(
|
|
||||||
{
|
|
||||||
name: configMapName,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
mute: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Toast.success(t("core.common.toast.uninstall_success"));
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Failed to uninstall theme", e);
|
|
||||||
} finally {
|
|
||||||
emit("reload");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<VEntity :is-selected="isSelected">
|
|
||||||
<template #start>
|
|
||||||
<VEntityField>
|
|
||||||
<template #description>
|
|
||||||
<div class="w-32">
|
|
||||||
<div
|
|
||||||
class="group aspect-h-3 aspect-w-4 block w-full overflow-hidden rounded border bg-gray-100"
|
|
||||||
>
|
|
||||||
<LazyImage
|
|
||||||
:key="theme.metadata.name"
|
|
||||||
:src="theme.spec.logo"
|
|
||||||
:alt="theme.spec.displayName"
|
|
||||||
classes="pointer-events-none object-cover group-hover:opacity-75"
|
|
||||||
>
|
|
||||||
<template #loading>
|
|
||||||
<div
|
|
||||||
class="flex h-full items-center justify-center object-cover"
|
|
||||||
>
|
|
||||||
<span class="text-xs text-gray-400">
|
|
||||||
{{ $t("core.common.status.loading") }}...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #error>
|
|
||||||
<div
|
|
||||||
class="flex h-full items-center justify-center object-cover"
|
|
||||||
>
|
|
||||||
<span class="text-xs text-red-400">
|
|
||||||
{{ $t("core.common.status.loading_error") }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</LazyImage>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
<VEntityField
|
|
||||||
:title="theme.spec.displayName"
|
|
||||||
:description="theme.spec.version"
|
|
||||||
>
|
|
||||||
<template #extra>
|
|
||||||
<VTag v-if="isActivated">
|
|
||||||
{{ $t("core.common.status.activated") }}
|
|
||||||
</VTag>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
</template>
|
|
||||||
<template #end>
|
|
||||||
<VEntityField v-if="getFailedMessage()">
|
|
||||||
<template #description>
|
|
||||||
<VStatusDot v-tooltip="getFailedMessage()" state="warning" animate />
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
<VEntityField v-if="theme.metadata.deletionTimestamp">
|
|
||||||
<template #description>
|
|
||||||
<VStatusDot
|
|
||||||
v-tooltip="$t('core.common.status.deleting')"
|
|
||||||
state="warning"
|
|
||||||
animate
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
<VEntityField>
|
|
||||||
<template #description>
|
|
||||||
<a
|
|
||||||
class="text-sm text-gray-400 hover:text-blue-600"
|
|
||||||
:href="theme.spec.author.website"
|
|
||||||
target="_blank"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
{{ theme.spec.author.name }}
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
<VEntityField>
|
|
||||||
<template #description>
|
|
||||||
<a
|
|
||||||
:href="theme.spec.repo"
|
|
||||||
class="text-gray-900 hover:text-blue-600"
|
|
||||||
target="_blank"
|
|
||||||
>
|
|
||||||
<IconGitHub />
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</VEntityField>
|
|
||||||
</template>
|
|
||||||
<template
|
|
||||||
v-if="currentUserHasPermission(['system:themes:manage'])"
|
|
||||||
#dropdownItems
|
|
||||||
>
|
|
||||||
<VDropdownItem v-if="!isActivated" @click="handleActiveTheme(true)">
|
|
||||||
{{ $t("core.common.buttons.activate") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
<VDropdownItem @click="emit('upgrade')">
|
|
||||||
{{ $t("core.common.buttons.upgrade") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
<VDropdownItem @click="emit('preview')">
|
|
||||||
{{ $t("core.common.buttons.preview") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
<VDropdownDivider />
|
|
||||||
<VDropdown placement="right" :triggers="['click']">
|
|
||||||
<VDropdownItem type="danger">
|
|
||||||
{{ $t("core.common.buttons.uninstall") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
<template #popper>
|
|
||||||
<VDropdownItem type="danger" @click="handleUninstall(theme)">
|
|
||||||
{{ $t("core.common.buttons.uninstall") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
<VDropdownItem type="danger" @click="handleUninstall(theme, true)">
|
|
||||||
{{ $t("core.theme.operations.uninstall_and_delete_config.button") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
</template>
|
|
||||||
</VDropdown>
|
|
||||||
<VDropdownItem type="danger" @click="handleResetSettingConfig">
|
|
||||||
{{ $t("core.common.buttons.reset") }}
|
|
||||||
</VDropdownItem>
|
|
||||||
</template>
|
|
||||||
</VEntity>
|
|
||||||
</template>
|
|
|
@ -0,0 +1,113 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
IconAddCircle,
|
||||||
|
VButton,
|
||||||
|
VEmpty,
|
||||||
|
VSpace,
|
||||||
|
VLoading,
|
||||||
|
} from "@halo-dev/components";
|
||||||
|
import ThemePreviewModal from "../preview/ThemePreviewModal.vue";
|
||||||
|
import ThemeListItem from "../ThemeListItem.vue";
|
||||||
|
import { ref, inject, type Ref } from "vue";
|
||||||
|
import type { Theme } from "@halo-dev/api-client";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
const selectedTheme = inject<Ref<Theme | undefined>>("selectedTheme", ref());
|
||||||
|
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
|
||||||
|
|
||||||
|
function handleSelectTheme(theme: Theme) {
|
||||||
|
selectedTheme.value = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: themes,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<Theme[]>({
|
||||||
|
queryKey: ["installed-themes"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.theme.listThemes({
|
||||||
|
page: 0,
|
||||||
|
size: 0,
|
||||||
|
uninstalled: false,
|
||||||
|
});
|
||||||
|
return data.items.sort((a, b) => {
|
||||||
|
const activatedThemeName = themeStore.activatedTheme?.metadata.name;
|
||||||
|
if (a.metadata.name === activatedThemeName) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (b.metadata.name === activatedThemeName) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
refetchInterval(data) {
|
||||||
|
const deletingThemes = data?.filter(
|
||||||
|
(theme) => !!theme.metadata.deletionTimestamp
|
||||||
|
);
|
||||||
|
|
||||||
|
return deletingThemes?.length ? 1000 : false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// preview
|
||||||
|
const previewVisible = ref(false);
|
||||||
|
const selectedPreviewTheme = ref<Theme>();
|
||||||
|
|
||||||
|
const handleOpenPreview = (theme: Theme) => {
|
||||||
|
selectedPreviewTheme.value = theme;
|
||||||
|
previewVisible.value = true;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="installed-themes-wrapper">
|
||||||
|
<VLoading v-if="isLoading" />
|
||||||
|
<Transition v-else-if="!themes?.length" appear name="fade">
|
||||||
|
<VEmpty
|
||||||
|
:message="$t('core.theme.list_modal.empty.message')"
|
||||||
|
:title="$t('core.theme.list_modal.empty.title')"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<VSpace>
|
||||||
|
<VButton :loading="isFetching" @click="refetch()">
|
||||||
|
{{ $t("core.common.buttons.refresh") }}
|
||||||
|
</VButton>
|
||||||
|
<VButton
|
||||||
|
v-permission="['system:themes:manage']"
|
||||||
|
type="primary"
|
||||||
|
@click="activeTabId = 'local-upload'"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<IconAddCircle class="h-full w-full" />
|
||||||
|
</template>
|
||||||
|
{{ $t("core.theme.common.buttons.install") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VEmpty>
|
||||||
|
</Transition>
|
||||||
|
<Transition v-else appear name="fade">
|
||||||
|
<ul class="box-border h-full w-full space-y-3" role="list">
|
||||||
|
<li v-for="(theme, index) in themes" :key="index">
|
||||||
|
<ThemeListItem
|
||||||
|
:theme="theme"
|
||||||
|
:is-selected="theme.metadata.name === selectedTheme?.metadata?.name"
|
||||||
|
@select="handleSelectTheme"
|
||||||
|
@preview="handleOpenPreview(theme)"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Transition>
|
||||||
|
<ThemePreviewModal
|
||||||
|
v-model:visible="previewVisible"
|
||||||
|
:theme="selectedPreviewTheme"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,89 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import AppDownloadAlert from "@/components/common/AppDownloadAlert.vue";
|
||||||
|
import { Dialog, Toast } from "@halo-dev/components";
|
||||||
|
import type { ErrorResponse } from "@uppy/core";
|
||||||
|
import type { UppyFile } from "@uppy/core";
|
||||||
|
import { THEME_ALREADY_EXISTS_TYPE } from "../../constants";
|
||||||
|
import type { ThemeInstallationErrorResponse } from "../../types";
|
||||||
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { inject } from "vue";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import UppyUpload from "@/components/upload/UppyUpload.vue";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
|
||||||
|
|
||||||
|
const endpoint = "/apis/api.console.halo.run/v1alpha1/themes/install";
|
||||||
|
|
||||||
|
const onUploaded = () => {
|
||||||
|
Toast.success(t("core.common.toast.install_success"));
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||||
|
themeStore.fetchActivatedTheme();
|
||||||
|
|
||||||
|
activeTabId.value = "installed";
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (file: UppyFile<unknown>, response: ErrorResponse) => {
|
||||||
|
const body = response.body as ThemeInstallationErrorResponse;
|
||||||
|
|
||||||
|
if (body.type === THEME_ALREADY_EXISTS_TYPE) {
|
||||||
|
handleCatchExistsException(body, file.data as File);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCatchExistsException = async (
|
||||||
|
error: ThemeInstallationErrorResponse,
|
||||||
|
file?: File
|
||||||
|
) => {
|
||||||
|
Dialog.info({
|
||||||
|
title: t("core.theme.operations.existed_during_installation.title"),
|
||||||
|
description: t(
|
||||||
|
"core.theme.operations.existed_during_installation.description"
|
||||||
|
),
|
||||||
|
confirmText: t("core.common.buttons.confirm"),
|
||||||
|
cancelText: t("core.common.buttons.cancel"),
|
||||||
|
onConfirm: async () => {
|
||||||
|
if (!file) {
|
||||||
|
throw new Error("File is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiClient.theme.upgradeTheme({
|
||||||
|
name: error.themeName,
|
||||||
|
file: file,
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.upgrade_success"));
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||||
|
themeStore.fetchActivatedTheme();
|
||||||
|
|
||||||
|
activeTabId.value = "installed";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="pb-3">
|
||||||
|
<AppDownloadAlert />
|
||||||
|
</div>
|
||||||
|
<UppyUpload
|
||||||
|
:restrictions="{
|
||||||
|
maxNumberOfFiles: 1,
|
||||||
|
allowedFileTypes: ['.zip'],
|
||||||
|
}"
|
||||||
|
:endpoint="endpoint"
|
||||||
|
width="100%"
|
||||||
|
auto-proceed
|
||||||
|
@uploaded="onUploaded"
|
||||||
|
@error="onError"
|
||||||
|
/>
|
||||||
|
</template>
|
|
@ -0,0 +1,48 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { VButton, VEmpty, VSpace, VLoading } from "@halo-dev/components";
|
||||||
|
import ThemeListItem from "../ThemeListItem.vue";
|
||||||
|
import type { Theme } from "@halo-dev/api-client";
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import { useQuery } from "@tanstack/vue-query";
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: themes,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<Theme[]>({
|
||||||
|
queryKey: ["not-installed-themes"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await apiClient.theme.listThemes({
|
||||||
|
page: 0,
|
||||||
|
size: 0,
|
||||||
|
uninstalled: true,
|
||||||
|
});
|
||||||
|
return data.items;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div id="not-installed-themes-wrapper">
|
||||||
|
<VLoading v-if="isLoading" />
|
||||||
|
<Transition v-else-if="!themes?.length" appear name="fade">
|
||||||
|
<VEmpty :title="$t('core.theme.list_modal.not_installed_empty.title')">
|
||||||
|
<template #actions>
|
||||||
|
<VSpace>
|
||||||
|
<VButton :loading="isFetching" @click="refetch">
|
||||||
|
{{ $t("core.common.buttons.refresh") }}
|
||||||
|
</VButton>
|
||||||
|
</VSpace>
|
||||||
|
</template>
|
||||||
|
</VEmpty>
|
||||||
|
</Transition>
|
||||||
|
<Transition v-else appear name="fade">
|
||||||
|
<ul class="box-border h-full w-full space-y-3" role="list">
|
||||||
|
<li v-for="(theme, index) in themes" :key="index">
|
||||||
|
<ThemeListItem :theme="theme" :installed="false" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,124 @@
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { apiClient } from "@/utils/api-client";
|
||||||
|
import { Dialog, Toast, VButton } from "@halo-dev/components";
|
||||||
|
import { useQueryClient } from "@tanstack/vue-query";
|
||||||
|
import type { Ref } from "vue";
|
||||||
|
import { inject } from "vue";
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import type { ThemeInstallationErrorResponse } from "../../types";
|
||||||
|
import { useThemeStore } from "@/stores/theme";
|
||||||
|
import { THEME_ALREADY_EXISTS_TYPE } from "../../constants";
|
||||||
|
import { useRouteQuery } from "@vueuse/router";
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { nextTick } from "vue";
|
||||||
|
import { submitForm } from "@formkit/core";
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const themeStore = useThemeStore();
|
||||||
|
|
||||||
|
const activeTabId = inject<Ref<string>>("activeTabId", ref(""));
|
||||||
|
const remoteDownloadUrl = ref("");
|
||||||
|
const downloading = ref(false);
|
||||||
|
|
||||||
|
const handleDownloadTheme = async () => {
|
||||||
|
try {
|
||||||
|
downloading.value = true;
|
||||||
|
|
||||||
|
await apiClient.theme.installThemeFromUri({
|
||||||
|
installFromUriRequest: {
|
||||||
|
uri: remoteDownloadUrl.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.install_success"));
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||||
|
themeStore.fetchActivatedTheme();
|
||||||
|
|
||||||
|
activeTabId.value = "installed";
|
||||||
|
|
||||||
|
// eslint-disable-next-line
|
||||||
|
} catch (error: any) {
|
||||||
|
const data = error?.response.data as ThemeInstallationErrorResponse;
|
||||||
|
if (data?.type === THEME_ALREADY_EXISTS_TYPE) {
|
||||||
|
handleCatchExistsException(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("Failed to download theme", error);
|
||||||
|
} finally {
|
||||||
|
routeRemoteDownloadUrl.value = null;
|
||||||
|
downloading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCatchExistsException = async (
|
||||||
|
error: ThemeInstallationErrorResponse
|
||||||
|
) => {
|
||||||
|
Dialog.info({
|
||||||
|
title: t("core.theme.operations.existed_during_installation.title"),
|
||||||
|
description: t(
|
||||||
|
"core.theme.operations.existed_during_installation.description"
|
||||||
|
),
|
||||||
|
confirmText: t("core.common.buttons.confirm"),
|
||||||
|
cancelText: t("core.common.buttons.cancel"),
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiClient.theme.upgradeThemeFromUri({
|
||||||
|
name: error.themeName,
|
||||||
|
upgradeFromUriRequest: {
|
||||||
|
uri: remoteDownloadUrl.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Toast.success(t("core.common.toast.upgrade_success"));
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["themes"] });
|
||||||
|
themeStore.fetchActivatedTheme();
|
||||||
|
|
||||||
|
activeTabId.value = "installed";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// handle remote download url from route
|
||||||
|
const routeRemoteDownloadUrl = useRouteQuery<string | null>(
|
||||||
|
"remote-download-url"
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (routeRemoteDownloadUrl.value) {
|
||||||
|
remoteDownloadUrl.value = routeRemoteDownloadUrl.value;
|
||||||
|
nextTick(() => {
|
||||||
|
submitForm("theme-remote-download-form");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<FormKit
|
||||||
|
id="theme-remote-download-form"
|
||||||
|
name="theme-remote-download-form"
|
||||||
|
type="form"
|
||||||
|
:preserve="true"
|
||||||
|
@submit="handleDownloadTheme"
|
||||||
|
>
|
||||||
|
<FormKit
|
||||||
|
v-model="remoteDownloadUrl"
|
||||||
|
:label="$t('core.theme.list_modal.tabs.remote_download.fields.url')"
|
||||||
|
type="text"
|
||||||
|
validation="required"
|
||||||
|
></FormKit>
|
||||||
|
</FormKit>
|
||||||
|
|
||||||
|
<div class="pt-5">
|
||||||
|
<VButton
|
||||||
|
:loading="downloading"
|
||||||
|
type="secondary"
|
||||||
|
@click="$formkit.submit('theme-remote-download-form')"
|
||||||
|
>
|
||||||
|
{{ $t("core.common.buttons.download") }}
|
||||||
|
</VButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -0,0 +1,2 @@
|
||||||
|
export const THEME_ALREADY_EXISTS_TYPE =
|
||||||
|
"https://halo.run/probs/theme-alreay-exists";
|
|
@ -25,6 +25,7 @@ import {
|
||||||
VTabbar,
|
VTabbar,
|
||||||
VLoading,
|
VLoading,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
IconListSettings,
|
||||||
} from "@halo-dev/components";
|
} from "@halo-dev/components";
|
||||||
import ThemeListModal from "../components/ThemeListModal.vue";
|
import ThemeListModal from "../components/ThemeListModal.vue";
|
||||||
import ThemePreviewModal from "../components/preview/ThemePreviewModal.vue";
|
import ThemePreviewModal from "../components/preview/ThemePreviewModal.vue";
|
||||||
|
@ -67,6 +68,7 @@ const themesModal = ref(false);
|
||||||
const previewModal = ref(false);
|
const previewModal = ref(false);
|
||||||
const activeTab = ref(tabs.value[0].id);
|
const activeTab = ref(tabs.value[0].id);
|
||||||
provide<Ref<string>>("activeTab", activeTab);
|
provide<Ref<string>>("activeTab", activeTab);
|
||||||
|
provide<Ref<boolean>>("themesModal", themesModal);
|
||||||
|
|
||||||
const { loading, isActivated, handleActiveTheme } =
|
const { loading, isActivated, handleActiveTheme } =
|
||||||
useThemeLifeCycle(selectedTheme);
|
useThemeLifeCycle(selectedTheme);
|
||||||
|
@ -184,11 +186,6 @@ onMounted(() => {
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<BasicLayout>
|
<BasicLayout>
|
||||||
<ThemeListModal
|
|
||||||
v-model:selected-theme="selectedTheme"
|
|
||||||
v-model:visible="themesModal"
|
|
||||||
@select="onSelectTheme"
|
|
||||||
/>
|
|
||||||
<VPageHeader :title="selectedTheme?.spec.displayName">
|
<VPageHeader :title="selectedTheme?.spec.displayName">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconPalette class="mr-2 self-center" />
|
<IconPalette class="mr-2 self-center" />
|
||||||
|
@ -204,15 +201,15 @@ onMounted(() => {
|
||||||
>
|
>
|
||||||
{{ $t("core.common.buttons.activate") }}
|
{{ $t("core.common.buttons.activate") }}
|
||||||
</VButton>
|
</VButton>
|
||||||
<VButton type="secondary" size="sm" @click="previewModal = true">
|
<VButton type="default" size="sm" @click="previewModal = true">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconEye class="h-full w-full" />
|
<IconEye class="h-full w-full" />
|
||||||
</template>
|
</template>
|
||||||
{{ $t("core.common.buttons.preview") }}
|
{{ $t("core.common.buttons.preview") }}
|
||||||
</VButton>
|
</VButton>
|
||||||
<VButton size="sm" type="default" @click="themesModal = true">
|
<VButton type="secondary" @click="themesModal = true">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<IconExchange class="h-full w-full" />
|
<IconListSettings class="h-full w-full" />
|
||||||
</template>
|
</template>
|
||||||
{{ $t("core.theme.actions.management") }}
|
{{ $t("core.theme.actions.management") }}
|
||||||
</VButton>
|
</VButton>
|
||||||
|
@ -271,6 +268,7 @@ onMounted(() => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ThemeListModal v-model:visible="themesModal" @select="onSelectTheme" />
|
||||||
<ThemePreviewModal v-model:visible="previewModal" :theme="selectedTheme" />
|
<ThemePreviewModal v-model:visible="previewModal" :theme="selectedTheme" />
|
||||||
</BasicLayout>
|
</BasicLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
export interface ThemeInstallationErrorResponse {
|
||||||
|
detail: string;
|
||||||
|
instance: string;
|
||||||
|
themeName: string;
|
||||||
|
requestId: string;
|
||||||
|
status: number;
|
||||||
|
timestamp: string;
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
}
|
|
@ -135,7 +135,9 @@ onMounted(() => {
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<VButton @click="handleVisibleChange(false)">关闭</VButton>
|
<VButton @click="handleVisibleChange(false)">
|
||||||
|
{{ $t("core.common.buttons.close") }}
|
||||||
|
</VButton>
|
||||||
</template>
|
</template>
|
||||||
</VModal>
|
</VModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
Loading…
Reference in New Issue