feat: add sticky block component (#4919)

#### What type of PR is this?

/kind feature
/area console
/milestone 2.11.x

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

添加 `<StickyBlock />` 组件,用于将元素固定在顶部或者底部。

此外,此 PR 针对主题设置、插件设置、系统设置等表单可能较长的页面使用了此组件。

<img width="1214" alt="图片" src="https://github.com/halo-dev/halo/assets/21301288/abc849eb-a9a9-4d0a-b81c-d7430815660d">


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

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

#### Special notes for your reviewer:

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

```release-note
添加 `<StickyBlock />` 组件,用于将元素固定在顶部或者底部,并为主题 / 插件 / 系统设置的底部保存按钮区域做了适配。
```
pull/4924/head
Ryan Wang 2023-11-27 17:38:08 +08:00 committed by GitHub
parent 0102f7a227
commit 925f8d0ea4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 129 additions and 63 deletions

View File

@ -14,6 +14,7 @@ import { apiClient } from "@/utils/api-client";
import { useSettingFormConvert } from "@console/composables/use-setting-form";
import { useI18n } from "vue-i18n";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import StickyBlock from "@/components/sticky-block/StickyBlock.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
@ -86,17 +87,19 @@ await suspense();
:data="configMapFormData[group]"
/>
</FormKit>
<div v-permission="['system:themes:manage']" class="pt-5">
<div class="flex justify-start">
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit(group || '')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>
<StickyBlock
v-permission="['system:themes:manage']"
class="-mx-4 -mb-4 rounded-b-base rounded-t-lg bg-white p-4 pt-5"
position="bottom"
>
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit(group || '')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
</StickyBlock>
</div>
</Transition>
</template>

View File

@ -14,6 +14,7 @@ import type { ConfigMap, Plugin, Setting } from "@halo-dev/api-client";
import { useI18n } from "vue-i18n";
import { useQuery, useQueryClient } from "@tanstack/vue-query";
import { toRaw } from "vue";
import StickyBlock from "@/components/sticky-block/StickyBlock.vue";
const { t } = useI18n();
const queryClient = useQueryClient();
@ -82,17 +83,20 @@ const handleSaveConfigMap = async () => {
/>
</FormKit>
</div>
<div v-permission="['system:plugins:manage']" class="pt-5">
<div class="flex justify-start">
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit(group || '')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>
<StickyBlock
v-permission="['system:plugins:manage']"
class="-mx-4 -mb-4 rounded-b-base rounded-t-lg bg-white p-4 pt-5"
position="bottom"
>
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit(group || '')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
</StickyBlock>
</div>
</Transition>
</template>

View File

@ -82,7 +82,7 @@ provide<Ref<Setting | undefined>>("setting", setting);
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<VCard :body-class="['!p-0', '!overflow-visible']">
<template #header>
<VTabbar
v-model:active-id="activeTab"
@ -91,7 +91,7 @@ provide<Ref<Setting | undefined>>("setting", setting);
type="outline"
></VTabbar>
</template>
<div class="bg-white">
<div class="rounded-b-base bg-white">
<template v-for="tab in tabs" :key="tab.id">
<component :is="tab.component" v-if="activeTab === tab.id" />
</template>

View File

@ -10,6 +10,7 @@ import { computed } from "vue";
import { toRaw } from "vue";
import type { FormKitSchemaCondition, FormKitSchemaNode } from "@formkit/core";
import { useI18n } from "vue-i18n";
import StickyBlock from "@/components/sticky-block/StickyBlock.vue";
const queryClient = useQueryClient();
const { t } = useI18n();
@ -86,17 +87,20 @@ const { isLoading: isMutating, mutate } = useMutation({
>
<FormKitSchema :schema="toRaw(formSchema)" :data="configMapData" />
</FormKit>
<div class="pt-5">
<div class="flex justify-start">
<VButton
:loading="isMutating"
type="secondary"
@click="$formkit.submit(name)"
>
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>
<StickyBlock
v-permission="['system:configmaps:manage']"
class="-mx-4 -mb-4 rounded-b-base rounded-t-lg bg-white p-4 pt-5"
position="bottom"
>
<VButton
:loading="isMutating"
type="secondary"
@click="$formkit.submit(name)"
>
{{ $t("core.common.buttons.save") }}
</VButton>
</StickyBlock>
</div>
</Transition>
</template>

View File

@ -4,6 +4,7 @@ import { computed, ref, type Ref, inject, toRaw } from "vue";
// components
import { Toast, VButton } from "@halo-dev/components";
import StickyBlock from "@/components/sticky-block/StickyBlock.vue";
// hooks
import { useSettingFormConvert } from "@console/composables/use-setting-form";
@ -67,35 +68,36 @@ const handleSaveConfigMap = async () => {
</script>
<template>
<Transition mode="out-in" name="fade">
<div class="bg-white p-4">
<div>
<FormKit
v-if="group && formSchema && configMapFormData?.[group]"
:id="group"
v-model="configMapFormData[group]"
:name="group"
:actions="false"
:preserve="true"
type="form"
@submit="handleSaveConfigMap"
<div class="p-4">
<FormKit
v-if="group && formSchema && configMapFormData?.[group]"
:id="group"
v-model="configMapFormData[group]"
:name="group"
:actions="false"
:preserve="true"
type="form"
@submit="handleSaveConfigMap"
>
<FormKitSchema
:schema="toRaw(formSchema)"
:data="configMapFormData[group]"
/>
</FormKit>
<StickyBlock
v-permission="['system:configmaps:manage']"
class="-mx-4 -mb-4 rounded-b-base rounded-t-lg bg-white p-4 pt-5"
position="bottom"
>
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit(group || '')"
>
<FormKitSchema
:schema="toRaw(formSchema)"
:data="configMapFormData[group]"
/>
</FormKit>
</div>
<div v-permission="['system:configmaps:manage']" class="pt-5">
<div class="flex justify-start">
<VButton
:loading="saving"
type="secondary"
@click="$formkit.submit(group || '')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
</div>
</div>
{{ $t("core.common.buttons.save") }}
</VButton>
</StickyBlock>
</div>
</Transition>
</template>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { useEventListener } from "@vueuse/core";
import { ref, onMounted } from "vue";
const props = withDefaults(
defineProps<{
position?: "top" | "bottom";
}>(),
{
position: "top",
}
);
const stickyBlock = ref<HTMLElement | null>(null);
const isSticky = ref(false);
function computeSticky() {
if (!stickyBlock.value) return;
const rect = stickyBlock.value?.getBoundingClientRect();
if (props.position === "top") {
isSticky.value = rect.top <= 0;
} else {
isSticky.value = rect.bottom >= window.innerHeight;
}
}
onMounted(() => {
computeSticky();
});
useEventListener("scroll", computeSticky);
</script>
<template>
<div
ref="stickyBlock"
:class="{ 'sticky-element': true, 'sticky-shadow': isSticky }"
>
<slot />
</div>
</template>
<style>
.sticky-element {
position: sticky;
bottom: 0;
}
.sticky-shadow {
box-shadow: 0px -5px 10px -5px rgba(0, 0, 0, 0.1);
}
</style>