feat: add stack widget for dashboard (#7525)

#### What type of PR is this?

/area ui
/kind feautre
/milestone 2.21.x

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

This PR adds a core Dashboard Widget called Stack Widget, which is used to stack a batch of components in the same location and supports manual or automatic switching.

<img width="737" alt="image" src="https://github.com/user-attachments/assets/de448cd8-cf62-4608-8523-88395298e734" />
<img width="827" alt="image" src="https://github.com/user-attachments/assets/fecaf637-9cb8-444a-888b-0ee2a9700bc7" />

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

Fixes #

#### Special notes for your reviewer:

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

```release-note
为仪表盘添加堆叠小部件
```
pull/7527/head
Ryan Wang 2025-06-09 23:08:34 +08:00 committed by GitHub
parent 3fa6532d9b
commit 315073406f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 623 additions and 11 deletions

View File

@ -0,0 +1,8 @@
<script setup lang="ts"></script>
<template>
<div
class="h-full text-base w-8 flex cursor-pointer items-center justify-center hover:opacity-80 text-white transition-all"
>
<slot />
</div>
</template>

View File

@ -6,6 +6,7 @@ import type {
DashboardWidgetDefinition,
} from "@halo-dev/console-shared";
import { computed, inject, ref, type ComputedRef } from "vue";
import ActionButton from "./ActionButton.vue";
import WidgetConfigFormModal from "./WidgetConfigFormModal.vue";
const props = defineProps<{
@ -61,21 +62,18 @@ function handleSaveConfig(config: Record<string, unknown>) {
@update:config="handleSaveConfig"
/>
<div
class="absolute hidden h-8 right-0 top-0 rounded-tr-lg bg-gray-100 overflow-hidden group-hover/grid-item:inline-flex items-center"
class="absolute z-[100] hidden h-8 right-0 top-0 rounded-tr-lg bg-gray-100 overflow-hidden group-hover/grid-item:inline-flex items-center"
>
<div
<ActionButton
v-if="widgetDefinition?.configFormKitSchema?.length"
class="h-full w-8 flex cursor-pointer items-center justify-center bg-black hover:bg-gray-800 text-white"
class="bg-black"
@click="configModalVisible = true"
>
<IconSettings class="text-base" />
</div>
<div
class="h-full w-8 flex cursor-pointer items-center justify-center bg-red-500 hover:bg-red-600 text-white"
@click="emit('remove')"
>
<IconCloseCircle class="text-base" />
</div>
<IconSettings />
</ActionButton>
<ActionButton class="bg-red-500" @click="emit('remove')">
<IconCloseCircle />
</ActionButton>
</div>
</grid-item>
<WidgetConfigFormModal

View File

@ -67,6 +67,7 @@ const groupWidgetDefinitionsKeys = computed(() => {
:width="1380"
:layer-closable="true"
:title="$t('core.dashboard_designer.widgets_modal.title')"
mount-to-body
@close="emit('close')"
>
<VTabbar

View File

@ -3,6 +3,7 @@ import { markRaw } from "vue";
import CommentStatsWidget from "./presets/comments/CommentStatsWidget.vue";
import PendingCommentsWidget from "./presets/comments/PendingCommentsWidget.vue";
import QuickActionWidget from "./presets/core/quick-action/QuickActionWidget.vue";
import StackWidget from "./presets/core/stack/StackWidget.vue";
import ViewsStatsWidget from "./presets/core/view-stats/ViewsStatsWidget.vue";
import PostStatsWidget from "./presets/posts/PostStatsWidget.vue";
import RecentPublishedWidget from "./presets/posts/RecentPublishedWidget.vue";
@ -182,4 +183,16 @@ export const internalWidgetDefinitions: DashboardWidgetDefinition[] = [
minW: 3,
},
},
{
id: "core:stack",
component: markRaw(StackWidget),
group: "core.dashboard.widgets.groups.other",
defaultConfig: {},
defaultSize: {
w: 6,
h: 12,
minH: 1,
minW: 1,
},
},
];

View File

@ -0,0 +1,144 @@
<script lang="ts" setup>
import WidgetCard from "@console/modules/dashboard/components/WidgetCard.vue";
import { IconSettings, VButton } from "@halo-dev/components";
import { nextTick, onMounted, onUnmounted, ref } from "vue";
import StackWidgetConfigModal from "./StackWidgetConfigModal.vue";
import IndexIndicator from "./components/IndexIndicator.vue";
import WidgetViewItem from "./components/WidgetViewItem.vue";
import type { StackWidgetConfig } from "./types";
const props = defineProps<{
config: StackWidgetConfig;
editMode?: boolean;
previewMode?: boolean;
}>();
const emit = defineEmits<{
(e: "update:config", config: StackWidgetConfig): void;
}>();
const configVisible = ref(false);
const index = ref(0);
function handleNavigate(direction: -1 | 1) {
const targetIndex = index.value + direction;
if (targetIndex < 0) {
index.value = props.config.widgets.length - 1;
} else if (targetIndex >= props.config.widgets.length) {
index.value = 0;
} else {
index.value = targetIndex;
}
}
// auto play
const autoPlayInterval = ref<NodeJS.Timeout | null>(null);
function startAutoPlay() {
if (!props.config.auto_play || configVisible.value) {
return;
}
if (autoPlayInterval.value) {
clearInterval(autoPlayInterval.value);
}
autoPlayInterval.value = setInterval(() => {
handleNavigate(1);
}, props.config.auto_play_interval || 3000);
}
function stopAutoPlay() {
if (autoPlayInterval.value) {
clearInterval(autoPlayInterval.value);
autoPlayInterval.value = null;
}
}
onMounted(() => {
startAutoPlay();
});
onUnmounted(() => {
stopAutoPlay();
});
async function handleSave(config: StackWidgetConfig) {
emit("update:config", config);
configVisible.value = false;
await nextTick();
if (config.auto_play) {
startAutoPlay();
} else {
stopAutoPlay();
}
}
</script>
<template>
<WidgetCard
v-if="!config.widgets?.length"
:title="$t('core.dashboard.widgets.presets.stack.title')"
>
<div class="flex items-center justify-center w-full h-full">
<VButton @click="configVisible = true">
{{
$t(
"core.dashboard.widgets.presets.stack.operations.add_widget.button"
)
}}
</VButton>
</div>
</WidgetCard>
<div
v-else
class="bg-white w-full h-full rounded-lg relative group/stack-item overflow-hidden"
@mouseenter="stopAutoPlay"
@mouseleave="startAutoPlay"
>
<div
v-if="editMode || previewMode"
class="flex absolute z-10 bg-white inset-0 rounded-t-lg flex-none justify-between h-10 items-center px-4 border-b border-[#eaecf0]"
>
<div class="inline-flex items-center gap-2">
<div class="text-base font-medium flex-1 shrink">
{{ $t("core.dashboard.widgets.presets.stack.title") }}
</div>
<IconSettings
v-if="editMode"
class="hover:text-gray-600 cursor-pointer"
@click="configVisible = true"
/>
</div>
</div>
<TransitionGroup name="fade">
<WidgetViewItem
v-for="(widget, i) in config.widgets"
v-show="index === i"
:key="widget.i"
:item="widget"
/>
</TransitionGroup>
<div
class="absolute bottom-2 left-0 right-0 z-10 flex justify-center group-hover/stack-item:opacity-100 opacity-0 transition-all duration-200"
>
<IndexIndicator
:index="index"
:total="config.widgets.length"
@prev="handleNavigate(-1)"
@next="handleNavigate(1)"
@update:index="index = $event"
/>
</div>
</div>
<StackWidgetConfigModal
v-if="configVisible"
:config="config"
@close="configVisible = false"
@save="handleSave"
/>
</template>

View File

@ -0,0 +1,205 @@
<script setup lang="ts">
import { randomUUID } from "@/utils/id";
import ActionButton from "@console/modules/dashboard/components/ActionButton.vue";
import WidgetHubModal from "@console/modules/dashboard/components/WidgetHubModal.vue";
import {
IconArrowDownLine,
IconArrowUpLine,
Toast,
VButton,
VModal,
VSpace,
} from "@halo-dev/components";
import type { DashboardWidgetDefinition } from "@halo-dev/console-shared";
import { cloneDeep } from "lodash-es";
import { onMounted, ref, toRaw, useTemplateRef } from "vue";
import { useI18n } from "vue-i18n";
import WidgetEditableItem from "./components/WidgetEditableItem.vue";
import type { SimpleWidget, StackWidgetConfig } from "./types";
const { t } = useI18n();
const props = defineProps<{
config: StackWidgetConfig;
}>();
const emit = defineEmits<{
(e: "close"): void;
(e: "save", config: StackWidgetConfig): void;
}>();
const widgets = ref<SimpleWidget[]>();
onMounted(() => {
widgets.value = toRaw(props.config.widgets);
});
const modal = useTemplateRef<InstanceType<typeof VModal> | null>("modal");
const widgetsHubModalVisible = ref(false);
function handleAddWidget(widgetDefinition: DashboardWidgetDefinition) {
if (widgetDefinition.id === "core:stack") {
Toast.error(
t("core.dashboard.widgets.presets.stack.config_modal.toast.nest_warning")
);
return;
}
widgets.value = [
...(widgets.value || []),
{
i: randomUUID(),
id: widgetDefinition.id,
config: widgetDefinition.defaultConfig,
},
];
widgetsHubModalVisible.value = false;
}
function handleSave(data: { auto_play: boolean; auto_play_interval: number }) {
emit("save", {
auto_play: data.auto_play,
auto_play_interval: data.auto_play_interval,
widgets: widgets.value || [],
});
}
function handleRemoveWidget(widget: SimpleWidget) {
widgets.value = widgets.value?.filter((w) => w.i !== widget.i);
}
function handleUpdateWidgetConfig(
widget: SimpleWidget,
config: Record<string, unknown>
) {
widgets.value = widgets.value?.map((w) =>
w.i === widget.i ? { ...w, config } : w
);
}
function handleMoveWidget(widget: SimpleWidget, direction: -1 | 1) {
const items = cloneDeep(widgets.value) || [];
const currentIndex = items.findIndex((item) => item.i === widget.i);
if (currentIndex === -1) return;
const targetIndex = currentIndex + direction;
if (targetIndex < 0 || targetIndex >= items.length) return;
[items[currentIndex], items[targetIndex]] = [
items[targetIndex],
items[currentIndex],
];
widgets.value = [...items];
}
</script>
<template>
<VModal
ref="modal"
mount-to-body
:title="$t('core.dashboard.widgets.presets.stack.config_modal.title')"
:width="800"
:centered="false"
@close="emit('close')"
>
<div class="flex flex-col gap-5">
<FormKit
id="stack-widget-config-form"
v-slot="{ value }"
type="form"
name="stack-widget-config-form"
:preserve="true"
@submit="handleSave"
>
<FormKit
type="checkbox"
name="auto_play"
:label="
$t(
'core.dashboard.widgets.presets.stack.config_modal.fields.auto_play.label'
)
"
:value="config.auto_play || false"
/>
<FormKit
v-if="value?.auto_play"
type="number"
number
name="auto_play_interval"
validation="required"
:value="config.auto_play_interval || 3000"
:label="
$t(
'core.dashboard.widgets.presets.stack.config_modal.fields.auto_play_interval.label'
)
"
/>
<div class="py-4 flex flex-col gap-4">
<label
class="formkit-label block text-sm font-medium text-gray-700 formkit-invalid:text-red-500"
>
{{
$t(
"core.dashboard.widgets.presets.stack.config_modal.fields.widgets.label"
)
}}
</label>
<div class="flex flex-col gap-2 border border-dashed p-2 rounded-lg">
<WidgetEditableItem
v-for="(widget, index) in widgets"
:key="widget.id"
:item="widget"
@remove="handleRemoveWidget(widget)"
@update:config="handleUpdateWidgetConfig(widget, $event)"
>
<template #actions>
<ActionButton
v-if="index > 0"
class="bg-gray-200"
@click="handleMoveWidget(widget, -1)"
>
<IconArrowUpLine class="text-gray-600" />
</ActionButton>
<ActionButton
v-if="index < (widgets?.length || 0) - 1"
class="bg-gray-200"
@click="handleMoveWidget(widget, 1)"
>
<IconArrowDownLine class="text-gray-600" />
</ActionButton>
</template>
</WidgetEditableItem>
<div class="flex justify-left">
<VButton @click="widgetsHubModalVisible = true">
{{ $t("core.common.buttons.add") }}
</VButton>
</div>
</div>
</div>
</FormKit>
</div>
<template #footer>
<VSpace>
<VButton
type="secondary"
@click="$formkit.submit('stack-widget-config-form')"
>
{{ $t("core.common.buttons.save") }}
</VButton>
<VButton @click="emit('close')">
{{ $t("core.common.buttons.cancel") }}
</VButton>
</VSpace>
</template>
</VModal>
<WidgetHubModal
v-if="widgetsHubModalVisible"
@close="widgetsHubModalVisible = false"
@add-widget="handleAddWidget"
/>
</template>

View File

@ -0,0 +1,53 @@
<script setup lang="ts">
import { IconArrowLeft, IconArrowRight } from "@halo-dev/components";
defineProps<{
index: number;
total: number;
}>();
const emit = defineEmits<{
(e: "prev"): void;
(e: "next"): void;
(e: "update:index", index: number): void;
}>();
</script>
<template>
<div
class="bg-white rounded-full p-1 flex gap-4 items-center hover:shadow transition-all"
>
<button
v-if="total > 1"
class="w-7 h-7 flex items-center justify-center rounded-full bg-transparent hover:bg-gray-100 transition-all duration-200 focus:outline-none group"
@click="emit('prev')"
>
<IconArrowLeft class="text-gray-400 group-hover:text-gray-900" />
</button>
<div class="flex items-center gap-2">
<div
v-for="i in total"
:key="i"
class="group cursor-pointer"
@click="emit('update:index', i - 1)"
>
<div
class="w-2 h-2 rounded-full transition-all duration-200 ease-in-out transform"
:class="{
'bg-primary scale-150': index === i - 1,
'bg-gray-300 group-hover:bg-gray-400': index !== i - 1,
}"
/>
</div>
</div>
<button
v-if="total > 1"
class="w-7 h-7 flex items-center justify-center rounded-full bg-transparent hover:bg-gray-100 transition-all duration-200 focus:outline-none group"
@click="emit('next')"
>
<IconArrowRight class="text-gray-400 group-hover:text-gray-900" />
</button>
</div>
</template>

View File

@ -0,0 +1,84 @@
<script setup lang="ts">
import { usePermission } from "@/utils/permission";
import ActionButton from "@console/modules/dashboard/components/ActionButton.vue";
import WidgetConfigFormModal from "@console/modules/dashboard/components/WidgetConfigFormModal.vue";
import { IconCloseCircle, IconSettings } from "@halo-dev/components";
import type { DashboardWidgetDefinition } from "@halo-dev/console-shared";
import { computed, inject, ref, type ComputedRef } from "vue";
import type { SimpleWidget } from "../types";
const props = defineProps<{
item: SimpleWidget;
}>();
const emit = defineEmits<{
(e: "remove"): void;
(e: "update:config", config: Record<string, unknown>): void;
}>();
const { currentUserHasPermission } = usePermission();
const availableWidgetDefinitions = inject<
ComputedRef<DashboardWidgetDefinition[]>
>("availableWidgetDefinitions");
const widgetDefinition = computed(() => {
return availableWidgetDefinitions?.value?.find(
(definition) => definition.id === props.item.id
);
});
const defaultSize = computed(() => {
return widgetDefinition.value?.defaultSize || { w: 1, h: 1 };
});
const configModalVisible = ref(false);
function handleSaveConfig(config: Record<string, unknown>) {
emit("update:config", config);
configModalVisible.value = false;
}
</script>
<template>
<div
v-if="currentUserHasPermission(widgetDefinition?.permissions)"
class="group/grid-item relative"
:style="{
width: `${defaultSize.w * 100}px`,
height: `${defaultSize.h * 36}px`,
}"
>
<component
:is="widgetDefinition?.component"
edit-mode
:config="item.config"
@update:config="handleSaveConfig"
/>
<div
class="absolute hidden h-8 right-0 top-0 rounded-tr-lg bg-gray-100 overflow-hidden group-hover/grid-item:inline-flex items-center"
>
<slot name="actions" />
<ActionButton
v-if="widgetDefinition?.configFormKitSchema?.length"
class="bg-black"
@click="configModalVisible = true"
>
<IconSettings />
</ActionButton>
<ActionButton class="bg-red-500" @click="emit('remove')">
<IconCloseCircle />
</ActionButton>
</div>
</div>
<WidgetConfigFormModal
v-if="
widgetDefinition &&
widgetDefinition.configFormKitSchema &&
configModalVisible
"
:widget-definition="widgetDefinition"
:config="item.config"
@close="configModalVisible = false"
@save="handleSaveConfig"
/>
</template>

View File

@ -0,0 +1,31 @@
<script setup lang="ts">
import { usePermission } from "@/utils/permission";
import type { DashboardWidgetDefinition } from "@halo-dev/console-shared";
import { computed, inject, type ComputedRef } from "vue";
import type { SimpleWidget } from "../types";
const props = defineProps<{
item: SimpleWidget;
}>();
const { currentUserHasPermission } = usePermission();
const availableWidgetDefinitions = inject<
ComputedRef<DashboardWidgetDefinition[]>
>("availableWidgetDefinitions");
const widgetDefinition = computed(() => {
return availableWidgetDefinitions?.value?.find(
(definition) => definition.id === props.item.id
);
});
</script>
<template>
<div
v-if="currentUserHasPermission(widgetDefinition?.permissions)"
class="relative w-full h-full"
>
<component :is="widgetDefinition?.component" :config="item.config" />
</div>
</template>

View File

@ -0,0 +1,11 @@
export interface SimpleWidget {
i: string;
id: string;
config?: Record<string, unknown>;
}
export interface StackWidgetConfig {
auto_play?: boolean;
auto_play_interval?: number;
widgets: SimpleWidget[];
}

View File

@ -38,6 +38,22 @@ core:
label: Enabled Items
pending_comments:
title: Pending Comments
stack:
title: Widget Stack
operations:
add_widget:
button: Add Widget
config_modal:
title: Widget Stack Config
fields:
auto_play:
label: Auto play
auto_play_interval:
label: Auto play interval
widgets:
label: Widgets
toast:
nest_warning: You cannot add a stack widget to a stack widget
dashboard_designer:
title: Edit Dashboard
actions:

View File

@ -108,6 +108,22 @@ core:
title: Pending Comments
views_stats:
title: Visits
stack:
title: Widget Stack
operations:
add_widget:
button: Add Widget
config_modal:
title: Widget Stack Config
fields:
auto_play:
label: Auto play
auto_play_interval:
label: Auto play interval
widgets:
label: Widgets
toast:
nest_warning: You cannot add a stack widget to a stack widget
dashboard_designer:
title: Edit Dashboard
actions:

View File

@ -104,6 +104,22 @@ core:
title: 浏览量
pending_comments:
title: 新评论
stack:
title: 堆叠部件
operations:
add_widget:
button: 添加部件
config_modal:
title: 堆叠部件配置
fields:
auto_play:
label: 自动切换
auto_play_interval:
label: 自动切换间隔
widgets:
label: 部件
toast:
nest_warning: 不能将堆叠部件添加到堆叠部件中
dashboard_designer:
title: 编辑仪表盘
actions:

View File

@ -104,6 +104,22 @@ core:
title: 瀏覽量
pending_comments:
title: 新評論
stack:
title: 堆疊部件
operations:
add_widget:
button: 添加部件
config_modal:
title: 堆疊部件配置
fields:
auto_play:
label: 自動切換
auto_play_interval:
label: 自動切換間隔
widgets:
label: 部件
toast:
nest_warning: 不能將堆疊部件添加到堆疊部件中
dashboard_designer:
title: 編輯儀表盤
actions: