mirror of https://github.com/halo-dev/halo-admin
perf: improve theme preview modal (#683)
#### What type of PR is this? /kind improvement /milestone 2.0 #### What this PR does / why we need it: 重构主题预览弹框,支持选择主题以及针对主题进行设置。 todolist: - [x] 支持保存之后自动刷新预览区域。 - [x] 优化 Tabs 组件,支持横向滚动以解决设置项过多时,选项卡的样式问题。 #### Screenshots: https://user-images.githubusercontent.com/21301288/200233823-fe317efe-536a-47a9-9495-efdde39be7ca.mp4 #### Special notes for your reviewer: 测试方式: 1. 需要先执行 `pnpm build:packages` 2. 进入主题管理,点击右上角的预览即可打开主题预览窗口 #### Does this PR introduce a user-facing change? ```release-note 重构主题预览弹框,支持选择主题以及针对主题进行设置。 ```pull/684/head
parent
bb9124231e
commit
fdf964b18d
|
@ -103,6 +103,9 @@ watch(
|
|||
<div v-if="$slots.header || title" class="modal-header group">
|
||||
<slot name="header">
|
||||
<div class="modal-header-title">{{ title }}</div>
|
||||
<div v-if="$slots.center" class="modal-header-center">
|
||||
<slot name="center"></slot>
|
||||
</div>
|
||||
<div class="modal-header-actions">
|
||||
<slot name="actions"></slot>
|
||||
<span class="bg-gray-50" @click="handleClose()">
|
||||
|
|
|
@ -3,11 +3,14 @@ import { VTabbar } from "./index";
|
|||
|
||||
function initState() {
|
||||
return {
|
||||
activeId: "johnniang",
|
||||
activeId: "general",
|
||||
items: [
|
||||
{ label: "Ryan Wang", id: "ryanwang" },
|
||||
{ label: "JohnNiang", id: "johnniang" },
|
||||
{ label: "guqing", id: "guqing" },
|
||||
{ label: "基本设置", id: "general" },
|
||||
{ label: "文章设置", id: "post" },
|
||||
{ label: "SEO 设置", id: "seo" },
|
||||
{ label: "评论设置", id: "comment" },
|
||||
{ label: "主题路由设置", id: "theme-route" },
|
||||
{ label: "代码注入", id: "code-inject" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
@ -60,18 +60,45 @@ const handleChange = (id: number | string) => {
|
|||
.tabbar-items {
|
||||
@apply flex
|
||||
items-center
|
||||
flex-row;
|
||||
flex-row
|
||||
overflow-x-auto
|
||||
py-0.5;
|
||||
|
||||
&::-webkit-scrollbar-track-piece {
|
||||
background-color: #f8f8f8;
|
||||
-webkit-border-radius: 2em;
|
||||
-moz-border-radius: 2em;
|
||||
border-radius: 2em;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: #ddd;
|
||||
background-clip: padding-box;
|
||||
-webkit-border-radius: 2em;
|
||||
-moz-border-radius: 2em;
|
||||
border-radius: 2em;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background-color: #bbb;
|
||||
}
|
||||
}
|
||||
|
||||
.tabbar-item {
|
||||
@apply flex
|
||||
@apply inline-flex
|
||||
cursor-pointer
|
||||
self-center
|
||||
transition-all
|
||||
text-sm
|
||||
justify-center
|
||||
gap-2
|
||||
h-9;
|
||||
h-9
|
||||
whitespace-nowrap;
|
||||
|
||||
.tabbar-item-label,
|
||||
.tabbar-item-icon {
|
||||
|
@ -126,7 +153,8 @@ const handleChange = (id: number | string) => {
|
|||
}
|
||||
|
||||
&.tabbar-outline {
|
||||
@apply p-1
|
||||
@apply px-1
|
||||
py-0.5
|
||||
bg-gray-100
|
||||
rounded-base;
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@ import IconMotionLine from "~icons/ri/emotion-line";
|
|||
import IconReplyLine from "~icons/ri/reply-line";
|
||||
import IconExternalLinkLine from "~icons/ri/external-link-line";
|
||||
import IconRefreshLine from "~icons/ri/refresh-line";
|
||||
import IconWindowLine from "~icons/ri/window-line";
|
||||
|
||||
export {
|
||||
IconDashboard,
|
||||
|
@ -106,4 +107,5 @@ export {
|
|||
IconReplyLine,
|
||||
IconExternalLinkLine,
|
||||
IconRefreshLine,
|
||||
IconWindowLine,
|
||||
};
|
||||
|
|
|
@ -7,86 +7,113 @@ import {
|
|||
IconPlug,
|
||||
IconUserSettings,
|
||||
IconPalette,
|
||||
IconWindowLine,
|
||||
VCard,
|
||||
IconUserLine,
|
||||
} from "@halo-dev/components";
|
||||
import { inject, markRaw, type Component } from "vue";
|
||||
import { inject, markRaw, ref, type Component } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import type { RouteLocationRaw } from "vue-router";
|
||||
import type { User } from "@halo-dev/api-client";
|
||||
import ThemePreviewModal from "@/modules/interface/themes/components/preview/ThemePreviewModal.vue";
|
||||
|
||||
interface Action {
|
||||
icon: Component;
|
||||
title: string;
|
||||
route: RouteLocationRaw;
|
||||
action: () => void;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
const currentUser = inject<User>("currentUser");
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const themePreviewVisible = ref(false);
|
||||
|
||||
const actions: Action[] = [
|
||||
{
|
||||
icon: markRaw(IconUserLine),
|
||||
title: "个人资料",
|
||||
route: { name: "UserDetail", params: { name: currentUser?.metadata.name } },
|
||||
action: () => {
|
||||
router.push({
|
||||
name: "UserDetail",
|
||||
params: { name: currentUser?.metadata.name },
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: markRaw(IconWindowLine),
|
||||
title: "查看站点",
|
||||
action: () => {
|
||||
themePreviewVisible.value = true;
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: markRaw(IconBookRead),
|
||||
title: "创建文章",
|
||||
route: {
|
||||
name: "PostEditor",
|
||||
action: () => {
|
||||
router.push({
|
||||
name: "PostEditor",
|
||||
});
|
||||
},
|
||||
permissions: ["system:posts:manage"],
|
||||
},
|
||||
{
|
||||
icon: markRaw(IconPages),
|
||||
title: "创建页面",
|
||||
route: {
|
||||
name: "SinglePageEditor",
|
||||
action: () => {
|
||||
router.push({
|
||||
name: "SinglePageEditor",
|
||||
});
|
||||
},
|
||||
permissions: ["system:singlepages:manage"],
|
||||
},
|
||||
{
|
||||
icon: markRaw(IconFolder),
|
||||
title: "附件上传",
|
||||
route: {
|
||||
name: "Attachments",
|
||||
query: {
|
||||
action: "upload",
|
||||
},
|
||||
action: () => {
|
||||
router.push({
|
||||
name: "Attachments",
|
||||
query: {
|
||||
action: "upload",
|
||||
},
|
||||
});
|
||||
},
|
||||
permissions: ["system:attachments:manage"],
|
||||
},
|
||||
{
|
||||
icon: markRaw(IconPalette),
|
||||
title: "主题管理",
|
||||
route: {
|
||||
name: "ThemeDetail",
|
||||
action: () => {
|
||||
router.push({
|
||||
name: "ThemeDetail",
|
||||
});
|
||||
},
|
||||
permissions: ["system:themes:view"],
|
||||
},
|
||||
{
|
||||
icon: markRaw(IconPlug),
|
||||
title: "插件管理",
|
||||
route: {
|
||||
name: "Plugins",
|
||||
action: () => {
|
||||
router.push({
|
||||
name: "Plugins",
|
||||
});
|
||||
},
|
||||
permissions: ["system:plugins:view"],
|
||||
},
|
||||
{
|
||||
icon: markRaw(IconUserSettings),
|
||||
title: "新建用户",
|
||||
route: {
|
||||
name: "Users",
|
||||
query: {
|
||||
action: "create",
|
||||
},
|
||||
action: () => {
|
||||
router.push({
|
||||
name: "Users",
|
||||
query: {
|
||||
action: "create",
|
||||
},
|
||||
});
|
||||
},
|
||||
permissions: ["system:users:manage"],
|
||||
},
|
||||
];
|
||||
|
||||
const router = useRouter();
|
||||
</script>
|
||||
<template>
|
||||
<VCard
|
||||
|
@ -100,7 +127,7 @@ const router = useRouter();
|
|||
:key="index"
|
||||
v-permission="action.permissions"
|
||||
class="group relative cursor-pointer bg-white p-6 transition-all hover:bg-gray-50"
|
||||
@click="router.push(action.route)"
|
||||
@click="action.action"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
|
@ -124,4 +151,5 @@ const router = useRouter();
|
|||
</div>
|
||||
</div>
|
||||
</VCard>
|
||||
<ThemePreviewModal v-model:visible="themePreviewVisible" title="查看站点" />
|
||||
</template>
|
||||
|
|
|
@ -1,175 +0,0 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
IconComputer,
|
||||
IconPhone,
|
||||
IconSave,
|
||||
IconSettings,
|
||||
IconTablet,
|
||||
VButton,
|
||||
VSpace,
|
||||
VTabbar,
|
||||
VTabItem,
|
||||
VTabs,
|
||||
} from "@halo-dev/components";
|
||||
import AttachmentSelectorModal from "@/modules/contents/attachments/components/AttachmentSelectorModal.vue";
|
||||
import { computed, onMounted, ref, shallowRef } from "vue";
|
||||
|
||||
const activeId = ref("general");
|
||||
const deviceActiveId = ref("desktop");
|
||||
const attachmentSelectVisible = ref(false);
|
||||
const settingRootVisible = ref(false);
|
||||
const settingVisible = ref(false);
|
||||
const devices = shallowRef([
|
||||
{
|
||||
id: "desktop",
|
||||
icon: IconComputer,
|
||||
},
|
||||
{
|
||||
id: "tablet",
|
||||
icon: IconTablet,
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
icon: IconPhone,
|
||||
},
|
||||
]);
|
||||
|
||||
const iframeClasses = computed(() => {
|
||||
if (deviceActiveId.value === "desktop") {
|
||||
return "w-full h-full";
|
||||
}
|
||||
if (deviceActiveId.value === "tablet") {
|
||||
return "w-2/3 h-2/3";
|
||||
}
|
||||
// phone
|
||||
return "w-96 h-[50rem]";
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener(
|
||||
"message",
|
||||
function receiveMessageFromIframePage(event) {
|
||||
if (event.data === "select_image") {
|
||||
attachmentSelectVisible.value = true;
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<AttachmentSelectorModal
|
||||
v-model:visible="attachmentSelectVisible"
|
||||
></AttachmentSelectorModal>
|
||||
<div class="flex h-screen">
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="grid h-14 grid-cols-2 items-center bg-white px-4 drop-shadow-sm sm:grid-cols-3"
|
||||
>
|
||||
<div>
|
||||
<h2 class="truncate text-xl font-bold text-gray-800">
|
||||
<span>Anatole</span>
|
||||
</h2>
|
||||
</div>
|
||||
<div class="hidden justify-center sm:flex">
|
||||
<VTabbar
|
||||
v-model:active-id="deviceActiveId"
|
||||
:items="devices"
|
||||
type="outline"
|
||||
></VTabbar>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<VSpace>
|
||||
<VButton size="sm" type="default" @click="settingVisible = true">
|
||||
<template #icon>
|
||||
<IconSettings class="h-full w-full" />
|
||||
</template>
|
||||
设置
|
||||
</VButton>
|
||||
<VButton type="secondary">
|
||||
<template #icon>
|
||||
<IconSave class="h-full w-full" />
|
||||
</template>
|
||||
保存
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-full items-center justify-center"
|
||||
style="height: calc(100vh - 4rem)"
|
||||
>
|
||||
<iframe
|
||||
:class="iframeClasses"
|
||||
class="border-none transition-all duration-300"
|
||||
src="http://localhost:8090"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-show="settingRootVisible"
|
||||
class="drawer-wrapper fixed top-0 left-0 z-[99999] flex h-full w-full flex-row items-end justify-center"
|
||||
>
|
||||
<transition
|
||||
enter-active-class="ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="ease-in duration-100"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
@before-enter="settingRootVisible = true"
|
||||
@after-leave="settingRootVisible = false"
|
||||
>
|
||||
<div
|
||||
v-show="settingVisible"
|
||||
class="drawer-layer absolute top-0 left-0 h-full w-full flex-none bg-gray-500 bg-opacity-75 transition-opacity"
|
||||
@click="settingVisible = false"
|
||||
></div>
|
||||
</transition>
|
||||
<transition
|
||||
enter-active-class="transform transition ease-in-out duration-300"
|
||||
enter-from-class="translate-y-full"
|
||||
enter-to-class="translate-y-0"
|
||||
leave-active-class="transform transition ease-in-out duration-300"
|
||||
leave-from-class="translate-y-0"
|
||||
leave-to-class="translate-y-full"
|
||||
>
|
||||
<div
|
||||
v-show="settingVisible"
|
||||
class="drawer-content relative flex h-3/4 w-screen flex-col items-stretch overflow-y-auto rounded-t-md bg-white shadow-xl"
|
||||
>
|
||||
<div class="drawer-body">
|
||||
<div class="h-full w-full overflow-y-auto bg-white drop-shadow-sm">
|
||||
<VTabs v-model:active-id="activeId" type="outline">
|
||||
<VTabItem id="general" class="p-3" label="基础设置">
|
||||
<FormKit :actions="false" type="form">
|
||||
<FormKit label="Halo 当前版本" type="text"></FormKit>
|
||||
<FormKit label="首页图片" type="text"></FormKit>
|
||||
</FormKit>
|
||||
</VTabItem>
|
||||
<VTabItem id="style" class="p-3" label="样式设置">
|
||||
<FormKit :actions="false" type="form">
|
||||
<FormKit label="文章代码高亮语言" type="text"></FormKit>
|
||||
<FormKit
|
||||
:options="[
|
||||
{ label: 'Java', value: 'java' },
|
||||
{ label: 'C', value: 'c' },
|
||||
{ label: 'Go', value: 'go' },
|
||||
{ label: 'JavaScript', value: 'javascript' },
|
||||
]"
|
||||
label="文章代码高亮主题"
|
||||
type="select"
|
||||
></FormKit>
|
||||
</FormKit>
|
||||
</VTabItem>
|
||||
</VTabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
|
@ -2,8 +2,6 @@
|
|||
import {
|
||||
IconAddCircle,
|
||||
IconGitHub,
|
||||
IconArrowLeft,
|
||||
IconArrowRight,
|
||||
Dialog,
|
||||
VButton,
|
||||
VEmpty,
|
||||
|
@ -17,7 +15,7 @@ import {
|
|||
VTabs,
|
||||
} from "@halo-dev/components";
|
||||
import LazyImage from "@/components/image/LazyImage.vue";
|
||||
import UrlPreviewModal from "@/components/preview/UrlPreviewModal.vue";
|
||||
import ThemePreviewModal from "./preview/ThemePreviewModal.vue";
|
||||
import ThemeUploadModal from "./ThemeUploadModal.vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import type { Theme } from "@halo-dev/api-client";
|
||||
|
@ -32,18 +30,18 @@ const { currentUserHasPermission } = usePermission();
|
|||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
selectedTheme: Theme | null;
|
||||
selectedTheme?: Theme;
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
selectedTheme: null,
|
||||
selectedTheme: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:visible", visible: boolean): void;
|
||||
(event: "close"): void;
|
||||
(event: "update:selectedTheme", theme: Theme | null): void;
|
||||
(event: "update:selectedTheme", theme?: Theme): void;
|
||||
(event: "select", theme: Theme | null): void;
|
||||
}>();
|
||||
|
||||
|
@ -193,48 +191,12 @@ defineExpose({
|
|||
handleFetchThemes,
|
||||
});
|
||||
|
||||
const preview = ref(false);
|
||||
const previewVisible = ref(false);
|
||||
const selectedPreviewTheme = ref<Theme>();
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (!selectedPreviewTheme.value) {
|
||||
return "";
|
||||
}
|
||||
return `${import.meta.env.VITE_API_URL}/?preview-theme=${
|
||||
selectedPreviewTheme.value.metadata.name
|
||||
}`;
|
||||
});
|
||||
|
||||
const previewModalTitle = computed(() => {
|
||||
if (!selectedPreviewTheme.value) {
|
||||
return "";
|
||||
}
|
||||
return `预览主题:${selectedPreviewTheme.value.spec.displayName}`;
|
||||
});
|
||||
|
||||
const handleOpenPreview = (theme: Theme) => {
|
||||
selectedPreviewTheme.value = theme;
|
||||
preview.value = true;
|
||||
};
|
||||
|
||||
const handleSelectPreviousPreviewTheme = async () => {
|
||||
const index = themes.value.findIndex(
|
||||
(theme) => theme.metadata.name === selectedPreviewTheme.value?.metadata.name
|
||||
);
|
||||
if (index > 0) {
|
||||
selectedPreviewTheme.value = themes.value[index - 1];
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectNextPreviewTheme = () => {
|
||||
const index = themes.value.findIndex(
|
||||
(theme) => theme.metadata.name === selectedPreviewTheme.value?.metadata.name
|
||||
);
|
||||
if (index < themes.value.length - 1) {
|
||||
selectedPreviewTheme.value = themes.value[index + 1];
|
||||
return;
|
||||
}
|
||||
previewVisible.value = true;
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
|
@ -526,18 +488,9 @@ const handleSelectNextPreviewTheme = () => {
|
|||
@close="handleFetchThemes"
|
||||
/>
|
||||
|
||||
<UrlPreviewModal
|
||||
v-model:visible="preview"
|
||||
:title="previewModalTitle"
|
||||
:url="previewUrl"
|
||||
>
|
||||
<template #actions>
|
||||
<span @click="handleSelectPreviousPreviewTheme">
|
||||
<IconArrowLeft />
|
||||
</span>
|
||||
<span @click="handleSelectNextPreviewTheme">
|
||||
<IconArrowRight />
|
||||
</span>
|
||||
</template>
|
||||
</UrlPreviewModal>
|
||||
<ThemePreviewModal
|
||||
v-if="visible"
|
||||
v-model:visible="previewVisible"
|
||||
:theme="selectedPreviewTheme"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
<script lang="ts" setup>
|
||||
import LazyImage from "@/components/image/LazyImage.vue";
|
||||
import type { Theme } from "@halo-dev/api-client";
|
||||
import { VEntity, VEntityField, VTag, VButton } from "@halo-dev/components";
|
||||
import { toRefs } from "vue";
|
||||
import { useThemeLifeCycle } from "../../composables/use-theme";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
theme: Theme;
|
||||
isSelected?: boolean;
|
||||
}>(),
|
||||
{
|
||||
isSelected: false,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "open-settings"): void;
|
||||
}>();
|
||||
|
||||
const { theme } = toRefs(props);
|
||||
|
||||
const { isActivated, handleActiveTheme } = useThemeLifeCycle(theme);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VEntity :is-selected="isSelected">
|
||||
<template #start>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<div class="w-32">
|
||||
<div
|
||||
class="group aspect-w-4 aspect-h-3 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">加载中...</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #error>
|
||||
<div
|
||||
class="flex h-full items-center justify-center object-cover"
|
||||
>
|
||||
<span class="text-xs text-red-400">加载异常</span>
|
||||
</div>
|
||||
</template>
|
||||
</LazyImage>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField
|
||||
:title="theme.spec.displayName"
|
||||
:description="theme.spec.version"
|
||||
>
|
||||
<template #extra>
|
||||
<VTag v-if="isActivated"> 当前启用 </VTag>
|
||||
</template>
|
||||
</VEntityField>
|
||||
</template>
|
||||
|
||||
<template #dropdownItems>
|
||||
<VButton v-close-popper block type="secondary" @click="handleActiveTheme">
|
||||
启用
|
||||
</VButton>
|
||||
<VButton
|
||||
v-close-popper
|
||||
block
|
||||
type="default"
|
||||
@click="emit('open-settings')"
|
||||
>
|
||||
设置
|
||||
</VButton>
|
||||
</template>
|
||||
</VEntity>
|
||||
</template>
|
|
@ -0,0 +1,383 @@
|
|||
<script lang="ts" setup>
|
||||
import ThemeListItem from "./ThemeListItem.vue";
|
||||
import { useSettingForm } from "@/composables/use-setting-form";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import type { FormKitSchemaCondition, FormKitSchemaNode } from "@formkit/core";
|
||||
import type { SettingForm, Theme } from "@halo-dev/api-client";
|
||||
import {
|
||||
VModal,
|
||||
IconLink,
|
||||
IconPalette,
|
||||
IconSettings,
|
||||
IconArrowLeft,
|
||||
VTabbar,
|
||||
VButton,
|
||||
IconComputer,
|
||||
IconPhone,
|
||||
IconTablet,
|
||||
IconRefreshLine,
|
||||
} from "@halo-dev/components";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { computed, markRaw, ref, watch } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
theme?: Theme;
|
||||
}>(),
|
||||
{
|
||||
visible: false,
|
||||
title: undefined,
|
||||
theme: undefined,
|
||||
}
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "update:visible", visible: boolean): void;
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
interface SettingTab {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const { activatedTheme } = storeToRefs(useThemeStore());
|
||||
|
||||
const previewFrame = ref<HTMLIFrameElement | null>(null);
|
||||
const themes = ref<Theme[]>([] as Theme[]);
|
||||
const themesVisible = ref(false);
|
||||
const switching = ref(false);
|
||||
const selectedTheme = ref<Theme>();
|
||||
|
||||
const handleFetchThemes = async () => {
|
||||
try {
|
||||
const { data } = await apiClient.theme.listThemes({
|
||||
page: 0,
|
||||
size: 0,
|
||||
uninstalled: false,
|
||||
});
|
||||
themes.value = data.items;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch themes", e);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
handleFetchThemes();
|
||||
selectedTheme.value = props.theme || activatedTheme?.value;
|
||||
} else {
|
||||
themesVisible.value = false;
|
||||
settingsVisible.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const onVisibleChange = (visible: boolean) => {
|
||||
emit("update:visible", visible);
|
||||
if (!visible) {
|
||||
emit("close");
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenThemes = () => {
|
||||
settingsVisible.value = false;
|
||||
themesVisible.value = !themesVisible.value;
|
||||
};
|
||||
|
||||
const handleSelect = (theme: Theme) => {
|
||||
selectedTheme.value = theme;
|
||||
};
|
||||
|
||||
const previewUrl = computed(() => {
|
||||
if (!selectedTheme.value) {
|
||||
return "#";
|
||||
}
|
||||
return `${import.meta.env.VITE_API_URL}/?preview-theme=${
|
||||
selectedTheme.value.metadata.name
|
||||
}`;
|
||||
});
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (props.title) {
|
||||
return props.title;
|
||||
}
|
||||
return `预览主题:${selectedTheme.value?.spec.displayName}`;
|
||||
});
|
||||
|
||||
// theme settings
|
||||
const settingTabs = ref<SettingTab[]>([] as SettingTab[]);
|
||||
const activeSettingTab = ref("");
|
||||
const settingsVisible = ref(false);
|
||||
|
||||
const settingName = computed(() => selectedTheme.value?.spec.settingName);
|
||||
const configMapName = computed(() => selectedTheme.value?.spec.configMapName);
|
||||
|
||||
const {
|
||||
setting,
|
||||
configMapFormData,
|
||||
saving,
|
||||
handleFetchConfigMap,
|
||||
handleFetchSettings,
|
||||
handleSaveConfigMap,
|
||||
} = useSettingForm(settingName, configMapName);
|
||||
|
||||
watch(
|
||||
() => selectedTheme.value,
|
||||
async () => {
|
||||
if (selectedTheme.value) {
|
||||
await handleFetchSettings();
|
||||
await handleFetchConfigMap();
|
||||
|
||||
if (setting.value) {
|
||||
const { forms } = setting.value.spec;
|
||||
settingTabs.value = forms.map((item: SettingForm) => {
|
||||
return {
|
||||
id: item.group,
|
||||
label: item.label || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
activeSettingTab.value = settingTabs.value[0].id;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const handleOpenSettings = (theme?: Theme) => {
|
||||
if (theme) {
|
||||
selectedTheme.value = theme;
|
||||
}
|
||||
themesVisible.value = false;
|
||||
settingsVisible.value = !settingsVisible.value;
|
||||
};
|
||||
|
||||
const formSchema = computed(() => {
|
||||
if (!setting.value) {
|
||||
return;
|
||||
}
|
||||
const { forms } = setting.value.spec;
|
||||
return forms.find((item) => item.group === activeSettingTab.value)
|
||||
?.formSchema as (FormKitSchemaCondition | FormKitSchemaNode)[];
|
||||
});
|
||||
|
||||
const handleSaveThemeConfigMap = async () => {
|
||||
await handleSaveConfigMap();
|
||||
handleRefresh();
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
previewFrame.value?.contentWindow?.location.reload();
|
||||
};
|
||||
|
||||
// mock devices
|
||||
const mockDevices = [
|
||||
{
|
||||
id: "desktop",
|
||||
icon: markRaw(IconComputer),
|
||||
},
|
||||
{
|
||||
id: "tablet",
|
||||
icon: markRaw(IconTablet),
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
icon: markRaw(IconPhone),
|
||||
},
|
||||
];
|
||||
|
||||
const deviceActiveId = ref(mockDevices[0].id);
|
||||
|
||||
const iframeClasses = computed(() => {
|
||||
if (deviceActiveId.value === "desktop") {
|
||||
return "w-full h-full";
|
||||
}
|
||||
if (deviceActiveId.value === "tablet") {
|
||||
return "w-2/3 h-2/3 ring-2 rounded ring-gray-300";
|
||||
}
|
||||
return "w-96 h-[50rem] ring-2 rounded ring-gray-300";
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<VModal
|
||||
:body-class="['!p-0']"
|
||||
:visible="visible"
|
||||
fullscreen
|
||||
:title="modalTitle"
|
||||
:mount-to-body="true"
|
||||
@update:visible="onVisibleChange"
|
||||
>
|
||||
<template #center>
|
||||
<VTabbar
|
||||
v-model:active-id="deviceActiveId"
|
||||
:items="mockDevices"
|
||||
type="outline"
|
||||
></VTabbar>
|
||||
</template>
|
||||
<template #actions>
|
||||
<span
|
||||
v-tooltip="{ content: '切换主题', delay: 300 }"
|
||||
:class="{ 'bg-gray-200': themesVisible }"
|
||||
@click="handleOpenThemes"
|
||||
>
|
||||
<IconPalette />
|
||||
</span>
|
||||
<span
|
||||
v-tooltip="{ content: '主题设置', delay: 300 }"
|
||||
:class="{ 'bg-gray-200': settingsVisible }"
|
||||
@click="handleOpenSettings(undefined)"
|
||||
>
|
||||
<IconSettings />
|
||||
</span>
|
||||
<span v-tooltip="{ content: '刷新', delay: 300 }" @click="handleRefresh">
|
||||
<IconRefreshLine />
|
||||
</span>
|
||||
<span v-tooltip="{ content: '新窗口打开', delay: 300 }">
|
||||
<a :href="previewUrl" target="_blank">
|
||||
<IconLink />
|
||||
</a>
|
||||
</span>
|
||||
</template>
|
||||
<div
|
||||
class="flex h-full items-center justify-center divide-x divide-gray-100 transition-all"
|
||||
>
|
||||
<transition
|
||||
enter-active-class="transform transition ease-in-out duration-300"
|
||||
enter-from-class="-translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transform transition ease-in-out duration-300"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="-translate-x-full"
|
||||
appear
|
||||
>
|
||||
<div
|
||||
v-if="themesVisible || settingsVisible"
|
||||
class="relative h-full w-96 overflow-y-auto"
|
||||
:class="{ '!overflow-hidden': switching }"
|
||||
style="overflow-y: overlay"
|
||||
>
|
||||
<transition
|
||||
enter-active-class="transform transition ease-in-out duration-300 delay-150"
|
||||
enter-from-class="translate-x-full"
|
||||
enter-to-class="-translate-x-0"
|
||||
leave-active-class="transform transition ease-in-out duration-300"
|
||||
leave-from-class="-translate-x-0"
|
||||
leave-to-class="translate-x-full"
|
||||
@before-enter="switching = true"
|
||||
@after-enter="switching = false"
|
||||
@before-leave="switching = true"
|
||||
@after-leave="switching = false"
|
||||
>
|
||||
<div v-show="settingsVisible" class="mb-20">
|
||||
<VTabbar
|
||||
v-model:active-id="activeSettingTab"
|
||||
:items="settingTabs"
|
||||
class="w-full !rounded-none"
|
||||
type="outline"
|
||||
></VTabbar>
|
||||
<div class="bg-white p-3">
|
||||
<div v-for="(tab, index) in settingTabs" :key="index">
|
||||
<FormKit
|
||||
v-if="
|
||||
tab.id === activeSettingTab &&
|
||||
configMapFormData &&
|
||||
formSchema
|
||||
"
|
||||
:id="tab.id"
|
||||
:key="tab.id"
|
||||
v-model="configMapFormData[tab.id]"
|
||||
:name="tab.id"
|
||||
:actions="false"
|
||||
:preserve="true"
|
||||
type="form"
|
||||
@submit="handleSaveThemeConfigMap"
|
||||
>
|
||||
<FormKitSchema :schema="formSchema" />
|
||||
</FormKit>
|
||||
</div>
|
||||
<div v-permission="['system:configmaps:manage']" class="pt-5">
|
||||
<div class="flex justify-start">
|
||||
<VButton
|
||||
:loading="saving"
|
||||
type="secondary"
|
||||
@click="$formkit.submit(activeSettingTab || '')"
|
||||
>
|
||||
保存
|
||||
</VButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
<transition
|
||||
enter-active-class="transform transition ease-in-out duration-300 delay-150"
|
||||
enter-from-class="-translate-x-full"
|
||||
enter-to-class="translate-x-0"
|
||||
leave-active-class="transform transition ease-in-out duration-300"
|
||||
leave-from-class="translate-x-0"
|
||||
leave-to-class="-translate-x-full"
|
||||
@before-enter="switching = true"
|
||||
@after-enter="switching = false"
|
||||
@before-leave="switching = true"
|
||||
@after-leave="switching = false"
|
||||
>
|
||||
<ul
|
||||
v-show="themesVisible"
|
||||
class="box-border h-full w-full divide-y divide-gray-100"
|
||||
role="list"
|
||||
>
|
||||
<li
|
||||
v-for="(item, index) in themes"
|
||||
:key="index"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<ThemeListItem
|
||||
:theme="item"
|
||||
:is-selected="
|
||||
selectedTheme?.metadata.name === item.metadata.name
|
||||
"
|
||||
@open-settings="handleOpenSettings(item)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</transition>
|
||||
<transition
|
||||
enter-active-class="transform transition ease-in-out duration-300"
|
||||
enter-from-class="translate-y-full"
|
||||
enter-to-class="translate-y-0"
|
||||
leave-active-class="transform transition ease-in-out duration-300"
|
||||
leave-from-class="translate-y-0"
|
||||
leave-to-class="translate-y-full"
|
||||
>
|
||||
<div v-if="settingsVisible" class="fixed bottom-2 left-2">
|
||||
<VButton
|
||||
size="md"
|
||||
circle
|
||||
type="primary"
|
||||
@click="handleOpenThemes"
|
||||
>
|
||||
<IconArrowLeft />
|
||||
</VButton>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
<div
|
||||
class="flex h-full flex-1 items-center justify-center transition-all duration-300"
|
||||
>
|
||||
<iframe
|
||||
v-if="visible"
|
||||
ref="previewFrame"
|
||||
class="border-none transition-all duration-500"
|
||||
:class="iframeClasses"
|
||||
:src="previewUrl"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</VModal>
|
||||
</template>
|
|
@ -26,6 +26,7 @@ import {
|
|||
VTabbar,
|
||||
} from "@halo-dev/components";
|
||||
import ThemeListModal from "../components/ThemeListModal.vue";
|
||||
import ThemePreviewModal from "../components/preview/ThemePreviewModal.vue";
|
||||
import type { SettingForm, Theme } from "@halo-dev/api-client";
|
||||
import { usePermission } from "@/utils/permission";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
|
@ -53,8 +54,9 @@ const initialTabs: ThemeTab[] = [
|
|||
];
|
||||
|
||||
const tabs = ref<ThemeTab[]>(cloneDeep(initialTabs));
|
||||
const selectedTheme = ref<Theme | undefined>();
|
||||
const selectedTheme = ref<Theme>();
|
||||
const themesModal = ref(false);
|
||||
const previewModal = ref(false);
|
||||
const activeTab = ref("");
|
||||
|
||||
const { loading, isActivated, handleActiveTheme } =
|
||||
|
@ -181,15 +183,11 @@ onMounted(() => {
|
|||
>
|
||||
启用
|
||||
</VButton>
|
||||
<VButton
|
||||
v-if="false"
|
||||
:route="{ name: 'ThemeVisual' }"
|
||||
type="secondary"
|
||||
>
|
||||
<VButton type="secondary" size="sm" @click="previewModal = true">
|
||||
<template #icon>
|
||||
<IconEye class="h-full w-full" />
|
||||
</template>
|
||||
可视化编辑
|
||||
预览
|
||||
</VButton>
|
||||
</VSpace>
|
||||
</template>
|
||||
|
@ -242,5 +240,7 @@ onMounted(() => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ThemePreviewModal v-model:visible="previewModal" :theme="selectedTheme" />
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { definePlugin } from "@halo-dev/console-shared";
|
||||
import BlankLayout from "@/layouts/BlankLayout.vue";
|
||||
import ThemeLayout from "./layouts/ThemeLayout.vue";
|
||||
import ThemeDetail from "./ThemeDetail.vue";
|
||||
import ThemeSetting from "./ThemeSetting.vue";
|
||||
import Visual from "./Visual.vue";
|
||||
import { IconPalette } from "@halo-dev/components";
|
||||
import { markRaw } from "vue";
|
||||
|
||||
|
@ -42,19 +40,5 @@ export default definePlugin({
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/theme/visual",
|
||||
component: BlankLayout,
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "ThemeVisual",
|
||||
component: Visual,
|
||||
meta: {
|
||||
title: "可视化编辑",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue