feat: restore lightweight admin modules

This commit is contained in:
2026-06-11 19:39:38 +08:00
parent 15229cc5ab
commit 1fba0aafb2
73 changed files with 13943 additions and 80 deletions
@@ -0,0 +1,142 @@
<script setup lang="ts">
import type { DashboardConfig } from './store/dashboardDesignStore';
/**
* 仪表盘渲染器
* 用于在实际页面中展示设计好的仪表盘配置
*/
import { computed, onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElEmpty } from 'element-plus';
import { GridItem, GridLayout } from 'grid-layout-plus';
import WidgetRenderer from './components/WidgetRenderer.vue';
const props = defineProps<{
// 仪表盘配置 JSON 字符串或对象
config: DashboardConfig | string;
}>();
// 解析后的配置
const dashboardConfig = ref<DashboardConfig | null>(null);
// 解析配置
const parseConfig = () => {
if (!props.config) {
dashboardConfig.value = null;
return;
}
if (typeof props.config === 'string') {
try {
dashboardConfig.value = JSON.parse(props.config);
} catch {
console.error('Invalid dashboard config JSON');
dashboardConfig.value = null;
}
} else {
dashboardConfig.value = props.config;
}
};
onMounted(parseConfig);
watch(() => props.config, parseConfig);
// 布局数据
const layout = computed(() => {
if (!dashboardConfig.value) return [];
return dashboardConfig.value.widgets.map((w) => ({
i: w.i,
x: w.x,
y: w.y,
w: w.w,
h: w.h,
}));
});
// 获取 widget 配置
const getWidget = (i: string) => {
if (!dashboardConfig.value) return null;
return dashboardConfig.value.widgets.find((w) => w.i === i);
};
// 获取动画延迟(交错入场效果)
const getAnimationDelay = (i: string) => {
if (!dashboardConfig.value) return 0;
const index = dashboardConfig.value.widgets.findIndex((w) => w.i === i);
return index * 80; // 每个组件延迟 80ms
};
</script>
<template>
<div
class="dashboard-renderer h-full"
:style="
dashboardConfig?.backgroundColor?.includes('gradient')
? { background: dashboardConfig.backgroundColor }
: { backgroundColor: dashboardConfig?.backgroundColor || '' }
"
>
<div v-if="dashboardConfig && layout.length > 0">
<GridLayout
:layout="layout"
:col-num="dashboardConfig.columns"
:row-height="dashboardConfig.rowHeight"
:margin="dashboardConfig.margin"
:is-draggable="false"
:is-resizable="false"
:vertical-compact="true"
:use-css-transforms="true"
:style="
dashboardConfig.showOuterMargin
? {}
: {
marginLeft: `-${dashboardConfig.margin[0]}px`,
marginRight: `-${dashboardConfig.margin[0]}px`,
marginTop: `-${dashboardConfig.margin[1]}px`,
width: `calc(100% + ${dashboardConfig.margin[0] * 2}px)`,
}
"
>
<GridItem
v-for="item in layout"
:key="item.i"
:i="item.i"
:x="item.x"
:y="item.y"
:w="item.w"
:h="item.h"
class="dashboard-widget"
>
<WidgetRenderer
v-if="getWidget(item.i)"
:widget="getWidget(item.i)!"
:is-design-mode="false"
:animation-delay="getAnimationDelay(item.i)"
/>
</GridItem>
</GridLayout>
</div>
<ElEmpty
v-else
:description="$t('dashboard-design.noConfigTip')"
class="py-20"
/>
</div>
</template>
<style scoped>
.dashboard-renderer {
background-color: var(--el-bg-color-page);
}
.dashboard-widget {
overflow: hidden;
background: var(--el-bg-color);
border-radius: 8px;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
}
</style>
@@ -0,0 +1,313 @@
<script setup lang="ts">
import type {
DashboardWidget,
WidgetMaterial,
} from '../store/dashboardDesignStore';
import {
computed,
defineAsyncComponent,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { Loader2 } from '@vben/icons';
import { $t } from '@vben/locales';
import {
defaultWidgetStyle,
useDashboardDesignStore,
} from '../store/dashboardDesignStore';
import { createRefreshTimer, fetchWidgetData } from '../utils/dataFetcher';
const props = defineProps<{
animationDelay?: number; // 入场动画延迟(毫秒)
isDesignMode?: boolean;
material?: WidgetMaterial;
widget: DashboardWidget;
}>();
// 入场动画状态
const isEntered = ref(false);
// 数据更新动画状态
const isUpdating = ref(false);
// 组件映射
const widgetComponents: Record<
string,
ReturnType<typeof defineAsyncComponent>
> = {
'notice-list': defineAsyncComponent(() => import('./widgets/NoticeList.vue')),
'announcement-list': defineAsyncComponent(
() => import('./widgets/AnnouncementList.vue'),
),
'quick-links': defineAsyncComponent(() => import('./widgets/QuickLinks.vue')),
'welcome-card': defineAsyncComponent(
() => import('./widgets/WelcomeCard.vue'),
),
calendar: defineAsyncComponent(() => import('./widgets/CalendarWidget.vue')),
countdown: defineAsyncComponent(
() => import('./widgets/CountdownWidget.vue'),
),
clock: defineAsyncComponent(() => import('./widgets/ClockWidget.vue')),
weather: defineAsyncComponent(() => import('./widgets/WeatherWidget.vue')),
'approval-center': defineAsyncComponent(
() => import('./widgets/ApprovalCenter.vue'),
),
'my-apps': defineAsyncComponent(() => import('./widgets/MyApps.vue')),
'server-monitor': defineAsyncComponent(
() => import('./widgets/ServerMonitor.vue'),
),
};
const store = useDashboardDesignStore();
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 = store.globalParams[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(
() => store.globalParamsVersion,
() => {
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>
@@ -0,0 +1,298 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import type { UserAnnouncement } from '#/api/core/announcement';
import { computed, onMounted, ref } from 'vue';
import { CheckCheck, Megaphone } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElEmpty, ElScrollbar, ElTag } from 'element-plus';
import {
getUserAnnouncementDetailApi,
getUserAnnouncementListApi,
} from '#/api/core/announcement';
import { ZqDialog } from '#/components/zq-dialog';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 实际公告数据
const announcements = ref<UserAnnouncement[]>([]);
const loading = ref(false);
// 详情弹窗
const detailVisible = ref(false);
const currentAnnouncement = ref<null | UserAnnouncement>(null);
const detailLoading = ref(false);
// 未读数量
const unreadCount = computed(
() => announcements.value.filter((a) => !a.is_read).length,
);
// 获取优先级配置
const getPriorityConfig = (priority: number) => {
switch (priority) {
case 1: {
return {
type: 'warning' as const,
label: $t('dashboard-design.widgets.announcement.priority.important'),
};
}
case 2: {
return {
type: 'danger' as const,
label: $t('dashboard-design.widgets.announcement.priority.urgent'),
};
}
default: {
return {
type: 'info' as const,
label: $t('dashboard-design.widgets.announcement.priority.normal'),
};
}
}
};
// 格式化时间
const formatTime = (dateStr?: string) => {
if (!dateStr) return '';
// 处理 "2025-12-01 21:47:29" 格式,替换空格为T以兼容所有浏览器
const date = new Date(dateStr.replace(' ', 'T'));
if (isNaN(date.getTime())) return dateStr;
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1)
return $t('dashboard-design.widgets.announcement.time.justNow');
if (minutes < 60)
return `${minutes}${$t('dashboard-design.widgets.announcement.time.minutesAgo')}`;
if (hours < 24)
return `${hours}${$t('dashboard-design.widgets.announcement.time.hoursAgo')}`;
if (days < 7)
return `${days}${$t('dashboard-design.widgets.announcement.time.daysAgo')}`;
return date.toLocaleDateString();
};
// 加载公告数据
const loadAnnouncements = async () => {
loading.value = true;
try {
const limit = props.widget.props.limit || 5;
const res = await getUserAnnouncementListApi({ page: 1, pageSize: limit });
announcements.value = res.items || [];
} catch (error) {
console.error('Failed to load announcements:', error);
} finally {
loading.value = false;
}
};
// 查看公告详情(会自动标记已读)
const viewDetail = async (item: UserAnnouncement) => {
detailVisible.value = true;
detailLoading.value = true;
try {
// 获取详情会自动标记已读
const detail = await getUserAnnouncementDetailApi(item.id);
currentAnnouncement.value = detail;
// 更新列表中的已读状态
item.is_read = true;
} catch (error) {
console.error('Failed to load announcement detail:', error);
currentAnnouncement.value = item;
} finally {
detailLoading.value = false;
}
};
// 全部已读(逐个标记)
const markAllRead = async () => {
if (unreadCount.value === 0) return;
const unreadItems = announcements.value.filter((a) => !a.is_read);
for (const item of unreadItems) {
try {
await getUserAnnouncementDetailApi(item.id);
item.is_read = true;
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
};
onMounted(() => {
loadAnnouncements();
});
</script>
<template>
<div class="announcement-list flex h-full flex-col p-3">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Megaphone class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
<ElTag v-if="unreadCount > 0" type="danger" size="small" round>
{{ unreadCount }}
</ElTag>
</div>
<ElButton
v-if="unreadCount > 0"
type="primary"
text
size="small"
@click="markAllRead"
>
<CheckCheck class="mr-1 h-3.5 w-3.5" />
{{ $t('dashboard-design.widgets.announcement.markAllRead') }}
</ElButton>
</div>
<ElScrollbar class="flex-1">
<div v-if="announcements.length > 0" class="space-y-2">
<div
v-for="item in announcements"
:key="item.id"
class="cursor-pointer rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
:class="{ 'opacity-60': item.is_read }"
@click="viewDetail(item)"
>
<div class="flex items-start gap-2">
<ElTag
v-if="item.is_top"
type="danger"
size="small"
effect="dark"
class="flex-shrink-0"
>
{{ $t('dashboard-design.widgets.announcement.top') }}
</ElTag>
<ElTag
:type="getPriorityConfig(item.priority).type"
size="small"
class="flex-shrink-0"
>
{{ getPriorityConfig(item.priority).label }}
</ElTag>
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium">{{ item.title }}</div>
</div>
</div>
<div
v-if="item.summary"
class="text-muted-foreground mt-1 line-clamp-2 text-xs"
>
{{ item.summary }}
</div>
<div
class="text-muted-foreground mt-1 flex items-center justify-between text-xs"
>
<span>{{ item.publisher_name }}</span>
<span>{{ formatTime(item.publish_time) }}</span>
</div>
</div>
</div>
<ElEmpty
v-else
:description="$t('dashboard-design.widgets.announcement.noData')"
:image-size="60"
/>
</ElScrollbar>
<!-- 公告详情弹窗 -->
<ZqDialog
v-model="detailVisible"
:title="currentAnnouncement?.title"
width="800px"
destroy-on-close
append-to-body
>
<div class="min-h-[500px]" v-loading="detailLoading">
<!-- <div v-if="detailLoading" class="py-8 text-center text-gray-400">
{{ $t('dashboard-design.widgets.announcement.loading') }}
</div> -->
<div v-if="currentAnnouncement" class="space-y-4">
<div class="flex items-center gap-2">
<ElTag
v-if="currentAnnouncement.is_top"
type="danger"
size="small"
effect="dark"
>
{{ $t('dashboard-design.widgets.announcement.top') }}
</ElTag>
<ElTag
:type="getPriorityConfig(currentAnnouncement.priority).type"
size="small"
>
{{ getPriorityConfig(currentAnnouncement.priority).label }}
</ElTag>
<span class="text-muted-foreground text-xs">{{
formatTime(currentAnnouncement.publish_time)
}}</span>
</div>
<!-- 摘要 -->
<div
v-if="currentAnnouncement.summary"
class="border-l-4 border-[var(--el-color-primary)] bg-[var(--el-fill-color-light)] py-3 pl-4 pr-3 text-sm text-[var(--el-text-color-regular)]"
>
{{ currentAnnouncement.summary }}
</div>
<!-- 富文本内容 -->
<div
class="announcement-content prose max-w-none"
v-html="
currentAnnouncement.content ||
$t('dashboard-design.widgets.announcement.noContent')
"
></div>
<div class="text-muted-foreground text-xs">
{{ $t('dashboard-design.widgets.announcement.publisher')
}}{{ currentAnnouncement.publisher_name }}
</div>
</div>
</div>
</ZqDialog>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
.announcement-content :deep(img) {
max-width: 100%;
}
.announcement-content :deep(table) {
border-collapse: collapse;
width: 100%;
}
.announcement-content :deep(td),
.announcement-content :deep(th) {
border: 1px solid var(--el-border-color);
padding: 8px;
}
.announcement-content :deep(blockquote) {
border-left: 4px solid var(--el-border-color);
padding-left: 16px;
margin: 8px 0;
color: var(--el-text-color-secondary);
}
.announcement-content :deep(a) {
color: var(--el-color-primary);
text-decoration: underline;
}
</style>
@@ -0,0 +1,118 @@
<script setup lang="ts">
import type { DashboardWidget } from '#/components/dashboard-design';
import { computed } from 'vue';
import {
ChevronRight,
ClipboardCheck,
ClipboardList,
FilePen,
Play,
Send,
UserCheck,
} from '@vben/icons';
import { $t } from '@vben/locales';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 审批中心菜单项
const menuItems = computed(() => [
{
key: 'initiated',
title: $t('dashboard-design.widgets.approvalCenter.initiated'),
icon: Send,
color: 'rgba(59, 130, 246, 0.85)',
path: '/workflow/initiated',
},
{
key: 'pending',
title: $t('dashboard-design.widgets.approvalCenter.pending'),
icon: ClipboardList,
color: 'rgba(245, 158, 11, 0.85)',
path: '/workflow/pending',
},
{
key: 'handling',
title: $t('dashboard-design.widgets.approvalCenter.handling'),
icon: FilePen,
color: 'rgba(139, 92, 246, 0.85)',
path: '/workflow/pending',
},
{
key: 'handled',
title: $t('dashboard-design.widgets.approvalCenter.handled'),
icon: ClipboardCheck,
color: 'rgba(14, 165, 233, 0.85)',
path: '/workflow/handled',
},
{
key: 'copy',
title: $t('dashboard-design.widgets.approvalCenter.copy'),
icon: UserCheck,
color: 'rgba(249, 115, 22, 0.85)',
path: '/workflow/copy',
},
{
key: 'start',
title: $t('dashboard-design.widgets.approvalCenter.start'),
icon: Play,
color: 'rgba(20, 184, 166, 0.85)',
path: '/workflow/start',
},
]);
// 路由前缀
const routePrefix = computed(
() => props.widget.props.routePrefix || '/app/workflow_center',
);
// 点击菜单项
const handleClick = (item: { path: string }) => {
window.open(`${routePrefix.value}${item.path}`, '_blank');
};
// 点击更多
const handleMore = () => {
window.open(`${routePrefix.value}/workflow/pending`, '_blank');
};
</script>
<template>
<div class="approval-center flex h-full flex-col p-4">
<!-- 头部 -->
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<button
v-if="widget.props.showMore"
type="button"
class="text-muted-foreground hover:text-primary flex items-center gap-0.5 text-xs transition-colors"
@click="handleMore"
>
{{ $t('dashboard-design.widgets.approvalCenter.more') }}
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 菜单网格 -->
<div class="flex flex-1 items-center justify-around gap-2">
<div
v-for="item in menuItems"
:key="item.key"
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105"
@click="handleClick(item)"
>
<div
class="flex h-11 w-11 items-center justify-center rounded-full"
:style="{ backgroundColor: item.color }"
>
<component :is="item.icon" class="h-5 w-5 text-white" />
</div>
<span class="text-muted-foreground whitespace-nowrap text-xs">{{
item.title
}}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,71 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { ref } from 'vue';
import { Calendar } from '@vben/icons';
import { ElCalendar } from 'element-plus';
defineProps<{
widget: DashboardWidget;
}>();
const selectedDate = ref(new Date());
</script>
<template>
<div class="calendar-widget flex h-full flex-col p-3">
<div class="mb-2 flex items-center gap-2">
<Calendar class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
</div>
<div class="min-h-0 flex-1 overflow-hidden">
<ElCalendar v-model="selectedDate" class="compact-calendar" />
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
.compact-calendar {
--el-calendar-border: none;
}
:deep(.el-calendar) {
height: 100%;
}
:deep(.el-calendar__header) {
padding: 8px 0;
}
:deep(.el-calendar__body) {
padding: 0;
}
:deep(.el-calendar-table thead th) {
padding: 4px 0;
font-size: 12px;
}
:deep(.el-calendar-table .el-calendar-day) {
height: 32px;
padding: 2px;
font-size: 12px;
}
:deep(.el-calendar-table td.is-selected .el-calendar-day) {
color: white;
background-color: var(--el-color-primary);
border-radius: 4px;
}
:deep(.el-calendar-table td.is-today .el-calendar-day) {
font-weight: bold;
color: var(--el-color-primary);
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { $t } from '@vben/locales';
import { preferences } from '@vben/preferences';
const props = defineProps<{
widget: DashboardWidget;
}>();
const locale = computed(() => preferences.app.locale);
const currentTime = ref('');
const currentDate = ref('');
let timer: null | ReturnType<typeof setInterval> = null;
const updateTime = () => {
const now = new Date();
// 处理时区
const date = now;
if (props.widget.props.timezone && props.widget.props.timezone !== 'local') {
try {
const options: Intl.DateTimeFormatOptions = {
timeZone: props.widget.props.timezone,
hour: '2-digit',
minute: '2-digit',
second: props.widget.props.showSeconds ? '2-digit' : undefined,
hour12: !props.widget.props.format24,
};
currentTime.value = now.toLocaleTimeString(locale.value, options);
const dateOptions: Intl.DateTimeFormatOptions = {
timeZone: props.widget.props.timezone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
};
currentDate.value = now.toLocaleDateString(locale.value, dateOptions);
return;
} catch {
// 时区无效,使用本地时间
}
}
// 本地时间
const hours = props.widget.props.format24
? String(date.getHours()).padStart(2, '0')
: String(date.getHours() % 12 || 12).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
const ampm = props.widget.props.format24
? ''
: (date.getHours() >= 12
? ' PM'
: ' AM');
currentTime.value = props.widget.props.showSeconds
? `${hours}:${minutes}:${seconds}${ampm}`
: `${hours}:${minutes}${ampm}`;
const weekdays = $t(
'dashboard-design.widgets.clock.weekdays',
) as unknown as string[];
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const weekday = weekdays[date.getDay()];
const yearSuffix = $t('dashboard-design.widgets.clock.year');
const monthSuffix = $t('dashboard-design.widgets.clock.month');
const daySuffix = $t('dashboard-design.widgets.clock.day');
currentDate.value = `${year}${yearSuffix}${month}${monthSuffix}${day}${daySuffix} ${weekday}`;
};
onMounted(() => {
updateTime();
timer = setInterval(updateTime, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
</script>
<template>
<div
class="clock-widget flex h-full flex-col items-center justify-center p-3"
>
<div v-if="widget.props.title" class="text-muted-foreground mb-2 text-sm">
{{ widget.props.title }}
</div>
<div class="time-display">
{{ currentTime }}
</div>
<div v-if="widget.props.showDate" class="date-display">
{{ currentDate }}
</div>
</div>
</template>
<style scoped>
.time-display {
font-size: 2.5rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
line-height: 1;
color: var(--el-text-color-primary);
}
.date-display {
margin-top: 8px;
font-size: 0.875rem;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,155 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { onMounted, onUnmounted, ref } from 'vue';
import { $t } from '@vben/locales';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 剩余时间
const remainingTime = ref({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
finished: false,
});
let timer: null | ReturnType<typeof setInterval> = null;
const calculateRemaining = () => {
const targetTime = new Date(props.widget.props.targetTime).getTime();
const now = Date.now();
const diff = targetTime - now;
if (diff <= 0) {
remainingTime.value = {
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
finished: true,
};
if (timer) {
clearInterval(timer);
timer = null;
}
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
remainingTime.value = { days, hours, minutes, seconds, finished: false };
};
onMounted(() => {
calculateRemaining();
timer = setInterval(calculateRemaining, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
const padZero = (num: number) => String(num).padStart(2, '0');
</script>
<template>
<div class="countdown-widget flex h-full flex-col p-3">
<div
v-if="widget.props.title"
class="text-muted-foreground mb-2 text-sm font-medium"
>
{{ widget.props.title }}
</div>
<div class="flex flex-1 items-center justify-center">
<template v-if="!remainingTime.finished">
<div class="flex items-center gap-2">
<template v-if="widget.props.showDays">
<div class="time-block">
<div class="time-value">{{ remainingTime.days }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.day') }}
</div>
</div>
<span class="time-separator">:</span>
</template>
<template v-if="widget.props.showHours">
<div class="time-block">
<div class="time-value">{{ padZero(remainingTime.hours) }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.hour') }}
</div>
</div>
<span class="time-separator">:</span>
</template>
<template v-if="widget.props.showMinutes">
<div class="time-block">
<div class="time-value">{{ padZero(remainingTime.minutes) }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.minute') }}
</div>
</div>
<span v-if="widget.props.showSeconds" class="time-separator">:</span>
</template>
<template v-if="widget.props.showSeconds">
<div class="time-block">
<div class="time-value">{{ padZero(remainingTime.seconds) }}</div>
<div class="time-label">
{{ $t('dashboard-design.widgets.countdown.second') }}
</div>
</div>
</template>
</div>
</template>
<div v-else class="text-muted-foreground text-lg">
{{
widget.props.finishedText ||
$t('dashboard-design.widgets.countdown.finished')
}}
</div>
</div>
</div>
</template>
<style scoped>
.time-block {
display: flex;
flex-direction: column;
align-items: center;
min-width: 48px;
}
.time-value {
font-size: 1.75rem;
font-weight: 600;
line-height: 1;
color: var(--el-color-primary);
}
.time-label {
margin-top: 4px;
font-size: 0.75rem;
color: var(--el-text-color-secondary);
}
.time-separator {
margin-bottom: 16px;
font-size: 1.5rem;
font-weight: 600;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { DashboardWidget } from '#/components/dashboard-design';
import type { ApplicationListItem } from '#/api/core/application';
import { computed, onMounted, ref } from 'vue';
import { AppWindow, ChevronRight, IconifyIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElEmpty, ElScrollbar } from 'element-plus';
import { getApplicationListApi } from '#/api/core/application';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 应用列表
const appList = ref<ApplicationListItem[]>([]);
const loading = ref(false);
// 最大显示数量
const maxCount = computed(() => props.widget.props.maxCount || 8);
// 显示的应用列表
const displayApps = computed(() => appList.value.slice(0, maxCount.value));
// 加载已发布的应用列表
const loadApps = async () => {
loading.value = true;
try {
const res = await getApplicationListApi({
page: 1,
pageSize: 100,
status: 'published',
});
appList.value = res.items || [];
} catch (error) {
console.error('Failed to load applications:', error);
} finally {
loading.value = false;
}
};
// 点击应用
const handleClick = (app: ApplicationListItem) => {
window.open(`${window.location.origin}/app/${app.code}`, '_blank');
};
// 点击更多
const handleMore = () => {
window.open(`${window.location.origin}/application`, '_blank');
};
onMounted(() => {
loadApps();
});
</script>
<template>
<div class="my-apps flex h-full flex-col p-4" v-loading="loading">
<!-- 头部 -->
<div class="mb-4 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<button
v-if="widget.props.showMore"
type="button"
class="text-muted-foreground hover:text-primary flex items-center gap-0.5 text-xs transition-colors"
@click="handleMore"
>
{{ $t('dashboard-design.widgets.myApps.more') }}
<ChevronRight class="h-3.5 w-3.5" />
</button>
</div>
<!-- 应用网格 -->
<ElScrollbar class="flex-1">
<div
v-if="displayApps.length > 0"
class="flex flex-wrap gap-4"
>
<div
v-for="app in displayApps"
:key="app.id"
class="flex cursor-pointer flex-col items-center gap-2 transition-transform hover:scale-105 m-4"
style="width: 72px"
@click="handleClick(app)"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-xl"
style="background: linear-gradient(135deg, var(--el-color-primary-light-3), var(--el-color-primary))"
>
<IconifyIcon
v-if="app.icon"
:icon="app.icon"
class="h-6 w-6 text-white"
/>
<AppWindow v-else class="h-6 w-6 text-white" />
</div>
<span class="text-muted-foreground w-full truncate text-center text-xs">{{ app.name }}</span>
</div>
</div>
<ElEmpty
v-else-if="!loading"
:description="$t('dashboard-design.widgets.myApps.noData')"
:image-size="60"
/>
</ElScrollbar>
</div>
</template>
@@ -0,0 +1,269 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import type { Message } from '#/api/core/message';
import { computed, onMounted, ref } from 'vue';
import { Bell, CheckCheck, ExternalLink } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElDialog, ElEmpty, ElScrollbar, ElTag } from 'element-plus';
import {
getMessageListApi,
markAllAsReadApi,
markAsReadApi,
} from '#/api/core/message';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 实际消息数据
const messages = ref<Message[]>([]);
const loading = ref(false);
// 详情弹窗
const detailVisible = ref(false);
const currentMessage = ref<Message | null>(null);
// 未读数量
const unreadCount = computed(
() => messages.value.filter((m) => m.status === 'unread').length,
);
// 获取消息类型配置
const getTypeConfig = (type: string) => {
switch (type) {
case 'announcement': {
return {
type: 'success' as const,
label: $t('message.typeMap.announcement'),
};
}
case 'system': {
return { type: 'info' as const, label: $t('message.typeMap.system') };
}
case 'todo': {
return { type: 'warning' as const, label: $t('message.typeMap.todo') };
}
case 'workflow': {
return {
type: 'primary' as const,
label: $t('message.typeMap.workflow'),
};
}
default: {
return { type: 'info' as const, label: $t('message.type') };
}
}
};
// 跳转到审批链接
const handleGoToLink = (message: Message) => {
if (!message.link_type || !message.link_id) return;
// 根据 link_type 跳转到不同页面
let url = '';
if (message.link_type === 'workflow_task') {
// 跳转到待办任务页面,带上任务ID
url = `/app/workflow_center/workflow/pending?id=${message.link_id}`;
} else if (message.link_type === 'workflow_instance') {
// 跳转到我发起的页面,带上实例ID
url = `/app/workflow_center/workflow/initiated?id=${message.link_id}`;
}
if (url) {
window.open(url, '_blank');
}
};
// 格式化时间
const formatTime = (dateStr: string) => {
if (!dateStr) return '';
// 处理 "2025-12-01 21:47:29" 格式,替换空格为T以兼容所有浏览器
const date = new Date(dateStr.replace(' ', 'T'));
if (isNaN(date.getTime())) return dateStr;
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60_000);
const hours = Math.floor(diff / 3_600_000);
const days = Math.floor(diff / 86_400_000);
if (minutes < 1)
return $t('dashboard-design.widgets.announcement.time.justNow');
if (minutes < 60)
return `${minutes}${$t('dashboard-design.widgets.announcement.time.minutesAgo')}`;
if (hours < 24)
return `${hours}${$t('dashboard-design.widgets.announcement.time.hoursAgo')}`;
if (days < 7)
return `${days}${$t('dashboard-design.widgets.announcement.time.daysAgo')}`;
return date.toLocaleDateString();
};
// 加载消息数据
const loadMessages = async () => {
loading.value = true;
try {
const limit = props.widget.props.limit || 5;
const res = await getMessageListApi({ page: 1, pageSize: limit });
messages.value = res.items || [];
} catch (error) {
console.error('Failed to load messages:', error);
} finally {
loading.value = false;
}
};
// 查看消息详情
const viewDetail = async (msg: Message) => {
currentMessage.value = msg;
detailVisible.value = true;
// 标记为已读
if (msg.status === 'unread') {
try {
await markAsReadApi(msg.id);
msg.status = 'read';
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
};
// 全部已读
const markAllRead = async () => {
if (unreadCount.value === 0) return;
try {
await markAllAsReadApi();
messages.value.forEach((msg) => {
msg.status = 'read';
});
} catch (error) {
console.error('Failed to mark all as read:', error);
}
};
onMounted(() => {
loadMessages();
});
</script>
<template>
<div class="notice-list flex h-full flex-col p-3">
<div class="mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<Bell class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
<ElTag v-if="unreadCount > 0" type="danger" size="small" round>
{{ unreadCount }}
</ElTag>
</div>
<ElButton
v-if="unreadCount > 0"
type="primary"
text
size="small"
@click="markAllRead"
>
<CheckCheck class="mr-1 h-3.5 w-3.5" />
{{ $t('dashboard-design.widgets.announcement.markAllRead') }}
</ElButton>
</div>
<ElScrollbar class="flex-1">
<div v-if="messages.length > 0" class="space-y-2">
<div
v-for="msg in messages"
:key="msg.id"
class="flex cursor-pointer items-start gap-2 rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
:class="{ 'opacity-60': msg.status === 'read' }"
@click="viewDetail(msg)"
>
<ElTag
:type="getTypeConfig(msg.msg_type).type"
size="small"
class="flex-shrink-0"
>
{{ getTypeConfig(msg.msg_type).label }}
</ElTag>
<div class="min-w-0 flex-1">
<div class="truncate text-sm">{{ msg.title }}</div>
<div class="text-muted-foreground mt-1 flex items-center text-xs">
<span v-if="msg.content" class="flex-1 truncate">{{
msg.content
}}</span>
<span class="ml-2 flex-shrink-0">{{
formatTime(msg.created_at)
}}</span>
</div>
</div>
</div>
</div>
<ElEmpty
v-else
:description="
$t('message.noData') ||
$t('dashboard-design.widgets.announcement.noData')
"
:image-size="60"
/>
</ElScrollbar>
<!-- 消息详情弹窗 -->
<ElDialog
v-model="detailVisible"
:title="currentMessage?.title"
width="600px"
destroy-on-close
append-to-body
>
<div v-if="currentMessage" class="min-h-[500px] space-y-4">
<div class="flex items-center gap-2">
<ElTag
:type="getTypeConfig(currentMessage.msg_type).type"
size="small"
>
{{ getTypeConfig(currentMessage.msg_type).label }}
</ElTag>
<span class="text-muted-foreground text-xs">{{
formatTime(currentMessage.created_at)
}}</span>
</div>
<div class="whitespace-pre-wrap text-sm leading-relaxed">
{{
currentMessage.content ||
$t('dashboard-design.widgets.announcement.noContent')
}}
</div>
<!-- 审批链接按钮 -->
<div
v-if="currentMessage.link_type && currentMessage.link_id"
class="mt-4"
>
<ElButton type="text" @click="handleGoToLink(currentMessage)">
<ExternalLink class="mr-1 h-3.5 w-3.5" />
查看详情
</ElButton>
</div>
<div
v-if="currentMessage.sender_name"
class="text-muted-foreground text-xs"
>
{{ $t('message.sender') || '发送者:'
}}{{ currentMessage.sender_name }}
</div>
</div>
</ElDialog>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,120 @@
<script setup lang="ts">
import type { DashboardWidget } from '#/components/dashboard-design';
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Grid, IconifyIcon } from '@vben/icons';
import { useAppContextStore } from '#/store/app-context';
const props = defineProps<{
widget: DashboardWidget;
}>();
const router = useRouter();
const appContextStore = useAppContextStore();
// 计算网格样式
const gridStyle = computed(() => {
const cols = props.widget.props.columns || 4;
const rows = props.widget.props.rows || 2;
return {
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${rows}, minmax(0, 1fr))`,
};
});
// 计算总格子数
const totalCells = computed(() => {
const cols = props.widget.props.columns || 4;
const rows = props.widget.props.rows || 2;
return cols * rows;
});
// 获取指定位置的菜单
const getMenuAt = (index: number) => {
const menus = props.widget.props.menus || [];
return menus[index] || null;
};
// 图标颜色样式
const iconColorStyle = computed(() => {
const color = props.widget.props.iconColor;
return color ? { color } : {};
});
// 获取 item 背景样式(支持渐变色)
const getItemBgStyle = (menu: any) => {
if (!menu?.bgColor) return {};
if (menu.bgColor.includes('gradient')) {
return { background: menu.bgColor };
}
return { backgroundColor: menu.bgColor };
};
// 点击菜单项
const handleClick = (menu: any) => {
if (!menu || !menu.path) return;
// 外链
if (menu.path.startsWith('http://') || menu.path.startsWith('https://')) {
window.open(menu.path, '_blank');
return;
}
// 路由跳转(子应用模式下始终使用 /app/{code} 前缀)
const code = appContextStore.appCode;
const targetPath = code ? `/app/${code}${menu.path}` : menu.path;
router.push(targetPath);
};
</script>
<template>
<div class="quick-links flex h-full flex-col p-3">
<div class="mb-3 flex items-center gap-2">
<Grid class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
</div>
<div class="grid flex-1 gap-2" :style="gridStyle">
<template v-for="idx in totalCells" :key="idx">
<div
v-if="getMenuAt(idx - 1)"
class="flex cursor-pointer flex-col items-center justify-center gap-1 rounded-lg p-2 transition-colors hover:bg-gray-100 dark:hover:bg-gray-800"
:style="getItemBgStyle(getMenuAt(idx - 1))"
@click="handleClick(getMenuAt(idx - 1))"
>
<IconifyIcon
v-if="getMenuAt(idx - 1)?.icon"
:icon="getMenuAt(idx - 1).icon"
class="h-6 w-6"
:class="{ 'text-primary': !widget.props.iconColor }"
:style="iconColorStyle"
/>
<Grid
v-else
class="h-6 w-6"
:class="{ 'text-primary': !widget.props.iconColor }"
:style="iconColorStyle"
/>
<span
class="text-muted-foreground w-full truncate text-center text-xs"
>{{ getMenuAt(idx - 1)?.title }}</span
>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-1 rounded-lg p-2"
>
<!-- 空位占位 -->
</div>
</template>
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,294 @@
<script setup lang="ts">
import type {
RealtimeStats,
ServerMonitorResponse,
} from '#/api/core/server-monitor';
import type { DashboardWidget } from '#/components/dashboard-design';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Cpu, Database, HardDrive, Network } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElProgress } from 'element-plus';
import {
getRealtimeStatsApi,
getServerOverviewApi,
} from '#/api/core/server-monitor';
const props = defineProps<{
widget: DashboardWidget;
}>();
const loading = ref(false);
const serverData = ref<null | ServerMonitorResponse>(null);
const realtimeData = ref<null | RealtimeStats>(null);
let timer: null | ReturnType<typeof setInterval> = null;
// 刷新间隔
const refreshInterval = computed(
() => props.widget.props.refreshInterval || 5000,
);
// 区域背景色
function getAreaStyle(colorProp: string) {
const color = props.widget.props[colorProp];
if (!color) return {};
if (color.includes('gradient')) return { background: color };
return { backgroundColor: color };
}
// 格式化字节
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / k ** i).toFixed(1)} ${sizes[i]}`;
}
// 格式化内存(后端返回 GB
function formatMemory(gb: number): string {
if (gb === 0) return '0 GB';
if (gb < 1) return `${(gb * 1024).toFixed(0)} MB`;
return `${gb.toFixed(1)} GB`;
}
// 格式化速度
function formatSpeed(bytesPerSecond: number): string {
return `${formatBytes(bytesPerSecond)}/s`;
}
// 使用率颜色
function getProgressColor(percent: number): string {
if (percent >= 90) return 'var(--el-color-danger)';
if (percent >= 70) return 'var(--el-color-warning)';
return 'var(--el-color-success)';
}
// 格式化运行时间
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86_400);
const hours = Math.floor((seconds % 86_400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const parts = [];
if (days > 0)
parts.push(`${days}${$t('dashboard-design.widgets.serverMonitor.days')}`);
if (hours > 0)
parts.push(`${hours}${$t('dashboard-design.widgets.serverMonitor.hours')}`);
if (minutes > 0)
parts.push(
`${minutes}${$t('dashboard-design.widgets.serverMonitor.minutes')}`,
);
return parts.join(' ') || '-';
}
// 加载数据
async function loadData(showLoading = false) {
if (showLoading) loading.value = true;
try {
const [overview, realtime] = await Promise.all([
getServerOverviewApi(),
getRealtimeStatsApi(),
]);
serverData.value = overview;
realtimeData.value = realtime;
} catch (error) {
console.error('Failed to load server monitor data:', error);
} finally {
if (showLoading) loading.value = false;
}
}
// 自动刷新
function startAutoRefresh() {
if (timer) return;
timer = setInterval(() => {
getRealtimeStatsApi()
.then((data) => {
realtimeData.value = data;
})
.catch(() => {});
}, refreshInterval.value);
}
function stopAutoRefresh() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
onMounted(async () => {
await loadData(true);
startAutoRefresh();
});
onUnmounted(() => {
stopAutoRefresh();
});
</script>
<template>
<div class="server-monitor flex h-full flex-col p-4" v-loading="loading">
<!-- 头部 -->
<div class="mb-6 flex items-center justify-between">
<span class="text-sm font-medium">{{ widget.props.title }}</span>
<span class="text-muted-foreground text-xs">
{{ serverData?.basic_info?.hostname || '-' }}
({{ serverData?.basic_info?.ip_address || '-' }})
</span>
</div>
<!-- 指标网格 -->
<div class="grid max-h-[150px] flex-1 grid-cols-2 gap-3 lg:grid-cols-4">
<!-- CPU -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.cpuBgColor }"
:style="getAreaStyle('cpuBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(59, 130, 246, 0.15)"
>
<Cpu class="h-4 w-4" style="color: var(--el-color-primary)" />
</div>
<span class="text-muted-foreground text-xs">CPU</span>
</div>
<div class="mb-1 text-xl font-bold">
{{ realtimeData?.cpu_percent?.toFixed(1) || '0.0' }}%
</div>
<ElProgress
:percentage="Number(realtimeData?.cpu_percent?.toFixed(1) || 0)"
:color="getProgressColor(realtimeData?.cpu_percent || 0)"
:show-text="false"
:stroke-width="4"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ serverData?.cpu_info?.physical_cores || 0
}}{{ $t('dashboard-design.widgets.serverMonitor.core') }}
{{ serverData?.cpu_info?.total_cores || 0
}}{{ $t('dashboard-design.widgets.serverMonitor.thread') }}
</div>
</div>
<!-- 内存 -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.memoryBgColor }"
:style="getAreaStyle('memoryBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(34, 197, 94, 0.15)"
>
<Database class="h-4 w-4" style="color: var(--el-color-success)" />
</div>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.memory')
}}</span>
</div>
<div class="mb-1 text-xl font-bold">
{{ realtimeData?.memory_percent?.toFixed(1) || '0.0' }}%
</div>
<ElProgress
:percentage="Number(realtimeData?.memory_percent?.toFixed(1) || 0)"
:color="getProgressColor(realtimeData?.memory_percent || 0)"
:show-text="false"
:stroke-width="4"
/>
<div class="text-muted-foreground mt-1 text-xs">
{{ formatMemory(realtimeData?.memory_details?.used || 0) }}
/ {{ formatMemory(realtimeData?.memory_details?.total || 0) }}
</div>
</div>
<!-- 磁盘 -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.diskBgColor }"
:style="getAreaStyle('diskBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(139, 92, 246, 0.15)"
>
<HardDrive class="h-4 w-4" style="color: var(--el-color-warning)" />
</div>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.disk')
}}</span>
</div>
<div class="space-y-0.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.read')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.disk_io?.read_speed || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.write')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.disk_io?.write_speed || 0)
}}</span>
</div>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('dashboard-design.widgets.serverMonitor.totalRW') }}:
{{ formatBytes(realtimeData?.disk_total?.read_bytes || 0) }} /
{{ formatBytes(realtimeData?.disk_total?.write_bytes || 0) }}
</div>
</div>
<!-- 网络 -->
<div
class="flex flex-col justify-between rounded-lg p-4"
:class="{ 'bg-secondary/50': !widget.props.networkBgColor }"
:style="getAreaStyle('networkBgColor')"
>
<div class="mb-2 flex items-center gap-2">
<div
class="flex h-7 w-7 items-center justify-center rounded-md"
style="background: rgba(249, 115, 22, 0.15)"
>
<Network class="h-4 w-4" style="color: var(--el-color-danger)" />
</div>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.network')
}}</span>
</div>
<div class="space-y-0.5 text-sm">
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.upload')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.network_io?.upload_speed || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.serverMonitor.download')
}}</span>
<span class="text-xs font-medium">{{
formatSpeed(realtimeData?.network_io?.download_speed || 0)
}}</span>
</div>
</div>
<div class="text-muted-foreground mt-1 text-xs">
{{ $t('dashboard-design.widgets.serverMonitor.uptime') }}:
{{ formatUptime(serverData?.boot_time?.uptime_seconds || 0) }}
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,364 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
Cloud,
CloudDrizzle,
CloudFog,
CloudRain,
CloudSnow,
CloudSun,
Sun,
Zap,
} from '@vben/icons';
import { $t } from '@vben/locales';
const props = defineProps<{
widget: DashboardWidget;
}>();
// 实时天气数据
const weatherData = ref<null | {
humidity: number;
temperature: number;
weatherCode: number;
windDirection: number;
windSpeed: number;
}>(null);
const error = ref(false);
const locatedCityName = ref('');
let timer: null | ReturnType<typeof setInterval> = null;
// 显示的城市名:优先手动配置,其次自动定位
const displayCityName = computed(() => {
return props.widget.props.cityName || locatedCityName.value || '-';
});
// 反向地理编码获取城市名
async function reverseGeocode(lat: number, lon: number) {
try {
const res = await fetch(
`https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${lat}&longitude=${lon}&localityLanguage=zh`,
);
if (!res.ok) return;
const data = await res.json();
locatedCityName.value =
data?.city || data?.locality || data?.principalSubdivision || '';
} catch {
// 反向编码失败不影响天气显示
}
}
// WMO Weather Code 映射
function getWeatherInfo(code: number): {
color: string;
icon: any;
label: string;
} {
// 晴天
if (code === 0)
return {
icon: Sun,
color: '#f59e0b',
label: $t('dashboard-design.widgets.weather.codes.clear'),
};
// 少云/多云
if (code === 1)
return {
icon: CloudSun,
color: '#60a5fa',
label: $t('dashboard-design.widgets.weather.codes.mainlyClear'),
};
if (code === 2)
return {
icon: CloudSun,
color: '#60a5fa',
label: $t('dashboard-design.widgets.weather.codes.partlyCloudy'),
};
if (code === 3)
return {
icon: Cloud,
color: '#9ca3af',
label: $t('dashboard-design.widgets.weather.codes.overcast'),
};
// 雾
if (code === 45 || code === 48)
return {
icon: CloudFog,
color: '#9ca3af',
label: $t('dashboard-design.widgets.weather.codes.fog'),
};
// 毛毛雨
if (code >= 51 && code <= 57)
return {
icon: CloudDrizzle,
color: '#60a5fa',
label: $t('dashboard-design.widgets.weather.codes.drizzle'),
};
// 雨
if (code >= 61 && code <= 67)
return {
icon: CloudRain,
color: '#3b82f6',
label: $t('dashboard-design.widgets.weather.codes.rain'),
};
// 雪
if (code >= 71 && code <= 77)
return {
icon: CloudSnow,
color: '#a5b4fc',
label: $t('dashboard-design.widgets.weather.codes.snow'),
};
// 阵雨
if (code >= 80 && code <= 82)
return {
icon: CloudRain,
color: '#3b82f6',
label: $t('dashboard-design.widgets.weather.codes.showers'),
};
// 阵雪
if (code >= 85 && code <= 86)
return {
icon: CloudSnow,
color: '#a5b4fc',
label: $t('dashboard-design.widgets.weather.codes.snowShowers'),
};
// 雷暴
if (code >= 95 && code <= 99)
return {
icon: Zap,
color: '#eab308',
label: $t('dashboard-design.widgets.weather.codes.thunderstorm'),
};
return {
icon: Sun,
color: '#f59e0b',
label: $t('dashboard-design.widgets.weather.codes.clear'),
};
}
// 风向角度转文字
function getWindDirection(degree: number): string {
const dirs = [
$t('dashboard-design.widgets.weather.windDir.n'),
$t('dashboard-design.widgets.weather.windDir.ne'),
$t('dashboard-design.widgets.weather.windDir.e'),
$t('dashboard-design.widgets.weather.windDir.se'),
$t('dashboard-design.widgets.weather.windDir.s'),
$t('dashboard-design.widgets.weather.windDir.sw'),
$t('dashboard-design.widgets.weather.windDir.w'),
$t('dashboard-design.widgets.weather.windDir.nw'),
];
const index = Math.round(degree / 45) % 8;
return dirs[index] || '';
}
// 当前天气信息
const currentWeather = computed(() => {
if (!weatherData.value) return null;
const info = getWeatherInfo(weatherData.value.weatherCode);
return {
...info,
temperature: Math.round(weatherData.value.temperature),
humidity: weatherData.value.humidity,
wind: `${getWindDirection(weatherData.value.windDirection)} ${weatherData.value.windSpeed.toFixed(0)} km/h`,
};
});
// 获取天气数据
async function fetchWeather() {
const lat = props.widget.props.latitude;
const lon = props.widget.props.longitude;
if (!lat || !lon) return;
try {
error.value = false;
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m&timezone=auto`;
const res = await fetch(url);
if (!res.ok) throw new Error('API error');
const data = await res.json();
if (data.current) {
weatherData.value = {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
weatherCode: data.current.weather_code,
windSpeed: data.current.wind_speed_10m,
windDirection: data.current.wind_direction_10m,
};
}
} catch {
error.value = true;
console.error('Failed to fetch weather data');
}
}
// 自动刷新
function startAutoRefresh() {
stopAutoRefresh();
const interval = (props.widget.props.refreshInterval || 30) * 60 * 1000; // 分钟转毫秒
timer = setInterval(fetchWeather, interval);
}
function stopAutoRefresh() {
if (timer) {
clearInterval(timer);
timer = null;
}
}
// 监听经纬度变化重新获取
watch(
() => [props.widget.props.latitude, props.widget.props.longitude],
() => {
fetchWeather();
},
);
onMounted(async () => {
// 如果开启自动定位且没有经纬度
if (props.widget.props.autoLocate && !props.widget.props.latitude) {
try {
const pos = await new Promise<GeolocationPosition>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
timeout: 5000,
});
});
const lat = pos.coords.latitude;
const lon = pos.coords.longitude;
// 并行获取天气数据和城市名
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&current=temperature_2m,relative_humidity_2m,weather_code,wind_speed_10m,wind_direction_10m&timezone=auto`;
const [res] = await Promise.all([fetch(url), reverseGeocode(lat, lon)]);
const data = await res.json();
if (data.current) {
weatherData.value = {
temperature: data.current.temperature_2m,
humidity: data.current.relative_humidity_2m,
weatherCode: data.current.weather_code,
windSpeed: data.current.wind_speed_10m,
windDirection: data.current.wind_direction_10m,
};
}
} catch {
// 定位失败,使用默认经纬度
await fetchWeather();
}
} else {
await fetchWeather();
}
startAutoRefresh();
});
onUnmounted(() => {
stopAutoRefresh();
});
</script>
<template>
<div class="weather-widget flex h-full flex-col p-3">
<div
v-if="widget.props.title"
class="text-muted-foreground mb-2 text-sm font-medium"
>
{{ widget.props.title }}
</div>
<!-- 加载/错误状态 -->
<div
v-if="!currentWeather && !error"
class="flex flex-1 items-center justify-center"
>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.weather.loading')
}}</span>
</div>
<div
v-else-if="error && !currentWeather"
class="flex flex-1 items-center justify-center"
>
<span class="text-muted-foreground text-xs">{{
$t('dashboard-design.widgets.weather.error')
}}</span>
</div>
<!-- 天气数据 -->
<div v-else-if="currentWeather" class="flex flex-1 items-center gap-4">
<!-- 天气图标和温度 -->
<div class="flex items-center gap-3">
<component
:is="currentWeather.icon"
class="h-12 w-12"
:style="{ color: currentWeather.color }"
/>
<div>
<div class="temperature">{{ currentWeather.temperature }}°</div>
<div class="weather-text">
{{ currentWeather.label }}
</div>
</div>
</div>
<!-- 详细信息 -->
<div class="weather-details">
<div class="detail-item">
<span class="detail-label">{{
$t('dashboard-design.widgets.weather.city')
}}</span>
<span class="detail-value">{{ displayCityName }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{
$t('dashboard-design.widgets.weather.humidity')
}}</span>
<span class="detail-value">{{ currentWeather.humidity }}%</span>
</div>
<div class="detail-item">
<span class="detail-label">{{
$t('dashboard-design.widgets.weather.wind')
}}</span>
<span class="detail-value">{{ currentWeather.wind }}</span>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.temperature {
font-size: 2rem;
font-weight: 600;
line-height: 1;
color: var(--el-text-color-primary);
}
.weather-text {
margin-top: 4px;
font-size: 0.875rem;
color: var(--el-text-color-secondary);
}
.weather-details {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 16px;
border-left: 1px solid var(--el-border-color-lighter);
}
.detail-item {
display: flex;
gap: 8px;
align-items: center;
font-size: 0.75rem;
}
.detail-label {
min-width: 32px;
color: var(--el-text-color-secondary);
}
.detail-value {
color: var(--el-text-color-primary);
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../store/dashboardDesignStore';
import { computed, onMounted, onUnmounted, ref } from 'vue';
import { Smile } from '@vben/icons';
import { $t } from '@vben/locales';
import { useUserStore } from '@vben/stores';
import { UserAvatar } from '#/components/user-avatar';
defineProps<{
widget: DashboardWidget;
}>();
const userStore = useUserStore();
// 用户信息
const userInfo = computed(() => userStore.userInfo);
// 用户名
const userName = computed(() => userStore.userInfo?.realName || '');
const currentTime = ref(new Date());
let timer: null | ReturnType<typeof setInterval> = null;
onMounted(() => {
timer = setInterval(() => {
currentTime.value = new Date();
}, 1000);
});
onUnmounted(() => {
if (timer) {
clearInterval(timer);
}
});
const greeting = computed(() => {
const hour = currentTime.value.getHours();
if (hour < 6) return $t('dashboard-design.widgets.welcome.greeting.night');
if (hour < 9) return $t('dashboard-design.widgets.welcome.greeting.morning');
if (hour < 12)
return $t('dashboard-design.widgets.welcome.greeting.morning2');
if (hour < 14) return $t('dashboard-design.widgets.welcome.greeting.noon');
if (hour < 18)
return $t('dashboard-design.widgets.welcome.greeting.afternoon');
if (hour < 22) return $t('dashboard-design.widgets.welcome.greeting.evening');
return $t('dashboard-design.widgets.welcome.greeting.night');
});
const formattedTime = computed(() => {
return currentTime.value.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
});
const formattedDate = computed(() => {
return currentTime.value.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
});
});
</script>
<template>
<div class="welcome-card flex h-full items-center justify-between p-4">
<div class="flex items-center gap-4">
<!-- 用户头像 -->
<UserAvatar
v-if="userInfo"
:name="userName"
:avatar="userInfo.avatar"
:size="48"
:font-size="20"
:show-popover="false"
:shadow="false"
class="flex-shrink-0"
/>
<div
v-else
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full"
style="
background: linear-gradient(
135deg,
var(--el-color-primary-light-3),
var(--el-color-primary)
);
"
>
<Smile class="h-6 w-6 text-white" />
</div>
<div>
<div class="text-lg font-semibold">
{{ greeting }}{{ userName
}}{{ widget.props.title ? `${widget.props.title}` : '' }}
</div>
<div class="text-muted-foreground text-sm">
{{ widget.props.subtitle }}
</div>
</div>
</div>
<div v-if="widget.props.showTime" class="text-right">
<div class="text-2xl font-bold tabular-nums">{{ formattedTime }}</div>
<div class="text-muted-foreground text-sm">{{ formattedDate }}</div>
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,12 @@
export { default as DashboardRenderer } from './DashboardRenderer.vue';
export type {
DashboardConfig,
DashboardWidget,
DataSourceConfig,
DataSourceType,
WidgetMaterial,
WidgetType,
} from './store/dashboardDesignStore';
export { createRefreshTimer, fetchWidgetData } from './utils/dataFetcher';
@@ -0,0 +1,314 @@
import type {
DataSourceConfig,
FieldMapping,
} from '../store/dashboardDesignStore';
import { $t } from '@vben/locales';
import { requestClient } from '#/api/request';
/**
* 根据路径获取对象中的值
* @param obj 对象
* @param path 路径,如 'data.list' 或 'data.items[0].name'
*/
export function getValueByPath(obj: any, path: string): any {
if (!obj || !path) return obj;
const keys = path.replaceAll(/\[(\d+)\]/g, '.$1').split('.');
let result = obj;
for (const key of keys) {
if (result === null || result === undefined) return undefined;
result = result[key];
}
return result;
}
/**
* 根据路径设置对象中的值
*/
export function setValueByPath(obj: any, path: string, value: any): void {
if (!obj || !path) return;
const keys = path.split('.');
let current = obj;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i]!;
if (current[key] === undefined) {
current[key] = {};
}
current = current[key];
}
current[keys[keys.length - 1]!] = value;
}
/**
* 应用字段映射
*/
export function applyFieldMappings(
data: any,
mappings: FieldMapping[] | undefined,
targetProps: Record<string, any>,
): Record<string, any> {
if (!mappings || mappings.length === 0) {
return { ...targetProps, ...data };
}
const result = { ...targetProps };
for (const mapping of mappings) {
const value = getValueByPath(data, mapping.source);
if (value !== undefined) {
setValueByPath(result, mapping.target, value);
}
}
return result;
}
/**
* 数据获取工具
*/
export async function fetchWidgetData(
dataSource: DataSourceConfig | undefined,
defaultProps: Record<string, any>,
params?: Record<string, any>,
): Promise<{ data: any; props: Record<string, any> }> {
if (!dataSource || dataSource.type === 'static') {
return { data: null, props: defaultProps };
}
// 通用数据源类型
if (dataSource.type === 'dataSource' && dataSource.dataSourceCode) {
try {
const response = await requestClient.get(
`/api/core/data-source/execute/${dataSource.dataSourceCode}`,
{ params: params && Object.keys(params).length > 0 ? params : undefined },
);
// 后端返回格式是 {data: ...},需要提取 data 字段
const rawData = response?.data ?? response;
// 数据源返回的数据可能是数组或对象(图表数据源返回 {xAxisData, seriesData} 格式)
const extractedData = dataSource.dataPath
? getValueByPath(rawData, dataSource.dataPath)
: rawData;
// 检查是否是图表数据格式(包含 xAxisData 或 seriesData 或 indicator 或 value
if (
extractedData &&
typeof extractedData === 'object' &&
!Array.isArray(extractedData) &&
('xAxisData' in extractedData ||
'seriesData' in extractedData ||
'indicator' in extractedData ||
'yAxisData' in extractedData ||
('value' in extractedData && 'max' in extractedData))
) {
// 图表数据格式,直接合并到 props
const mappedProps = { ...defaultProps, ...extractedData };
// 如果有额外的字段映射,也应用
if (dataSource.fieldMappings && dataSource.fieldMappings.length > 0) {
return {
data: extractedData,
props: applyFieldMappings(
extractedData,
dataSource.fieldMappings,
mappedProps,
),
};
}
return { data: extractedData, props: mappedProps };
}
// 普通数据,应用字段映射
const mappedProps = applyFieldMappings(
extractedData,
dataSource.fieldMappings,
defaultProps,
);
return { data: extractedData, props: mappedProps };
} catch (error) {
console.error('Failed to fetch data source:', error);
return { data: null, props: defaultProps };
}
}
// API 类型
if (dataSource.type === 'api' && dataSource.apiUrl) {
try {
const method = dataSource.apiMethod || 'GET';
const params = dataSource.apiParams || {};
const body = dataSource.apiBody || {};
const headers = dataSource.apiHeaders || {};
let response: any;
response = await (method === 'GET'
? requestClient.get(dataSource.apiUrl, {
params,
headers,
})
: requestClient.post(dataSource.apiUrl, body, {
params,
headers,
}));
// 根据 dataPath 提取数据
const extractedData = dataSource.dataPath
? getValueByPath(response, dataSource.dataPath)
: response;
// 应用字段映射
const mappedProps = applyFieldMappings(
extractedData,
dataSource.fieldMappings,
defaultProps,
);
return { data: extractedData, props: mappedProps };
} catch (error) {
console.error('Failed to fetch widget data:', error);
return { data: null, props: defaultProps };
}
}
return { data: null, props: defaultProps };
}
/**
* 创建自动刷新定时器
*/
export function createRefreshTimer(
dataSource: DataSourceConfig | undefined,
callback: () => void,
): (() => void) | null {
if (
!dataSource?.refreshEnabled ||
!dataSource.refreshInterval ||
dataSource.refreshInterval <= 0
) {
return null;
}
const timer = setInterval(callback, dataSource.refreshInterval * 1000);
return () => clearInterval(timer);
}
/**
* 获取组件支持的字段映射目标
*/
export function getWidgetFieldTargets(
widgetType: string,
): { key: string; label: string }[] {
const commonFields = [
{ key: 'title', label: $t('dashboard-design.attribute.fieldLabels.title') },
];
const fieldMap: Record<string, { key: string; label: string }[]> = {
'stat-card': [
...commonFields,
{
key: 'value',
label: $t('dashboard-design.attribute.fieldLabels.value'),
},
{
key: 'trend',
label: $t('dashboard-design.attribute.fieldLabels.trend'),
},
{
key: 'trendLabel',
label: $t('dashboard-design.attribute.fieldLabels.trendLabel'),
},
{
key: 'prefix',
label: $t('dashboard-design.attribute.fieldLabels.prefix'),
},
{
key: 'suffix',
label: $t('dashboard-design.attribute.fieldLabels.suffix'),
},
],
'progress-card': [
...commonFields,
{
key: 'percentage',
label: $t('dashboard-design.attribute.fieldLabels.percentage'),
},
],
'chart-line': [
...commonFields,
{
key: 'xAxisData',
label: $t('dashboard-design.attribute.fieldLabels.xAxisData'),
},
{
key: 'seriesData',
label: $t('dashboard-design.attribute.fieldLabels.seriesData'),
},
],
'chart-bar': [
...commonFields,
{
key: 'xAxisData',
label: $t('dashboard-design.attribute.fieldLabels.xAxisData'),
},
{
key: 'seriesData',
label: $t('dashboard-design.attribute.fieldLabels.seriesData'),
},
],
'chart-pie': [
...commonFields,
{
key: 'seriesData',
label: $t('dashboard-design.attribute.fieldLabels.seriesData'),
},
],
'chart-gauge': [
...commonFields,
{
key: 'value',
label: $t('dashboard-design.attribute.fieldLabels.currentValue'),
},
{ key: 'min', label: $t('dashboard-design.attribute.fieldLabels.min') },
{ key: 'max', label: $t('dashboard-design.attribute.fieldLabels.max') },
],
'todo-list': [
...commonFields,
{
key: 'items',
label: $t('dashboard-design.attribute.fieldLabels.listData'),
},
],
'notice-list': [
...commonFields,
{
key: 'items',
label: $t('dashboard-design.attribute.fieldLabels.listData'),
},
],
'ranking-list': [
...commonFields,
{
key: 'items',
label: $t('dashboard-design.attribute.fieldLabels.listData'),
},
],
'quick-links': [
...commonFields,
{
key: 'links',
label: $t('dashboard-design.attribute.fieldLabels.linkData'),
},
],
};
return fieldMap[widgetType] || commonFields;
}