Files
ai-agent-admin/web/apps/web-ele/src/components/dashboard-design/components/WidgetRenderer.vue
T
2026-06-11 09:59:56 +08:00

320 lines
7.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type {
DashboardWidget,
WidgetStyle,
} from '../types';
import {
computed,
defineAsyncComponent,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { Loader2 } from '@vben/icons';
import { $t } from '@vben/locales';
import { useDashboardRuntime } from '../runtime';
import { createRefreshTimer, fetchWidgetData } from '../utils/dataFetcher';
const props = defineProps<{
animationDelay?: number; // 入场动画延迟(毫秒)
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
// 入场动画状态
const isEntered = ref(false);
// 数据更新动画状态
const isUpdating = ref(false);
// 组件映射
const widgetComponents: Record<
string,
ReturnType<typeof defineAsyncComponent>
> = {
'announcement-list': defineAsyncComponent(
() => import('./widgets/AnnouncementList.vue'),
),
'notice-list': defineAsyncComponent(() => import('./widgets/NoticeList.vue')),
'quick-links': defineAsyncComponent(() => import('./widgets/QuickLinks.vue')),
'server-monitor': defineAsyncComponent(
() => import('./widgets/ServerMonitor.vue'),
),
weather: defineAsyncComponent(() => import('./widgets/WeatherWidget.vue')),
'welcome-card': defineAsyncComponent(
() => import('./widgets/WelcomeCard.vue'),
),
};
const defaultWidgetStyle: WidgetStyle = {
backgroundColor: '',
borderWidth: 0,
borderColor: '',
borderStyle: 'solid',
borderRadius: 8,
shadowEnabled: true,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 4,
shadowOffsetX: 0,
shadowOffsetY: 1,
padding: 16,
titleShow: true,
titleFontSize: 14,
titleColor: '',
titleAlign: 'left',
titleFontWeight: 'normal',
};
const { globalParams, globalParamsVersion } = useDashboardRuntime();
const currentComponent = computed(() => {
return widgetComponents[props.widget.type];
});
// 从 paramBindings 解析出实际参数值
function resolveParams(): Record<string, any> | undefined {
const bindings = props.widget.dataSource?.paramBindings;
if (!bindings || bindings.length === 0) return undefined;
const params: Record<string, any> = {};
for (const b of bindings) {
if (b.paramName && b.globalKey) {
const val = globalParams.value[b.globalKey];
if (val !== undefined && val !== '') {
params[b.paramName] = val;
}
}
}
return Object.keys(params).length > 0 ? params : undefined;
}
// 动态数据
const dynamicProps = ref<Record<string, any>>({});
const isLoading = ref(false);
const hasError = ref(false);
// 合并后的 widget(静态 props + 动态 props
const mergedWidget = computed(() => {
return {
...props.widget,
props: {
...props.widget.props,
...dynamicProps.value,
},
};
});
// 计算样式(只应用容器级别的样式,不影响组件内部)
const widgetStyle = computed(() => {
const style = { ...defaultWidgetStyle, ...props.widget.style };
const css: Record<string, string> = {};
// 背景(默认使用主题背景色,支持渐变色)
const bgValue = style.backgroundColor || 'var(--el-bg-color)';
if (bgValue.includes('gradient')) {
css.background = bgValue;
} else {
css.backgroundColor = bgValue;
}
// 边框
if (style.borderWidth && style.borderWidth > 0) {
css.borderWidth = `${style.borderWidth}px`;
css.borderStyle = style.borderStyle || 'solid';
css.borderColor = style.borderColor || 'var(--el-border-color)';
}
// 圆角
if (style.borderRadius !== undefined) {
css.borderRadius = `${style.borderRadius}px`;
}
// 阴影
if (style.shadowEnabled) {
const color = style.shadowColor || 'rgba(0, 0, 0, 0.1)';
const blur = style.shadowBlur || 4;
const x = style.shadowOffsetX || 0;
const y = style.shadowOffsetY || 1;
css.boxShadow = `${x}px ${y}px ${blur}px ${color}`;
} else {
css.boxShadow = 'none';
}
return css;
});
// 加载数据
const loadData = async () => {
if (!props.widget.dataSource || props.widget.dataSource.type === 'static') {
dynamicProps.value = {};
return;
}
isLoading.value = true;
hasError.value = false;
try {
const params = resolveParams();
const result = await fetchWidgetData(
props.widget.dataSource,
props.widget.props,
params,
);
// 数据更新动画
if (isEntered.value && Object.keys(dynamicProps.value).length > 0) {
isUpdating.value = true;
setTimeout(() => {
isUpdating.value = false;
}, 300);
}
dynamicProps.value = result.props;
} catch (error) {
console.error('[WidgetRenderer] loadData error', error);
hasError.value = true;
} finally {
isLoading.value = false;
}
};
// 刷新定时器清理函数
let cleanupTimer: (() => void) | null = null;
// 设置刷新定时器
const setupRefreshTimer = () => {
if (cleanupTimer) {
cleanupTimer();
cleanupTimer = null;
}
// 支持 api 和 dataSource 类型的自动刷新
const dsType = props.widget.dataSource?.type;
if (!props.isDesignMode && (dsType === 'api' || dsType === 'dataSource')) {
cleanupTimer = createRefreshTimer(props.widget.dataSource, loadData);
}
};
// 监听数据源变化
watch(
() => props.widget.dataSource,
() => {
loadData();
setupRefreshTimer();
},
{ deep: true },
);
// 监听全局筛选参数变化,有 paramBindings 的组件重新加载数据
watch(
() => globalParamsVersion.value,
() => {
if (props.isDesignMode) return;
const bindings = props.widget.dataSource?.paramBindings;
if (bindings && bindings.length > 0) {
loadData();
}
},
);
onMounted(() => {
loadData();
setupRefreshTimer();
// 入场动画
const delay = props.animationDelay || 0;
setTimeout(() => {
isEntered.value = true;
}, delay);
});
onUnmounted(() => {
if (cleanupTimer) {
cleanupTimer();
}
});
// 动画类名
const animationClass = computed(() => {
return {
'widget-enter': true,
'widget-entered': isEntered.value,
'widget-updating': isUpdating.value,
};
});
</script>
<template>
<div
class="widget-renderer relative h-full w-full overflow-hidden"
:class="animationClass"
:style="widgetStyle"
>
<!-- 加载状态 -->
<div
v-if="isLoading"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50"
>
<Loader2 class="text-primary h-6 w-6 animate-spin" />
</div>
<!-- 错误状态 -->
<div v-if="hasError && !isLoading" class="absolute right-2 top-2 z-10">
<span class="text-xs text-red-500">{{
$t('dashboard-design.loadDataError')
}}</span>
</div>
<!-- 组件内容 -->
<component
:is="currentComponent"
v-if="currentComponent"
:widget="mergedWidget"
:is-design-mode="isDesignMode"
/>
<div
v-else
class="flex h-full w-full items-center justify-center text-gray-400"
>
{{ $t('dashboard-design.unknownWidget') }}: {{ widget.type }}
</div>
</div>
</template>
<style scoped>
@keyframes pulse-update {
0% {
transform: scale(1);
}
50% {
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.widget-enter {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.widget-entered {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 数据更新动画 */
.widget-updating {
animation: pulse-update 0.3s ease-in-out;
}
/* 入场动画 */
</style>