Track dashboard render runtime files

This commit is contained in:
2026-06-10 13:24:23 +08:00
parent 53e924379a
commit bf03a72669
39 changed files with 7887 additions and 0 deletions
+1
View File
@@ -59,6 +59,7 @@
"cron-parser": "^4.9.0",
"dayjs": "catalog:",
"element-plus": "catalog:",
"grid-layout-plus": "1.1.1",
"pinia": "catalog:",
"vue": "catalog:",
"vue-router": "catalog:",
@@ -0,0 +1,20 @@
import { requestClient } from '#/api/request';
export interface PageMeta {
id: string;
application_id?: string;
name: string;
code: string;
category: string;
description: string;
status: string;
version: number;
page_config: Record<string, any>;
sort: number;
sys_create_datetime: string;
sys_update_datetime: string;
}
export async function getPageByCodeApi(code: string) {
return requestClient.get<PageMeta>(`/api/online_dev/page/code/${code}`);
}
@@ -0,0 +1,135 @@
<script setup lang="ts">
import type { DashboardConfig } from './types';
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<{
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,
}));
});
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;
};
const outerMarginStyle = computed(() => {
const config = dashboardConfig.value;
if (!config || config.showOuterMargin !== false) return {};
const margin = config.margin || [12, 12];
return {
marginLeft: `-${margin[0]}px`,
marginRight: `-${margin[0]}px`,
marginTop: `-${margin[1]}px`,
width: `calc(100% + ${margin[0] * 2}px)`,
};
});
</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="outerMarginStyle"
>
<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,357 @@
<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>
> = {
'stat-card': defineAsyncComponent(() => import('./widgets/StatCard.vue')),
'progress-card': defineAsyncComponent(
() => import('./widgets/ProgressCard.vue'),
),
'todo-list': defineAsyncComponent(() => import('./widgets/TodoList.vue')),
'notice-list': defineAsyncComponent(() => import('./widgets/NoticeList.vue')),
'ranking-list': defineAsyncComponent(
() => import('./widgets/RankingList.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')),
'image-carousel': defineAsyncComponent(
() => import('./widgets/ImageCarousel.vue'),
),
'data-table': defineAsyncComponent(() => import('./widgets/DataTable.vue')),
iframe: defineAsyncComponent(() => import('./widgets/IframeWidget.vue')),
'video-player': defineAsyncComponent(
() => import('./widgets/VideoPlayer.vue'),
),
image: defineAsyncComponent(() => import('./widgets/ImageWidget.vue')),
'approval-center': defineAsyncComponent(
() => import('./widgets/ApprovalCenter.vue'),
),
'my-apps': defineAsyncComponent(() => import('./widgets/MyApps.vue')),
'server-monitor': defineAsyncComponent(
() => import('./widgets/ServerMonitor.vue'),
),
'filter-input': defineAsyncComponent(
() => import('./widgets/FilterInput.vue'),
),
'filter-select': defineAsyncComponent(
() => import('./widgets/FilterSelect.vue'),
),
'filter-date': defineAsyncComponent(
() => import('./widgets/FilterDate.vue'),
),
'filter-date-range': defineAsyncComponent(
() => import('./widgets/FilterDateRange.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>
@@ -0,0 +1,298 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
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 '../../types';
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 '../../types';
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 '../../types';
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,285 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed } from 'vue';
import { $t } from '@vben/locales';
import { ElEmpty, ElTable, ElTableColumn } from 'element-plus';
const props = defineProps<{
widget: DashboardWidget;
}>();
const columns = computed(() => props.widget.props.columns || []);
const tableData = computed(() => props.widget.props.data || []);
// 表格高度配置
const tableHeight = computed(() => {
if (props.widget.props.maxHeight) return undefined;
return props.widget.props.height || '100%';
});
// 状态颜色映射
const getStatusClass = (status: string) => {
const statusMap: Record<string, string> = {
[$t('dashboard-design.widgets.dataTable.status.normal')]: 'status-success',
[$t('dashboard-design.widgets.dataTable.status.warning')]: 'status-warning',
[$t('dashboard-design.widgets.dataTable.status.error')]: 'status-danger',
[$t('dashboard-design.widgets.dataTable.status.success')]: 'status-success',
[$t('dashboard-design.widgets.dataTable.status.failed')]: 'status-danger',
[$t('dashboard-design.widgets.dataTable.status.processing')]: 'status-info',
};
return statusMap[status] || '';
};
// 统计类型国际化标签
const summaryTypeLabels = computed(() => ({
sum: $t('dashboard-design.widgets.dataTable.summary.sum'),
avg: $t('dashboard-design.widgets.dataTable.summary.avg'),
count: $t('dashboard-design.widgets.dataTable.summary.count'),
max: $t('dashboard-design.widgets.dataTable.summary.max'),
min: $t('dashboard-design.widgets.dataTable.summary.min'),
}));
// 表尾统计方法
const getSummaryMethod = (param: { columns: any[]; data: any[] }) => {
const { columns: tableCols, data } = param;
const sums: string[] = [];
const summaryColumns = props.widget.props.summaryColumns || [];
const summaryType = props.widget.props.summaryType || 'sum';
const precision = props.widget.props.summaryPrecision ?? 2;
tableCols.forEach((column, index) => {
// 第一列显示统计类型名称
if (index === 0) {
sums[index] =
summaryTypeLabels.value[
summaryType as keyof typeof summaryTypeLabels.value
] || summaryType;
return;
}
// 查找该列的统计配置
const summaryConfig = summaryColumns.find(
(sc: any) => sc.field === column.property,
);
if (!summaryConfig || !summaryConfig.enabled) {
sums[index] = '';
return;
}
// 获取该列的所有数值
const values = data.map((item) => Number(item[column.property]));
const validValues = values.filter((value) => !Number.isNaN(value));
if (validValues.length === 0) {
sums[index] = '-';
return;
}
let result: number;
switch (summaryType) {
case 'avg': {
result =
validValues.reduce((acc, val) => acc + val, 0) / validValues.length;
break;
}
case 'count': {
result = validValues.length;
break;
}
case 'max': {
result = Math.max(...validValues);
break;
}
case 'min': {
result = Math.min(...validValues);
break;
}
case 'sum': {
result = validValues.reduce((acc, val) => acc + val, 0);
break;
}
default: {
result = validValues.reduce((acc, val) => acc + val, 0);
}
}
sums[index] = result.toFixed(precision);
});
return sums;
};
// 合并单元格方法
const getSpanMethod = ({ row, column, rowIndex, columnIndex }: any) => {
const mergeConfig = props.widget.props.mergeConfig;
if (!mergeConfig || !mergeConfig.enabled) return;
const mergeRules = mergeConfig.rules || [];
for (const rule of mergeRules) {
if (rule.type === 'row' && rule.field === column.property) {
// 行合并:相同值的相邻行合并
const data = tableData.value;
const currentValue = row[rule.field];
// 检查是否是合并组的第一行
if (rowIndex === 0 || data[rowIndex - 1][rule.field] !== currentValue) {
let rowspan = 1;
for (let i = rowIndex + 1; i < data.length; i++) {
if (data[i][rule.field] === currentValue) {
rowspan++;
} else {
break;
}
}
return { rowspan, colspan: 1 };
} else {
// 被合并的行
return { rowspan: 0, colspan: 0 };
}
}
if (rule.type === 'column' && rowIndex === rule.rowIndex) {
// 列合并
if (columnIndex === rule.startCol) {
return { rowspan: 1, colspan: rule.colspan || 1 };
} else if (
columnIndex > rule.startCol &&
columnIndex < rule.startCol + (rule.colspan || 1)
) {
return { rowspan: 0, colspan: 0 };
}
}
}
};
</script>
<template>
<div class="data-table-widget flex h-full w-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="table-container">
<ElTable
:data="tableData"
:stripe="widget.props.stripe !== false"
:border="widget.props.border"
:size="widget.props.size || 'default'"
:height="tableHeight"
:max-height="widget.props.maxHeight"
:highlight-current-row="widget.props.highlightCurrentRow"
:show-header="widget.props.showHeader !== false"
:empty-text="widget.props.emptyText || $t('common.noData')"
:show-summary="widget.props.showSummary"
:summary-method="
widget.props.showSummary ? getSummaryMethod : undefined
"
:span-method="
widget.props.mergeConfig?.enabled ? getSpanMethod : undefined
"
:header-cell-style="{
textAlign: widget.props.headerAlign || 'left',
...(widget.props.headerBgColor?.includes('gradient')
? { background: widget.props.headerBgColor }
: {
backgroundColor:
widget.props.headerBgColor || 'var(--el-fill-color-light)',
}),
}"
:cell-style="{
textAlign: widget.props.cellAlign || 'left',
}"
v-loading="widget.props.loading"
>
<ElTableColumn
v-if="widget.props.showIndex"
type="index"
label="#"
width="50"
/>
<ElTableColumn
v-for="col in columns"
:key="col.prop"
:prop="col.prop"
:label="col.label"
:width="col.width"
:min-width="col.minWidth"
:align="col.align || widget.props.cellAlign || 'left'"
:header-align="col.headerAlign || widget.props.headerAlign || 'left'"
:fixed="col.fixed"
:sortable="col.sortable"
:show-overflow-tooltip="col.showOverflowTooltip !== false"
>
<template #default="{ row }">
<span
v-if="col.prop === 'status'"
:class="getStatusClass(row[col.prop])"
>
{{ row[col.prop] }}
</span>
<span v-else>{{ row[col.prop] }}</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty
:description="widget.props.emptyText || $t('common.noData')"
:image-size="80"
/>
</template>
</ElTable>
</div>
</div>
</template>
<style scoped>
.data-table-widget {
width: 100%;
height: 100%;
box-sizing: border-box;
}
.table-container {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
}
.data-table-widget :deep(.el-table) {
width: 100% !important;
--el-table-header-bg-color: var(--el-fill-color-light);
}
.data-table-widget :deep(.el-table__inner-wrapper) {
width: 100%;
}
.data-table-widget :deep(.el-table__header-wrapper),
.data-table-widget :deep(.el-table__body-wrapper) {
width: 100%;
}
.status-success {
color: var(--el-color-success);
}
.status-warning {
color: var(--el-color-warning);
}
.status-danger {
color: var(--el-color-danger);
}
.status-info {
color: var(--el-color-info);
}
</style>
@@ -0,0 +1,127 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElDatePicker } from 'element-plus';
import { useDashboardRuntime } from '../../runtime';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const { updateGlobalParam } = useDashboardRuntime();
const dateValue = ref<string>(props.widget.props.defaultValue || '');
const label = computed(() => props.widget.props.label || '');
const placeholder = computed(
() =>
props.widget.props.placeholder ||
$t('dashboard-design.material.defaultProps.filterDatePlaceholder'),
);
const paramKey = computed(() => props.widget.props.paramKey || '');
const dateFormat = computed(
() => props.widget.props.dateFormat || 'YYYY-MM-DD',
);
const labelPosition = computed(
() => props.widget.props.labelPosition || 'left',
);
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
const componentSize = computed(
() => props.widget.props.componentSize || 'default',
);
const showBorder = computed(() => props.widget.props.showBorder ?? false);
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
const isTopLabel = computed(() => labelPosition.value === 'top');
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
const containerClass = computed(() => [
'filter-widget h-full w-full',
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
showBorder.value ? 'filter-widget--bordered' : '',
]);
const containerStyle = computed(() => ({
padding: isTopLabel.value ? '8px 12px' : '0 12px',
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
}));
const labelStyle = computed(() => ({
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
textAlign: labelAlign.value as 'center' | 'left' | 'right',
flexShrink: 0,
marginBottom: isTopLabel.value ? '4px' : undefined,
marginRight: isTopLabel.value ? undefined : '8px',
}));
function handleChange(val: any) {
if (props.isDesignMode) return;
if (paramKey.value) {
updateGlobalParam(paramKey.value, val || '');
}
}
watch(
() => props.widget.props.defaultValue,
(val) => {
if (!props.isDesignMode && val !== undefined) {
dateValue.value = val;
if (paramKey.value) {
updateGlobalParam(paramKey.value, val);
}
}
},
{ immediate: true },
);
</script>
<template>
<div :class="containerClass" :style="containerStyle">
<span
v-if="label && !isHiddenLabel"
class="text-foreground text-sm font-medium"
:style="labelStyle"
>
{{ label }}
</span>
<ElDatePicker
v-model="dateValue"
type="date"
:placeholder="placeholder"
:disabled="isDesignMode"
:format="dateFormat"
:value-format="dateFormat"
:size="componentSize"
clearable
class="!flex-1"
@change="handleChange"
/>
<span
v-if="isDesignMode && !paramKey"
class="ml-2 flex-shrink-0 text-xs text-orange-500"
>
{{ $t('dashboard-design.filter.noParamKey') }}
</span>
</div>
</template>
<style scoped>
.filter-widget--bordered {
border: 1px solid var(--el-border-color);
background-color: var(--el-bg-color);
}
.filter-widget.flex-col :deep(.el-input),
.filter-widget.flex-col :deep(.el-select),
.filter-widget.flex-col :deep(.el-date-editor) {
flex: none;
width: 100%;
}
</style>
@@ -0,0 +1,159 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElDatePicker } from 'element-plus';
import { useDashboardRuntime } from '../../runtime';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const { updateGlobalParam } = useDashboardRuntime();
const dateRange = ref<[string, string] | []>(
props.widget.props.defaultValue || [],
);
const label = computed(() => props.widget.props.label || '');
const startPlaceholder = computed(
() =>
props.widget.props.startPlaceholder ||
$t('dashboard-design.material.defaultProps.filterStartDate'),
);
const endPlaceholder = computed(
() =>
props.widget.props.endPlaceholder ||
$t('dashboard-design.material.defaultProps.filterEndDate'),
);
const startParamKey = computed(
() => props.widget.props.startParamKey || '',
);
const endParamKey = computed(
() => props.widget.props.endParamKey || '',
);
const dateFormat = computed(
() => props.widget.props.dateFormat || 'YYYY-MM-DD',
);
const labelPosition = computed(
() => props.widget.props.labelPosition || 'left',
);
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
const componentSize = computed(
() => props.widget.props.componentSize || 'default',
);
const showBorder = computed(() => props.widget.props.showBorder ?? false);
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
const isTopLabel = computed(() => labelPosition.value === 'top');
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
const containerClass = computed(() => [
'filter-widget h-full w-full',
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
showBorder.value ? 'filter-widget--bordered' : '',
]);
const containerStyle = computed(() => ({
padding: isTopLabel.value ? '8px 12px' : '0 12px',
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
}));
const labelStyle = computed(() => ({
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
textAlign: labelAlign.value as 'center' | 'left' | 'right',
flexShrink: 0,
marginBottom: isTopLabel.value ? '4px' : undefined,
marginRight: isTopLabel.value ? undefined : '8px',
}));
function handleChange(val: [string, string] | null) {
if (props.isDesignMode) return;
if (val && val.length === 2) {
if (startParamKey.value) {
updateGlobalParam(startParamKey.value, val[0]);
}
if (endParamKey.value) {
updateGlobalParam(endParamKey.value, val[1]);
}
} else {
if (startParamKey.value) {
updateGlobalParam(startParamKey.value, '');
}
if (endParamKey.value) {
updateGlobalParam(endParamKey.value, '');
}
}
}
watch(
() => props.widget.props.defaultValue,
(val) => {
if (!props.isDesignMode && Array.isArray(val) && val.length === 2) {
dateRange.value = val as [string, string];
if (startParamKey.value) {
updateGlobalParam(startParamKey.value, val[0]);
}
if (endParamKey.value) {
updateGlobalParam(endParamKey.value, val[1]);
}
}
},
{ immediate: true },
);
const hasParamKey = computed(
() => startParamKey.value || endParamKey.value,
);
</script>
<template>
<div :class="containerClass" :style="containerStyle">
<span
v-if="label && !isHiddenLabel"
class="text-foreground text-sm font-medium"
:style="labelStyle"
>
{{ label }}
</span>
<ElDatePicker
v-model="dateRange"
type="daterange"
:start-placeholder="startPlaceholder"
:end-placeholder="endPlaceholder"
:disabled="isDesignMode"
:format="dateFormat"
:value-format="dateFormat"
:size="componentSize"
clearable
class="!flex-1"
@change="handleChange"
/>
<span
v-if="isDesignMode && !hasParamKey"
class="ml-2 flex-shrink-0 text-xs text-orange-500"
>
{{ $t('dashboard-design.filter.noParamKey') }}
</span>
</div>
</template>
<style scoped>
.filter-widget--bordered {
border: 1px solid var(--el-border-color);
background-color: var(--el-bg-color);
}
.filter-widget.flex-col :deep(.el-input),
.filter-widget.flex-col :deep(.el-select),
.filter-widget.flex-col :deep(.el-date-editor) {
flex: none;
width: 100%;
}
</style>
@@ -0,0 +1,139 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed, ref, watch } from 'vue';
import { Search } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElInput } from 'element-plus';
import { useDashboardRuntime } from '../../runtime';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const { updateGlobalParam } = useDashboardRuntime();
const inputValue = ref(props.widget.props.defaultValue || '');
const label = computed(() => props.widget.props.label || '');
const placeholder = computed(
() =>
props.widget.props.placeholder ||
$t('dashboard-design.material.defaultProps.filterInputPlaceholder'),
);
const paramKey = computed(() => props.widget.props.paramKey || '');
const labelPosition = computed(
() => props.widget.props.labelPosition || 'left',
);
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
const componentSize = computed(
() => props.widget.props.componentSize || 'default',
);
const showBorder = computed(() => props.widget.props.showBorder ?? false);
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
const isTopLabel = computed(() => labelPosition.value === 'top');
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
const containerClass = computed(() => [
'filter-widget h-full w-full',
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
showBorder.value ? 'filter-widget--bordered' : '',
]);
const containerStyle = computed(() => ({
padding: isTopLabel.value ? '8px 12px' : '0 12px',
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
}));
const labelStyle = computed(() => ({
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
textAlign: labelAlign.value as 'center' | 'left' | 'right',
flexShrink: 0,
marginBottom: isTopLabel.value ? '4px' : undefined,
marginRight: isTopLabel.value ? undefined : '8px',
}));
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
function handleInput(val: string | number) {
if (props.isDesignMode) return;
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
if (paramKey.value) {
updateGlobalParam(paramKey.value, val);
}
}, 300);
}
function handleClear() {
if (props.isDesignMode) return;
if (paramKey.value) {
updateGlobalParam(paramKey.value, '');
}
}
watch(
() => props.widget.props.defaultValue,
(val) => {
if (!props.isDesignMode && val !== undefined) {
inputValue.value = val;
if (paramKey.value) {
updateGlobalParam(paramKey.value, val);
}
}
},
{ immediate: true },
);
</script>
<template>
<div :class="containerClass" :style="containerStyle">
<span
v-if="label && !isHiddenLabel"
class="text-foreground text-sm font-medium"
:style="labelStyle"
>
{{ label }}
</span>
<ElInput
v-model="inputValue"
:placeholder="placeholder"
:disabled="isDesignMode"
clearable
:size="componentSize"
class="flex-1"
@input="handleInput"
@clear="handleClear"
>
<template #prefix>
<Search class="h-4 w-4 opacity-50" />
</template>
</ElInput>
<span
v-if="isDesignMode && !paramKey"
class="ml-2 flex-shrink-0 text-xs text-orange-500"
>
{{ $t('dashboard-design.filter.noParamKey') }}
</span>
</div>
</template>
<style scoped>
.filter-widget--bordered {
border: 1px solid var(--el-border-color);
background-color: var(--el-bg-color);
}
.filter-widget.flex-col :deep(.el-input),
.filter-widget.flex-col :deep(.el-select),
.filter-widget.flex-col :deep(.el-date-editor) {
flex: none;
width: 100%;
}
</style>
@@ -0,0 +1,180 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed, onMounted, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElOption, ElSelect } from 'element-plus';
import { requestClient } from '#/api/request';
import { useDashboardRuntime } from '../../runtime';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const { updateGlobalParam } = useDashboardRuntime();
const selectValue = ref<any>(props.widget.props.defaultValue || '');
const dynamicOptions = ref<{ label: string; value: any }[]>([]);
const loadingOptions = ref(false);
const label = computed(() => props.widget.props.label || '');
const placeholder = computed(
() =>
props.widget.props.placeholder ||
$t('dashboard-design.material.defaultProps.filterSelectPlaceholder'),
);
const paramKey = computed(() => props.widget.props.paramKey || '');
const multiple = computed(() => props.widget.props.multiple || false);
const clearable = computed(() => props.widget.props.clearable !== false);
const labelPosition = computed(
() => props.widget.props.labelPosition || 'left',
);
const labelWidth = computed(() => props.widget.props.labelWidth ?? 80);
const labelAlign = computed(() => props.widget.props.labelAlign || 'right');
const componentSize = computed(
() => props.widget.props.componentSize || 'default',
);
const showBorder = computed(() => props.widget.props.showBorder ?? false);
const borderRadius = computed(() => props.widget.props.borderRadius ?? 4);
const isTopLabel = computed(() => labelPosition.value === 'top');
const isHiddenLabel = computed(() => labelPosition.value === 'hidden');
const containerClass = computed(() => [
'filter-widget h-full w-full',
isTopLabel.value ? 'flex flex-col' : 'flex items-center',
showBorder.value ? 'filter-widget--bordered' : '',
]);
const containerStyle = computed(() => ({
padding: isTopLabel.value ? '8px 12px' : '0 12px',
borderRadius: showBorder.value ? `${borderRadius.value}px` : undefined,
}));
const labelStyle = computed(() => ({
width: isTopLabel.value ? 'auto' : `${labelWidth.value}px`,
textAlign: labelAlign.value as 'center' | 'left' | 'right',
flexShrink: 0,
marginBottom: isTopLabel.value ? '4px' : undefined,
marginRight: isTopLabel.value ? undefined : '8px',
}));
const options = computed(() => {
if (props.widget.props.optionSource === 'dataSource') {
return dynamicOptions.value;
}
return props.widget.props.options || [];
});
async function loadDynamicOptions() {
const code = props.widget.props.optionDataSourceCode;
if (!code) return;
try {
loadingOptions.value = true;
const response = await requestClient.get(
`/api/core/data-source/execute/${code}`,
);
const rawData = response?.data ?? response;
const data = Array.isArray(rawData) ? rawData : [];
const labelField = props.widget.props.optionLabelField || 'label';
const valueField = props.widget.props.optionValueField || 'value';
dynamicOptions.value = data.map((item: any) => ({
label: String(item[labelField] ?? ''),
value: item[valueField],
}));
} catch (error) {
console.error('Failed to load filter options:', error);
} finally {
loadingOptions.value = false;
}
}
function handleChange(val: any) {
if (props.isDesignMode) return;
if (paramKey.value) {
updateGlobalParam(paramKey.value, val);
}
}
function handleClear() {
if (props.isDesignMode) return;
if (paramKey.value) {
updateGlobalParam(paramKey.value, multiple.value ? [] : '');
}
}
watch(
() => props.widget.props.defaultValue,
(val) => {
if (!props.isDesignMode && val !== undefined) {
selectValue.value = val;
if (paramKey.value) {
updateGlobalParam(paramKey.value, val);
}
}
},
{ immediate: true },
);
onMounted(() => {
if (props.widget.props.optionSource === 'dataSource') {
loadDynamicOptions();
}
});
</script>
<template>
<div :class="containerClass" :style="containerStyle">
<span
v-if="label && !isHiddenLabel"
class="text-foreground text-sm font-medium"
:style="labelStyle"
>
{{ label }}
</span>
<ElSelect
v-model="selectValue"
:placeholder="placeholder"
:disabled="isDesignMode"
:multiple="multiple"
:clearable="clearable"
:loading="loadingOptions"
:size="componentSize"
class="flex-1"
@change="handleChange"
@clear="handleClear"
>
<ElOption
v-for="opt in options"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
<span
v-if="isDesignMode && !paramKey"
class="ml-2 flex-shrink-0 text-xs text-orange-500"
>
{{ $t('dashboard-design.filter.noParamKey') }}
</span>
</div>
</template>
<style scoped>
.filter-widget--bordered {
border: 1px solid var(--el-border-color);
background-color: var(--el-bg-color);
}
.filter-widget.flex-col :deep(.el-input),
.filter-widget.flex-col :deep(.el-select),
.filter-widget.flex-col :deep(.el-date-editor) {
flex: none;
width: 100%;
}
</style>
@@ -0,0 +1,202 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed, ref } from 'vue';
import { AlertCircle, ExternalLink, RefreshCw } from '@vben/icons';
import { $t } from '@vben/locales';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const iframeRef = ref<HTMLIFrameElement | null>(null);
const isLoading = ref(true);
const hasError = ref(false);
const iframeUrl = computed(() => props.widget.props.url || '');
const handleLoad = () => {
isLoading.value = false;
hasError.value = false;
};
const handleError = () => {
isLoading.value = false;
hasError.value = true;
};
const refresh = () => {
if (iframeRef.value) {
isLoading.value = true;
hasError.value = false;
iframeRef.value.src = iframeUrl.value;
}
};
const openInNewTab = () => {
if (iframeUrl.value) {
window.open(iframeUrl.value, '_blank');
}
};
</script>
<template>
<div class="iframe-widget flex h-full flex-col">
<!-- 标题栏 -->
<div v-if="widget.props.title" class="iframe-header">
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
<div class="header-actions">
<button
class="action-btn"
:title="$t('dashboard-design.widgets.iframe.refresh')"
@click="refresh"
>
<RefreshCw class="h-3.5 w-3.5" />
</button>
<button
class="action-btn"
:title="$t('dashboard-design.widgets.iframe.openNew')"
@click="openInNewTab"
>
<ExternalLink class="h-3.5 w-3.5" />
</button>
</div>
</div>
<!-- iframe 容器 -->
<div class="iframe-container relative min-h-0 flex-1">
<!-- 设计模式下显示占位 -->
<div v-if="isDesignMode" class="design-placeholder">
<ExternalLink class="h-8 w-8 text-gray-400" />
<div class="mt-2 text-sm text-gray-500">
{{ $t('dashboard-design.widgets.iframe.placeholder') }}
</div>
<div class="mt-1 max-w-full truncate px-4 text-xs text-gray-400">
{{ iframeUrl || $t('dashboard-design.widgets.iframe.noUrl') }}
</div>
</div>
<!-- 实际 iframe -->
<template v-else>
<!-- 加载状态 -->
<div v-if="isLoading" class="loading-overlay">
<div class="loading-spinner"></div>
<div class="mt-2 text-sm text-gray-500">
{{ $t('dashboard-design.widgets.iframe.loading') }}
</div>
</div>
<!-- 错误状态 -->
<div v-if="hasError && !isLoading" class="error-overlay">
<AlertCircle class="h-8 w-8 text-red-400" />
<div class="mt-2 text-sm text-gray-500">
{{ $t('dashboard-design.widgets.iframe.loadFailed') }}
</div>
<button class="text-primary mt-2 text-xs" @click="refresh">
{{ $t('dashboard-design.widgets.iframe.retry') }}
</button>
</div>
<!-- iframe -->
<iframe
v-show="!hasError"
ref="iframeRef"
:src="iframeUrl"
:class="{ 'with-border': widget.props.showBorder }"
:allowfullscreen="widget.props.allowFullscreen"
frameborder="0"
class="iframe-content"
@load="handleLoad"
@error="handleError"
></iframe>
</template>
</div>
</div>
</template>
<style scoped>
.iframe-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.header-actions {
display: flex;
gap: 4px;
}
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: var(--el-text-color-secondary);
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
transition: all 0.2s;
}
.action-btn:hover {
color: var(--el-text-color-primary);
background: var(--el-fill-color-light);
}
.iframe-container {
position: relative;
overflow: hidden;
}
.design-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: var(--el-fill-color-lighter);
}
.loading-overlay,
.error-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--el-bg-color);
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--el-border-color-lighter);
border-top-color: var(--el-color-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.iframe-content {
width: 100%;
height: 100%;
}
.iframe-content.with-border {
border: 1px solid var(--el-border-color-lighter);
}
</style>
@@ -0,0 +1,252 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { ChevronLeft, ChevronRight } from '@vben/icons';
import { $t } from '@vben/locales';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const currentIndex = ref(0);
let autoplayTimer: null | ReturnType<typeof setInterval> = null;
// 处理后的图片列表(响应式)
const images = ref<any[]>([]);
// 异步解析图片URL
async function resolveImageUrl(url: string): Promise<string> {
if (!url) return '';
if (url.startsWith('file://')) {
const fileId = url.slice(7);
return await getFileUrl(fileId);
}
return url;
}
// 加载图片列表
async function loadImages() {
const rawImages = props.widget.props.images || [];
const resolvedImages = await Promise.all(
rawImages.map(async (img: any) => ({
...img,
url: await resolveImageUrl(img.url || ''),
})),
);
images.value = resolvedImages;
}
// 监听图片列表变化
watch(
() => props.widget.props.images,
() => {
loadImages();
},
{ immediate: true, deep: true },
);
const nextSlide = () => {
if (images.value.length === 0) return;
currentIndex.value = (currentIndex.value + 1) % images.value.length;
};
const prevSlide = () => {
if (images.value.length === 0) return;
currentIndex.value =
(currentIndex.value - 1 + images.value.length) % images.value.length;
};
const goToSlide = (index: number) => {
currentIndex.value = index;
};
const startAutoplay = () => {
if (autoplayTimer) {
clearInterval(autoplayTimer);
}
if (props.widget.props.autoplay && !props.isDesignMode) {
const interval = props.widget.props.interval || 3000;
autoplayTimer = setInterval(nextSlide, interval);
}
};
const stopAutoplay = () => {
if (autoplayTimer) {
clearInterval(autoplayTimer);
autoplayTimer = null;
}
};
const handleImageClick = (image: any) => {
if (image.link && !props.isDesignMode) {
window.open(image.link, '_blank');
}
};
onMounted(() => {
startAutoplay();
});
onUnmounted(() => {
stopAutoplay();
});
watch(
() => props.widget.props.autoplay,
() => {
if (props.widget.props.autoplay) {
startAutoplay();
} else {
stopAutoplay();
}
},
);
</script>
<template>
<div
class="image-carousel relative h-full w-full overflow-hidden"
@mouseenter="stopAutoplay"
@mouseleave="startAutoplay"
>
<!-- 图片容器 -->
<div
class="carousel-track flex h-full transition-transform duration-500 ease-in-out"
:style="{ transform: `translateX(-${currentIndex * 100}%)` }"
>
<div
v-for="(image, index) in images"
:key="index"
class="carousel-slide h-full w-full flex-shrink-0"
:class="{ 'cursor-pointer': image.link && !isDesignMode }"
@click="handleImageClick(image)"
>
<img
:src="image.url"
:alt="
image.title ||
`${$t('dashboard-design.widgets.image.title')} ${Number(index) + 1}`
"
class="h-full w-full object-cover"
/>
<div v-if="image.title" class="slide-title">
{{ image.title }}
</div>
</div>
</div>
<!-- 左右箭头 -->
<template v-if="widget.props.showArrow && images.length > 1">
<button class="arrow-btn arrow-left" @click.stop="prevSlide">
<ChevronLeft class="h-5 w-5" />
</button>
<button class="arrow-btn arrow-right" @click.stop="nextSlide">
<ChevronRight class="h-5 w-5" />
</button>
</template>
<!-- 指示器 -->
<div
v-if="widget.props.showIndicator && images.length > 1"
class="indicators"
>
<button
v-for="(_, index) in images"
:key="index"
class="indicator"
:class="{ active: currentIndex === Number(index) }"
@click.stop="goToSlide(Number(index))"
></button>
</div>
<!-- 空状态 -->
<div
v-if="images.length === 0"
class="flex h-full w-full items-center justify-center text-gray-400"
>
{{ $t('dashboard-design.widgets.image.noData') }}
</div>
</div>
</template>
<style scoped>
.carousel-track {
will-change: transform;
}
.slide-title {
position: absolute;
right: 0;
bottom: 0;
left: 0;
padding: 8px 12px;
font-size: 0.875rem;
color: white;
background: linear-gradient(transparent, rgb(0 0 0 / 60%));
}
.arrow-btn {
position: absolute;
top: 50%;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
cursor: pointer;
background: rgb(255 255 255 / 80%);
border: none;
border-radius: 50%;
opacity: 0;
transform: translateY(-50%);
transition: opacity 0.2s;
}
.image-carousel:hover .arrow-btn {
opacity: 1;
}
.arrow-btn:hover {
background: rgb(255 255 255 / 100%);
}
.arrow-left {
left: 8px;
}
.arrow-right {
right: 8px;
}
.indicators {
position: absolute;
bottom: 12px;
left: 50%;
z-index: 10;
display: flex;
gap: 6px;
transform: translateX(-50%);
}
.indicator {
width: 8px;
height: 8px;
cursor: pointer;
background: rgb(255 255 255 / 50%);
border: none;
border-radius: 50%;
transition: all 0.2s;
}
.indicator.active {
width: 20px;
background: white;
border-radius: 4px;
}
</style>
@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed, ref, watch } from 'vue';
import { ElImage } from 'element-plus';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const widgetProps = computed(() => props.widget.props);
// 主图URL(响应式)
const imageSrc = ref('');
// 预览图片列表(响应式)
const previewList = ref<string[]>([]);
// 异步解析图片URL
async function resolveImageUrl(url: string): Promise<string> {
if (!url) return '';
if (url.startsWith('file://')) {
const fileId = url.slice(7);
return await getFileUrl(fileId);
}
return url;
}
// 加载图片URL
async function loadImageUrls() {
// 加载主图
imageSrc.value = await resolveImageUrl(widgetProps.value.src || '');
// 加载预览列表
const list = widgetProps.value.previewSrcList || [];
previewList.value =
list.length === 0 && imageSrc.value
? [imageSrc.value]
: await Promise.all(list.map((url: string) => resolveImageUrl(url)));
}
// 监听图片源变化
watch(
() => [widgetProps.value.src, widgetProps.value.previewSrcList],
() => {
loadImageUrls();
},
{ immediate: true, deep: true },
);
</script>
<template>
<div class="image-widget flex h-full w-full flex-col">
<!-- 标题 -->
<div
v-if="widgetProps.title"
class="mb-2 flex-shrink-0 text-sm font-medium"
style="color: var(--el-text-color-primary)"
>
{{ widgetProps.title }}
</div>
<!-- 图片容器 -->
<div class="relative min-h-0 flex-1 overflow-hidden rounded">
<ElImage
:src="imageSrc"
:alt="widgetProps.alt || '图片'"
:fit="widgetProps.fit || 'cover'"
:lazy="widgetProps.lazy !== false"
:preview-src-list="isDesignMode ? [] : previewList"
:z-index="widgetProps.zIndex || 2000"
:hide-on-click-modal="widgetProps.hideOnClickModal || false"
class="h-full w-full"
:preview-teleported="true"
>
<template #error>
<div
class="flex h-full w-full items-center justify-center"
style="background-color: var(--el-fill-color-light)"
>
<span style="color: var(--el-text-color-secondary)">
加载失败
</span>
</div>
</template>
<template #placeholder>
<div
class="flex h-full w-full items-center justify-center"
style="background-color: var(--el-fill-color-lighter)"
>
<span style="color: var(--el-text-color-placeholder)">
加载中...
</span>
</div>
</template>
</ElImage>
</div>
</div>
</template>
<style scoped>
.image-widget :deep(.el-image) {
display: block;
}
.image-widget :deep(.el-image__inner) {
transition: transform 0.3s ease;
}
.image-widget:hover :deep(.el-image__inner) {
transform: scale(1.02);
}
</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 '../../types';
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,28 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { ElProgress } from 'element-plus';
defineProps<{
widget: DashboardWidget;
}>();
</script>
<template>
<div class="progress-card flex h-full flex-col justify-between p-4">
<div class="text-muted-foreground text-sm">{{ widget.props.title }}</div>
<div class="flex flex-1 items-center justify-center">
<ElProgress
type="dashboard"
:percentage="widget.props.percentage"
:status="widget.props.status || undefined"
:stroke-width="widget.props.strokeWidth"
:show-text="widget.props.showText"
/>
</div>
</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,71 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { Award } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElScrollbar } from 'element-plus';
defineProps<{
widget: DashboardWidget;
}>();
const getRankClass = (rank: number) => {
switch (rank) {
case 1: {
return 'bg-yellow-500 text-white';
}
case 2: {
return 'bg-gray-400 text-white';
}
case 3: {
return 'bg-amber-600 text-white';
}
default: {
return 'bg-gray-200 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
}
}
};
const formatValue = (value: number) => {
if (value >= 10_000) {
return `${(value / 10_000).toFixed(1)}${$t('dashboard-design.widgets.ranking.tenThousand')}`;
}
return value.toLocaleString();
};
</script>
<template>
<div class="ranking-list flex h-full flex-col p-3">
<div class="mb-3 flex items-center gap-2">
<Award class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
</div>
<ElScrollbar class="flex-1">
<div class="space-y-2">
<div
v-for="item in widget.props.items"
:key="item.rank"
class="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
>
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded text-xs font-medium"
:class="getRankClass(item.rank)"
>
{{ item.rank }}
</div>
<div class="flex-1 truncate text-sm">{{ item.name }}</div>
<div class="text-muted-foreground text-sm font-medium">
{{ formatValue(item.value) }}
</div>
</div>
</div>
</ElScrollbar>
</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,91 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { computed } from 'vue';
import {
Activity,
Award,
Bell,
CreditCard,
TrendingUp,
Users,
} from '@vben/icons';
import { $t } from '@vben/locales';
const props = defineProps<{
showTitle?: boolean;
widget: DashboardWidget;
}>();
// 图标映射
const iconMap: Record<string, any> = {
TrendingUp,
CreditCard,
Users,
Activity,
Bell,
Award,
};
const iconComponent = computed(() => {
return iconMap[props.widget.props.iconName] || TrendingUp;
});
const trendClass = computed(() => {
const trend = props.widget.props.trend || 0;
if (trend > 0) return 'text-green-500';
if (trend < 0) return 'text-red-500';
return 'text-gray-500';
});
const trendIcon = computed(() => {
const trend = props.widget.props.trend || 0;
if (trend > 0) return '↑';
if (trend < 0) return '↓';
return '';
});
const formatValue = (value: number) => {
if (value >= 10_000) {
return `${(value / 10_000).toFixed(1)}${$t('dashboard-design.widgets.ranking.tenThousand')}`;
}
return value.toLocaleString();
};
</script>
<template>
<div class="stat-card flex h-full flex-col justify-between p-4">
<div class="flex items-start justify-between">
<div>
<div class="text-muted-foreground text-sm">
{{ widget.props.title }}
</div>
<div class="mt-2 text-2xl font-bold">
{{ widget.props.prefix }}{{ formatValue(widget.props.value)
}}{{ widget.props.suffix }}
</div>
</div>
<div
class="flex h-10 w-10 items-center justify-center rounded-lg"
:style="{ backgroundColor: `${widget.props.iconColor}20` }"
>
<component
:is="iconComponent"
class="h-5 w-5"
:style="{ color: widget.props.iconColor }"
/>
</div>
</div>
<div class="mt-3 flex items-center gap-1 text-sm">
<span :class="trendClass">
{{ trendIcon }} {{ Math.abs(widget.props.trend || 0) }}%
</span>
<span class="text-muted-foreground">{{ widget.props.trendLabel }}</span>
</div>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { CheckSquare } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElCheckbox, ElScrollbar, ElTag } from 'element-plus';
defineProps<{
widget: DashboardWidget;
}>();
const getPriorityType = (priority: string) => {
switch (priority) {
case 'high': {
return 'danger';
}
case 'low': {
return 'info';
}
case 'medium': {
return 'warning';
}
default: {
return 'info';
}
}
};
const getPriorityLabel = (priority: string) => {
switch (priority) {
case 'high': {
return $t('dashboard-design.widgets.todo.priority.high');
}
case 'low': {
return $t('dashboard-design.widgets.todo.priority.low');
}
case 'medium': {
return $t('dashboard-design.widgets.todo.priority.medium');
}
default: {
return priority;
}
}
};
</script>
<template>
<div class="todo-list flex h-full flex-col p-3">
<div class="mb-3 flex items-center gap-2">
<CheckSquare class="text-muted-foreground h-4 w-4" />
<span class="text-muted-foreground text-sm font-medium">{{
widget.props.title
}}</span>
</div>
<ElScrollbar class="flex-1">
<div class="space-y-2">
<div
v-for="item in widget.props.items"
:key="item.id"
class="flex items-center gap-2 rounded-md p-2 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800"
>
<ElCheckbox :model-value="item.done" size="small" />
<span
class="flex-1 text-sm"
:class="{ 'text-muted-foreground line-through': item.done }"
>
{{ item.title }}
</span>
<ElTag :type="getPriorityType(item.priority)" size="small">
{{ getPriorityLabel(item.priority) }}
</ElTag>
</div>
</div>
</ElScrollbar>
</div>
</template>
<style scoped>
/* 背景色由 WidgetRenderer 控制 */
</style>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
import { onMounted, ref, watch } from 'vue';
import { Maximize, Pause, Play, Volume2, VolumeX } from '@vben/icons';
import { $t } from '@vben/locales';
import { getFileUrl } from '#/composables/useFileUrl';
const props = defineProps<{
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
const videoRef = ref<HTMLVideoElement | null>(null);
const isPlaying = ref(false);
const isMuted = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const progress = ref(0);
// 视频和封面URL(响应式)
const videoUrl = ref('');
const posterUrl = ref('');
// 异步解析URL
async function resolveUrl(url: string): Promise<string> {
if (!url) return '';
if (url.startsWith('file://')) {
const fileId = url.slice(7);
return await getFileUrl(fileId);
}
return url;
}
// 加载URL
async function loadUrls() {
videoUrl.value = await resolveUrl(props.widget.props.url || '');
posterUrl.value = await resolveUrl(props.widget.props.poster || '');
}
// 监听URL变化
watch(
() => [props.widget.props.url, props.widget.props.poster],
() => {
loadUrls();
},
{ immediate: true },
);
const togglePlay = () => {
if (!videoRef.value) return;
if (isPlaying.value) {
videoRef.value.pause();
} else {
videoRef.value.play();
}
};
const toggleMute = () => {
if (!videoRef.value) return;
videoRef.value.muted = !videoRef.value.muted;
isMuted.value = videoRef.value.muted;
};
const toggleFullscreen = () => {
if (!videoRef.value) return;
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
videoRef.value.requestFullscreen();
}
};
const handleTimeUpdate = () => {
if (!videoRef.value) return;
currentTime.value = videoRef.value.currentTime;
progress.value = (currentTime.value / duration.value) * 100;
};
const handleLoadedMetadata = () => {
if (!videoRef.value) return;
duration.value = videoRef.value.duration;
};
const handlePlay = () => {
isPlaying.value = true;
};
const handlePause = () => {
isPlaying.value = false;
};
const handleSeek = (e: MouseEvent) => {
if (!videoRef.value) return;
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
videoRef.value.currentTime = percent * duration.value;
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
onMounted(() => {
if (videoRef.value) {
isMuted.value = props.widget.props.muted || false;
videoRef.value.muted = isMuted.value;
}
});
watch(
() => props.widget.props.url,
() => {
if (videoRef.value) {
videoRef.value.load();
isPlaying.value = false;
currentTime.value = 0;
progress.value = 0;
}
},
);
</script>
<template>
<div class="video-player flex h-full flex-col">
<div
v-if="widget.props.title"
class="text-muted-foreground p-2 text-sm font-medium"
>
{{ widget.props.title }}
</div>
<div class="video-container relative min-h-0 flex-1 bg-black">
<video
ref="videoRef"
:src="videoUrl"
:poster="posterUrl"
:autoplay="widget.props.autoplay && !isDesignMode"
:loop="widget.props.loop"
:controls="false"
class="h-full w-full object-contain"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@play="handlePlay"
@pause="handlePause"
></video>
<!-- 自定义控制栏 -->
<div v-if="!widget.props.controls" class="custom-controls">
<button class="control-btn" @click="togglePlay">
<Play v-if="!isPlaying" class="h-5 w-5" />
<Pause v-else class="h-5 w-5" />
</button>
<div class="progress-bar" @click="handleSeek">
<div class="progress-fill" :style="{ width: `${progress}%` }"></div>
</div>
<span class="time-display">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</span>
<button class="control-btn" @click="toggleMute">
<VolumeX v-if="isMuted" class="h-4 w-4" />
<Volume2 v-else class="h-4 w-4" />
</button>
<button class="control-btn" @click="toggleFullscreen">
<Maximize class="h-4 w-4" />
</button>
</div>
<!-- 原生控制栏 -->
<video
v-if="widget.props.controls"
ref="videoRef"
:src="videoUrl"
:poster="posterUrl"
:autoplay="widget.props.autoplay && !isDesignMode"
:loop="widget.props.loop"
:muted="widget.props.muted"
controls
class="h-full w-full object-contain"
></video>
<!-- 设计模式遮罩 -->
<div v-if="isDesignMode" class="design-overlay">
<Play class="h-12 w-12 text-white/80" />
<div class="mt-2 text-sm text-white/80">
{{ $t('dashboard-design.widgets.video.placeholder') }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.video-container {
position: relative;
overflow: hidden;
}
.custom-controls {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
background: linear-gradient(transparent, rgb(0 0 0 / 70%));
opacity: 0;
transition: opacity 0.2s;
}
.video-container:hover .custom-controls {
opacity: 1;
}
.control-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: white;
cursor: pointer;
background: transparent;
border: none;
border-radius: 4px;
transition: background 0.2s;
}
.control-btn:hover {
background: rgb(255 255 255 / 20%);
}
.progress-bar {
flex: 1;
height: 4px;
overflow: hidden;
cursor: pointer;
background: rgb(255 255 255 / 30%);
border-radius: 2px;
}
.progress-fill {
height: 100%;
background: var(--el-color-primary);
transition: width 0.1s;
}
.time-display {
min-width: 80px;
font-size: 12px;
color: white;
text-align: center;
}
.design-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 50%);
}
</style>
@@ -0,0 +1,364 @@
<script setup lang="ts">
import type { DashboardWidget } from '../../types';
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 '../../types';
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,10 @@
export { default as DashboardRenderer } from './DashboardRenderer.vue';
export type {
DashboardConfig,
DashboardWidget,
DataSourceConfig,
DataSourceType,
WidgetStyle,
WidgetType,
} from './types';
export { createRefreshTimer, fetchWidgetData } from './utils/dataFetcher';
@@ -0,0 +1,23 @@
import { ref } from 'vue';
const globalParams = ref<Record<string, any>>({});
const globalParamsVersion = ref(0);
function updateGlobalParam(key: string, value: any) {
globalParams.value = { ...globalParams.value, [key]: value };
globalParamsVersion.value++;
}
function clearGlobalParams() {
globalParams.value = {};
globalParamsVersion.value++;
}
export function useDashboardRuntime() {
return {
clearGlobalParams,
globalParams,
globalParamsVersion,
updateGlobalParam,
};
}
@@ -0,0 +1,115 @@
export type DataSourceType = 'api' | 'dataSource' | 'static' | 'upload';
export interface FieldMapping {
source: string;
target: string;
}
export interface ParamBinding {
paramName: string;
globalKey: string;
}
export interface DataSourceConfig {
type: DataSourceType;
dataSourceCode?: string;
apiUrl?: string;
apiMethod?: 'GET' | 'POST';
apiHeaders?: Record<string, string>;
apiParams?: Record<string, any>;
apiBody?: Record<string, any>;
dataPath?: string;
fieldMappings?: FieldMapping[];
paramBindings?: ParamBinding[];
refreshInterval?: number;
refreshEnabled?: boolean;
}
export type WidgetType =
| 'announcement-list'
| 'approval-center'
| 'calendar'
| 'chart-area'
| 'chart-bar'
| 'chart-funnel'
| 'chart-gauge'
| 'chart-heatmap'
| 'chart-kline'
| 'chart-line'
| 'chart-pie'
| 'chart-radar'
| 'chart-ring'
| 'chart-sankey'
| 'chart-scatter'
| 'clock'
| 'countdown'
| 'data-table'
| 'filter-date'
| 'filter-date-range'
| 'filter-input'
| 'filter-select'
| 'form-render'
| 'iframe'
| 'image'
| 'image-carousel'
| 'my-apps'
| 'notice-list'
| 'progress-card'
| 'quick-links'
| 'ranking-list'
| 'server-monitor'
| 'stat-card'
| 'todo-list'
| 'video-player'
| 'weather'
| 'welcome-card';
export interface WidgetStyle {
backgroundColor?: string;
backgroundImage?: string;
backgroundSize?: 'auto' | 'contain' | 'cover';
borderWidth?: number;
borderColor?: string;
borderStyle?: 'dashed' | 'dotted' | 'none' | 'solid';
borderRadius?: number;
shadowEnabled?: boolean;
shadowColor?: string;
shadowBlur?: number;
shadowOffsetX?: number;
shadowOffsetY?: number;
padding?: number;
titleShow?: boolean;
titleFontSize?: number;
titleColor?: string;
titleAlign?: 'center' | 'left' | 'right';
titleFontWeight?: 'bold' | 'normal';
}
export interface DashboardWidget {
id: string;
type: WidgetType;
i: string;
x: number;
y: number;
w: number;
h: number;
minW?: number;
minH?: number;
maxW?: number;
maxH?: number;
title?: string;
props: Record<string, any>;
style?: WidgetStyle;
dataSource?: DataSourceConfig;
}
export interface DashboardConfig {
id: string;
name: string;
columns: number;
rowHeight: number;
margin: [number, number];
backgroundColor?: string;
showOuterMargin?: boolean;
widgets: DashboardWidget[];
}
@@ -0,0 +1,314 @@
import type {
DataSourceConfig,
FieldMapping,
} from '../types';
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;
}
@@ -0,0 +1,894 @@
{
"title": "Dashboard Design",
"preview": "Preview",
"save": "Save",
"saveSuccess": "Save successful",
"clear": "Clear",
"clearConfirm": "Are you sure you want to clear the canvas? This action cannot be undone.",
"clearSuccess": "Cleared",
"export": "Export",
"exportSuccess": "Export successful",
"import": "Import",
"importTitle": "Import Configuration",
"importPlaceholder": "Please paste JSON configuration content...",
"importSuccess": "Import successful",
"importError": "Invalid configuration format",
"importEmpty": "Please enter configuration content",
"viewCode": "View Code",
"jsonPreview": "JSON Preview",
"copyCode": "Copy Code",
"copySuccess": "Copied to clipboard",
"copyError": "Copy failed",
"undoSuccess": "Undone",
"redoSuccess": "Redone",
"copyWidgetSuccess": "Copied",
"pasteWidgetSuccess": "Pasted",
"deleteWidgetSuccess": "Deleted",
"noWidgetsTip": "Please add widgets first",
"noConfigTip": "No dashboard configuration",
"loadingData": "Loading data...",
"loadDataError": "Failed to load data",
"unknownWidget": "Unknown widget type",
"widgetCount": "{count} widgets",
"dragTip": "Drag widgets from the left to here",
"add": "Add",
"copy": "Copy",
"delete": "Delete",
"reset": "Reset",
"clean": "Clear",
"canvas": {
"title": "Design Canvas",
"adaptive": "Adaptive",
"actualSize": "Actual Size",
"settings": "Canvas Settings",
"undo": "Undo",
"redo": "Redo"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
},
"weekdaysShort": {
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun"
},
"material": {
"title": "Material Library",
"search": "Search widgets...",
"category": {
"common": "Common",
"chart": "Chart",
"filter": "Filter",
"map": "Map",
"media": "Media",
"other": "Other"
},
"widgets": {
"statCard": "Stat Card",
"progressCard": "Progress Card",
"chartLine": "Line Chart",
"chartBar": "Bar Chart",
"chartPie": "Pie Chart",
"chartGauge": "Gauge Chart",
"chartArea": "Area Chart",
"chartRadar": "Radar Chart",
"chartFunnel": "Funnel Chart",
"chartScatter": "Scatter Chart",
"chartRing": "Ring Progress",
"chartHeatmap": "Heatmap",
"chartKline": "K-line Chart",
"chartSankey": "Sankey Chart",
"todoList": "Todo List",
"noticeList": "Message Notification",
"announcementList": "Announcement List",
"rankingList": "Ranking List",
"quickLinks": "Quick Links",
"welcomeCard": "Welcome Card",
"calendar": "Calendar",
"countdown": "Countdown",
"clock": "Clock",
"weather": "Weather",
"imageCarousel": "Image Carousel",
"dataTable": "Data Table",
"iframe": "iframe Embed",
"videoPlayer": "Video Player",
"image": "Image",
"formRender": "Form Render",
"approvalCenter": "Approval Center",
"myApps": "My Apps",
"serverMonitor": "Server Monitor",
"filterInput": "Input Filter",
"filterSelect": "Select Filter",
"filterDate": "Date Filter",
"filterDateRange": "Date Range"
},
"defaultProps": {
"statTitle": "Statistic Data",
"trendLabel": "vs Yesterday",
"progressTitle": "Completion Progress",
"visitTrend": "Visit Trend",
"visits": "Visits",
"downloads": "Downloads",
"salesStat": "Sales Statistics",
"trafficSource": "Traffic Source",
"searchEngine": "Search Engine",
"directAccess": "Direct Access",
"emailMarketing": "Email Marketing",
"unionAds": "Union Ads",
"videoAds": "Video Ads",
"sysLoad": "System Load",
"abilityEval": "Ability Evaluation",
"sales": "Sales",
"mgmt": "Mgmt",
"tech": "Tech",
"cs": "CS",
"rd": "R&D",
"mkt": "Mkt",
"budget": "Budget",
"actual": "Actual",
"convFunnel": "Conversion Funnel",
"visit": "Visit",
"consult": "Consult",
"intent": "Intent",
"order": "Order",
"deal": "Deal",
"dataDist": "Data Distribution",
"height": "Height (cm)",
"weight": "Weight (kg)",
"male": "Male",
"female": "Female",
"kpiDone": "KPI Completion",
"salesVolume": "Sales Volume",
"orderVolume": "Order Volume",
"customerCount": "Customer Count",
"weekVisitHeat": "Weekly Visit Heat",
"stockTrend": "Stock Trend",
"todoTitle": "To-do Items",
"todoItem1": "Complete project report",
"todoItem2": "Team weekly meeting",
"todoItem3": "Code review",
"latestNotice": "Message Notification",
"latestAnnouncement": "Latest Announcements",
"salesRanking": "Sales Ranking",
"welcomeTitle": "Welcome back",
"welcomeSubtitle": "Today is a good day",
"countdownTitle": "Event Countdown",
"todayWeather": "Today's Weather",
"dataList": "Data List",
"externalPage": "External Page",
"itemName": "Item",
"imageTitle": "Image",
"myDashboard": "My Dashboard",
"formRender": "Form Data",
"approvalCenter": "Approval Center",
"myApps": "My Apps",
"serverMonitor": "Server Monitor",
"filterInput": "Keyword",
"filterInputPlaceholder": "Enter keyword to filter...",
"filterSelect": "Select Filter",
"filterSelectPlaceholder": "Please select...",
"filterDate": "Date",
"filterDatePlaceholder": "Select date",
"filterDateRange": "Date Range",
"filterStartDate": "Start Date",
"filterEndDate": "End Date"
}
},
"attribute": {
"title": "Attribute Panel",
"canvasConfig": "Canvas Config",
"widgetConfig": "Widget Config",
"styleConfig": "Style Config",
"dataConfig": "Data Config",
"globalSettings": "Global Settings",
"dashboardName": "Dashboard Name",
"gridLayout": "Grid Layout",
"columns": "Columns",
"rowHeight": "Row Height (px)",
"widgetMargin": "Widget Margin (px)",
"horizontal": "Horizontal",
"vertical": "Vertical",
"background": "Background",
"backgroundColor": "Background Color",
"reset": "Reset",
"display": "Display",
"outerMargin": "Outer Margin",
"outerMarginTip": "Show outer margin during rendering",
"widgetTitle": "Title",
"layout": "Layout",
"widthGrid": "Width (Grid)",
"heightGrid": "Height (Grid)",
"layoutTip": "Tip: Resize widgets directly on the canvas",
"iconConfig": "Icon Config",
"icon": "Icon",
"iconColor": "Icon Color",
"status": "Status",
"strokeWidth": "Stroke Width",
"showText": "Show Text",
"border": "Border",
"borderWidth": "Border Width",
"borderColor": "Border Color",
"borderStyleLabel": "Border Style",
"borderRadius": "Corner Radius",
"shadow": "Shadow",
"enableShadow": "Enable Shadow",
"shadowColor": "Shadow Color",
"shadowBlur": "Blur Radius",
"chartLayout": "Chart Layout",
"margin": {
"left": "Left Margin (%)",
"right": "Right Margin (%)",
"top": "Top Margin (%)",
"bottom": "Bottom Margin (%)"
},
"tabs": {
"basic": "Basic",
"data": "Data",
"style": "Style"
},
"placeholder": {
"name": "Please enter name",
"title": "Please enter title",
"remark": "Please enter description"
},
"iconOptions": {
"trending": "Trending",
"creditCard": "Credit Card",
"users": "Users",
"activity": "Activity",
"bell": "Bell",
"award": "Award"
},
"progressStatus": {
"default": "Default",
"success": "Success",
"warning": "Warning",
"exception": "Exception"
},
"colorTheme": {
"label": "Color Theme",
"default": "Default",
"fresh": "Fresh",
"business": "Business",
"tech": "Tech",
"warm": "Warm"
},
"chart": {
"smooth": "Smooth Curve",
"showArea": "Show Area",
"showSymbol": "Show Symbol",
"symbolSize": "Symbol Size",
"lineWidth": "Line Width",
"axis": "Axis",
"xAxisName": "X-Axis Name",
"yAxisName": "Y-Axis Name",
"nameLocation": "Name Location",
"legend": "Legend",
"showLegend": "Show Legend",
"legendPosition": "Legend Position",
"barWidth": "Bar Width",
"barRadius": "Bar Radius",
"horizontal": "Horizontal",
"stack": "Stack",
"showBackground": "Show Background",
"pieType": "Pie Type",
"showLabel": "Show Label",
"labelPosition": "Label Position",
"minValue": "Min Value",
"maxValue": "Max Value",
"splitNumber": "Split Number",
"showProgress": "Show Progress",
"showMA5": "Show MA5",
"showMA10": "Show MA10",
"shape": "Shape",
"sort": "Sort",
"orient": "Orient",
"pointSize": "Point Size",
"numerical": "Numerical Config",
"currentValue": "Current Value",
"min": "Min",
"max": "Max",
"unit": "Unit",
"maConfig": "MA Config",
"prefix": "Prefix",
"suffix": "Suffix",
"trendConfig": "Trend Config",
"trendValue": "Trend Value (%)",
"trendTip": "Positive for increase, negative for decrease",
"trendLabel": "Trend Description",
"seriesNamePrefix": "Series",
"itemNamePrefix": "Item",
"placeholder": {
"xAxis": "e.g. Month",
"yAxis": "e.g. Sales",
"unit": "e.g. %",
"title": "Please enter title",
"xAxis2": "Separated by commas, e.g. Jan, Feb, Mar",
"seriesData": "Separated by commas, e.g. 100, 200, 300",
"seriesName": "Series Name",
"prefix": "e.g. $",
"suffix": "e.g. items, %",
"trendLabel": "e.g. vs Yesterday",
"subtitle": "Please enter subtitle"
},
"location": {
"start": "Start",
"middle": "Middle",
"end": "End",
"top": "Top",
"bottom": "Bottom",
"left": "Left",
"right": "Right"
},
"pieTypes": {
"pie": "Pie",
"ring": "Ring",
"rose": "Rose"
},
"labelPositions": {
"outside": "Outside",
"inside": "Inside"
},
"barWidthOptions": {
"auto": "Auto",
"thin": "Thin (30%)",
"medium": "Medium (50%)",
"thick": "Thick (70%)"
},
"shapes": {
"polygon": "Polygon",
"circle": "Circle"
},
"sortOptions": {
"descending": "Descending",
"ascending": "Ascending",
"none": "None"
},
"orientOptions": {
"vertical": "Vertical",
"horizontal": "Horizontal"
},
"dataEditMode": "Edit Mode",
"dataEditForm": "Form",
"dataEditJson": "JSON",
"jsonPlaceholder": "JSON format data",
"jsonTip": "Edit JSON directly, automatically applied on blur",
"xAxisData": "X-Axis Data",
"seriesDataLabel": "Series Data",
"addSeries": "Add Series",
"dataItem": "Data Item",
"addItem": "Add Data Item",
"radarTip": "Radar chart data is complex, JSON mode is recommended",
"scatterTip": "Scatter chart data is coordinate points, JSON mode is recommended",
"ringTip": "Ring progress data JSON mode is recommended",
"heatmapTip": "Heatmap data JSON mode is recommended",
"klineTip": "K-line data JSON mode is recommended",
"sankeyTip": "Sankey data JSON mode is recommended",
"gaugeTip": "Gauge data set 'Current Value' in Basic Config"
},
"dataSource": {
"dataType": "Data Type",
"static": "Static Data",
"uploadImage": "Image Upload",
"uploadVideo": "Video Upload",
"dataSource": "Data Source",
"api": "API Interface",
"config": "Data Source Config",
"select": "Select Data Source",
"selectPlaceholder": "Please select data source",
"mapping": "Field Mapping",
"sourceField": "Source Field",
"targetField": "Target",
"addMapping": "Add Mapping",
"mappingTip": "Tip: Data source mapping is configured in 'Data Source Management', additional mappings here override default.",
"refresh": "Refresh Config",
"autoRefresh": "Auto Refresh",
"interval": "Refresh Interval",
"intervalUnit": "Unit: seconds, min 5 seconds",
"apiConfig": "API Config",
"apiUrl": "Request URL",
"apiUrlPlaceholder": "e.g. /api/dashboard/stats",
"apiMethod": "Request Method",
"dataPath": "Data Path",
"dataPathPlaceholder": "e.g. data.list",
"dataPathTip": "Path to extract data from response",
"enableRefresh": "Enable Refresh",
"intervalSeconds": "Refresh Interval (s)",
"paramBinding": "Param Binding",
"paramName": "Data Source Param",
"globalParam": "Filter Param",
"addBinding": "Add Binding",
"paramBindingTip": "Bind filter component parameters to data source parameters for interactive filtering. Select data source parameter on the left, select or enter filter parameter key on the right.",
"required": "Required"
},
"widget": {
"todo": {
"items": "Todo Items",
"add": "Add Todo",
"done": "Done",
"newItem": "New Todo",
"priority": {
"high": "High",
"medium": "Medium",
"low": "Low"
}
},
"notice": {
"tip": "Notice list automatically fetched from system messages, no manual configuration needed",
"limit": "Display Count"
},
"announcement": {
"tip": "Announcement list automatically fetched from system announcements, no manual configuration needed",
"limit": "Display Count"
},
"ranking": {
"items": "Ranking Items",
"add": "Add Ranking",
"newItem": "New Member"
},
"quickLinks": {
"title": "Quick Links",
"tip": "Click grid to select menu, icon automatically uses menu config",
"iconColorDefault": "Default Theme Color",
"selectMenu": "Select Menu"
},
"welcome": {
"subtitle": "Subtitle",
"subtitlePlaceholder": "Please enter subtitle",
"showTime": "Show Time"
},
"countdown": {
"config": "Countdown Settings",
"targetTime": "Target Time",
"targetTimePlaceholder": "Select target time",
"showDays": "Show Days",
"showHours": "Show Hours",
"showMinutes": "Show Minutes",
"showSeconds": "Show Seconds",
"finishedText": "Finished Text",
"finishedTextPlaceholder": "e.g. Finished"
},
"clock": {
"config": "Clock Settings",
"showDate": "Show Date",
"showSeconds": "Show Seconds",
"format24": "24H Format",
"timezone": "Timezone",
"timezoneOptions": {
"local": "Local Time",
"utc": "UTC",
"beijing": "Beijing Time",
"tokyo": "Tokyo Time",
"newyork": "New York Time",
"london": "London Time"
}
},
"weather": {
"config": "Weather Config",
"cityName": "City Name",
"cityNamePlaceholder": "e.g. Beijing",
"presetCity": "Preset City",
"presetCityPlaceholder": "Quick select city",
"latitude": "Latitude",
"longitude": "Longitude",
"autoLocate": "Auto Locate",
"refreshInterval": "Refresh Interval (min)",
"refreshIntervalTip": "Unit: minutes, minimum 5 minutes"
},
"carousel": {
"config": "Carousel Settings",
"autoplay": "Autoplay",
"interval": "Interval",
"intervalUnit": "ms",
"showIndicator": "Show Indicator",
"showArrow": "Show Arrow",
"imageList": "Image List",
"addImage": "Add Image",
"selectFromGallery": "From Gallery",
"selectedImages": "Selected Images",
"selectTip": "Please select images from gallery",
"newItem": "New Image"
},
"table": {
"config": "Table Settings",
"stripe": "Stripe",
"border": "Border",
"showIndex": "Show Index",
"showHeader": "Show Header",
"highlightCurrentRow": "Highlight Current Row",
"alignment": "Alignment",
"headerAlign": "Header Align",
"cellAlign": "Cell Align",
"heightConfig": "Height Config",
"height": "Fixed Height",
"heightPlaceholder": "e.g. 300px or 100%",
"heightTip": "Supports pixels or percentage",
"maxHeight": "Max Height",
"maxHeightPlaceholder": "e.g. 500",
"maxHeightTip": "Shows scrollbar when exceeded",
"customization": "Customization",
"emptyText": "Empty Text",
"emptyTextPlaceholder": "No data",
"headerBgColor": "Header Background",
"summaryConfig": "Summary Config",
"showSummary": "Show Footer Summary",
"summaryType": "Summary Type",
"summaryTypes": {
"sum": "Sum",
"avg": "Average",
"count": "Count",
"max": "Maximum",
"min": "Minimum"
},
"summaryPrecision": "Decimal Places",
"summaryColumns": "Summary Columns",
"noColumnsConfigured": "Please configure table columns first",
"mergeConfig": "Merge Config",
"enableMerge": "Enable Merge",
"mergeRules": "Merge Rules",
"mergeTypes": {
"row": "Row Merge",
"column": "Column Merge"
},
"selectMergeField": "Select merge field",
"rowIndex": "Row Index",
"startCol": "Start Column",
"colspan": "Column Span",
"addMergeRule": "Add Merge Rule",
"size": "Size",
"sizeOptions": {
"default": "Default",
"large": "Large",
"small": "Small"
},
"columnConfig": "Column Config",
"columnConfigJson": "Column Config JSON",
"tableData": "Table Data",
"tableDataJson": "Table Data JSON",
"complexDataTip": "Table data structure is complex, JSON mode is recommended"
},
"iframe": {
"config": "Display Settings",
"showBorder": "Show Border",
"allowFullscreen": "Allow Fullscreen",
"pageAddress": "Page Address"
},
"video": {
"config": "Playback Settings",
"autoplay": "Autoplay",
"loop": "Loop",
"muted": "Muted",
"controls": "Native Controls",
"videoAddress": "Video Address",
"poster": "Poster",
"posterPlaceholder": "Poster Image URL (Optional)",
"selectFromFileLib": "From File Library"
},
"image": {
"title": "Image",
"config": "Image Settings",
"alt": "Alt Text",
"altPlaceholder": "Alt text when image cannot be displayed",
"fit": "Fit Mode",
"fitOptions": {
"cover": "Cover",
"contain": "Contain",
"fill": "Fill",
"none": "None",
"scaleDown": "Scale Down"
},
"lazy": "Lazy Load",
"previewConfig": "Preview Settings",
"zIndex": "Z-Index",
"closeOnClickModal": "Close on Click Modal",
"url": "Image URL",
"urlPlaceholder": "Please enter image URL",
"link": "Link URL",
"previewList": "Preview List",
"previewListTip": "List of images to preview when clicked, current image if empty",
"addImage": "Add Image",
"mainImage": "Main Image"
},
"common": {
"staticDataTip": "Current component uses static data, can be modified in Basic Config"
},
"formRender": {
"formConfig": "Form Config",
"formCode": "Select Form",
"formCodePlaceholder": "Please select a form",
"containerType": "Container Type",
"drawer": "Drawer",
"dialog": "Dialog",
"displayConfig": "Display Config",
"showToolbar": "Show Toolbar",
"showPagination": "Show Pagination",
"pageSize": "Page Size",
"buttonConfig": "Button Config",
"showAdd": "Add Button",
"showView": "View Button",
"showEdit": "Edit Button",
"showDelete": "Delete Button"
}
},
"fieldLabels": {
"title": "Title",
"value": "Value",
"time": "Time",
"trend": "Trend %",
"trendLabel": "Trend Desc",
"prefix": "Prefix",
"suffix": "Suffix",
"percentage": "Percentage",
"xAxisData": "X-Axis Data",
"seriesData": "Series Data",
"currentValue": "Current Value",
"min": "Min",
"max": "Max",
"listData": "List Data",
"linkData": "Link Data"
},
"resultType": {
"list": "List",
"tree": "Tree",
"object": "Object",
"value": "Value",
"chartAxis": "Axis Chart",
"chartPie": "Pie Chart",
"chartGauge": "Gauge Chart",
"chartRadar": "Radar Chart",
"chartScatter": "Scatter Chart",
"chartHeatmap": "Heatmap"
},
"borderStyle": {
"solid": "Solid",
"dashed": "Dashed",
"dotted": "Dotted",
"none": "None"
}
},
"widgets": {
"announcement": {
"markAllRead": "Mark All as Read",
"top": "Top",
"noData": "No Announcements",
"loading": "Loading...",
"noContent": "No detailed content",
"publisher": "Publisher: ",
"priority": {
"normal": "Normal",
"important": "Important",
"urgent": "Urgent"
},
"time": {
"justNow": "Just now",
"minutesAgo": " minutes ago",
"hoursAgo": " hours ago",
"daysAgo": " days ago"
}
},
"weather": {
"city": "City",
"humidity": "Humidity",
"wind": "Wind",
"loading": "Loading weather data...",
"error": "Failed to fetch weather data",
"codes": {
"clear": "Clear",
"mainlyClear": "Mainly Clear",
"partlyCloudy": "Partly Cloudy",
"overcast": "Overcast",
"fog": "Fog",
"drizzle": "Drizzle",
"rain": "Rain",
"snow": "Snow",
"showers": "Showers",
"snowShowers": "Snow Showers",
"thunderstorm": "Thunderstorm"
},
"windDir": {
"n": "N",
"ne": "NE",
"e": "E",
"se": "SE",
"s": "S",
"sw": "SW",
"w": "W",
"nw": "NW"
}
},
"chart": {
"visits": "Visits"
},
"iframe": {
"refresh": "Refresh",
"openNew": "Open in New Tab",
"placeholder": "iframe Embedding",
"noUrl": "URL not set",
"loading": "Loading...",
"loadFailed": "Load failed",
"retry": "Click to retry"
},
"todo": {
"title": "To-do List",
"noData": "No to-dos",
"priority": {
"high": "High",
"medium": "Medium",
"low": "Low"
}
},
"dataTable": {
"status": {
"normal": "Normal",
"warning": "Warning",
"error": "Error",
"success": "Success",
"failed": "Failed",
"processing": "Processing"
},
"summary": {
"sum": "Sum",
"avg": "Avg",
"count": "Count",
"max": "Max",
"min": "Min"
}
},
"formRender": {
"placeholder": "Form Render Widget",
"noFormCode": "Please configure form code",
"deleteConfirm": "Are you sure to delete this data?",
"batchDeleteConfirm": "Are you sure to delete the selected {count} records?",
"importResult": "Successfully imported {success}, failed {failed}",
"view": "View"
},
"welcome": {
"greeting": {
"night": "Good late night",
"morning": "Good morning",
"morning2": "Good morning",
"noon": "Good noon",
"afternoon": "Good afternoon",
"evening": "Good evening"
}
},
"countdown": {
"day": "d",
"hour": "h",
"minute": "m",
"second": "s",
"finished": "Finished"
},
"video": {
"placeholder": "Video Player"
},
"ranking": {
"tenThousand": "W"
},
"clock": {
"weekdays": ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
"year": "-",
"month": "-",
"day": ""
},
"image": {
"title": "Image",
"noData": "No images"
},
"approvalCenter": {
"initiated": "My Initiated",
"pending": "My Pending",
"handling": "My Handling",
"signing": "My Signing",
"handled": "My Handled",
"copy": "CC to Me",
"start": "Start Process",
"more": "More",
"config": "Approval Center Config",
"routePrefix": "Route Prefix"
},
"myApps": {
"more": "More",
"noData": "No apps"
},
"serverMonitor": {
"config": "Server Monitor Config",
"cpuBgColor": "CPU Background",
"memoryBgColor": "Memory Background",
"diskBgColor": "Disk Background",
"networkBgColor": "Network Background",
"core": " Cores",
"thread": " Threads",
"memory": "Memory",
"disk": "Disk",
"network": "Network",
"read": "Read",
"write": "Write",
"upload": "Upload",
"download": "Download",
"totalRW": "Total R/W",
"uptime": "Uptime",
"days": "d ",
"hours": "h ",
"minutes": "m"
}
},
"filter": {
"config": "Filter Config",
"paramKey": "Param Key",
"paramKeyPlaceholder": "e.g. status",
"startParamKey": "Start Date Param",
"startParamKeyPlaceholder": "e.g. start_date",
"endParamKey": "End Date Param",
"endParamKeyPlaceholder": "e.g. end_date",
"label": "Label",
"labelPlaceholder": "Enter label",
"defaultValue": "Default Value",
"noParamKey": "Param key not configured",
"optionConfig": "Option Config",
"optionSource": "Option Source",
"staticOptions": "Static Options",
"optionLabel": "Label",
"optionValue": "Value",
"addOption": "Add Option",
"optionDataSource": "Option Data Source",
"labelField": "Label Field",
"valueField": "Value Field",
"multiple": "Multiple",
"dateFormat": "Date Format",
"styleConfig": "Style Config",
"labelPosition": "Label Position",
"labelPositionLeft": "Left",
"labelPositionTop": "Top",
"labelPositionHidden": "Hidden",
"labelWidth": "Label Width",
"labelAlign": "Label Align",
"alignLeft": "Left",
"alignCenter": "Center",
"alignRight": "Right",
"componentSize": "Size",
"sizeLarge": "Large",
"sizeDefault": "Default",
"sizeSmall": "Small",
"showBorder": "Show Border",
"borderRadius": "Border Radius"
},
"gradient": {
"solid": "Solid",
"gradient": "Gradient",
"startColor": "Start",
"endColor": "End",
"direction": "Direction",
"noColor": "Not set",
"presetTitle": "Preset Gradients",
"presets": {
"warmSunrise": "Warm Sunrise",
"oceanBreeze": "Ocean Breeze",
"freshMint": "Fresh Mint",
"peachGlow": "Peach Glow",
"lavenderDream": "Lavender Dream",
"skyBlue": "Sky Blue",
"roseWater": "Rose Water",
"softGrass": "Soft Grass",
"winterNymph": "Winter Nymph",
"cottonCandy": "Cotton Candy",
"sunnyMorning": "Sunny Morning",
"crystalClear": "Crystal Clear"
}
}
}
@@ -0,0 +1,873 @@
{
"title": "仪表盘设计",
"preview": "预览",
"save": "保存",
"saveSuccess": "保存成功",
"clear": "清空",
"clearConfirm": "确定要清空画布吗?此操作不可撤销。",
"clearSuccess": "已清空",
"export": "导出",
"exportSuccess": "导出成功",
"import": "导入",
"importTitle": "导入配置",
"importPlaceholder": "请粘贴 JSON 配置内容...",
"importSuccess": "导入成功",
"importError": "配置格式错误",
"importEmpty": "请输入配置内容",
"viewCode": "查看代码",
"jsonPreview": "JSON 预览",
"copyCode": "复制代码",
"copySuccess": "已复制到剪贴板",
"copyError": "复制失败",
"undoSuccess": "已撤销",
"redoSuccess": "已重做",
"copyWidgetSuccess": "已复制",
"pasteWidgetSuccess": "已粘贴",
"deleteWidgetSuccess": "已删除",
"noWidgetsTip": "请先添加组件",
"noConfigTip": "暂无仪表盘配置",
"loadingData": "数据加载中...",
"loadDataError": "数据加载失败",
"unknownWidget": "未知组件类型",
"widgetCount": "{count} 个组件",
"dragTip": "从左侧拖拽组件到此处",
"add": "添加",
"copy": "复制",
"delete": "删除",
"reset": "重置",
"clean": "清除",
"canvas": {
"title": "设计画布",
"adaptive": "自适应",
"actualSize": "实际尺寸",
"settings": "画布设置",
"undo": "撤销",
"redo": "重做"
},
"months": {
"jan": "1月",
"feb": "2月",
"mar": "3月",
"apr": "4月",
"may": "5月",
"jun": "6月",
"jul": "7月",
"aug": "8月",
"sep": "9月",
"oct": "10月",
"nov": "11月",
"dec": "12月"
},
"weekdaysShort": {
"mon": "周一",
"tue": "周二",
"wed": "周三",
"thu": "周四",
"fri": "周五",
"sat": "周六",
"sun": "周日"
},
"location": {
"east": "华东",
"south": "华南",
"north": "华北",
"central": "华中",
"southwest": "西南",
"northwest": "西北",
"northeast": "东北"
},
"material": {
"title": "组件库",
"search": "搜索组件...",
"category": {
"common": "通用",
"chart": "图表",
"filter": "筛选器",
"map": "地图",
"media": "多媒体",
"other": "其他"
},
"widgets": {
"statCard": "统计卡片",
"progressCard": "进度卡片",
"chartLine": "折线图",
"chartBar": "柱状图",
"chartPie": "饼图",
"chartGauge": "仪表盘",
"chartArea": "面积图",
"chartRadar": "雷达图",
"chartFunnel": "漏斗图",
"chartScatter": "散点图",
"chartRing": "环形进度",
"chartHeatmap": "热力图",
"chartKline": "K线图",
"chartSankey": "桑基图",
"todoList": "待办列表",
"noticeList": "消息通知",
"announcementList": "公告列表",
"rankingList": "排行榜",
"quickLinks": "快捷入口",
"welcomeCard": "欢迎卡片",
"calendar": "日历",
"countdown": "倒计时",
"clock": "时钟",
"weather": "天气",
"imageCarousel": "图片轮播",
"dataTable": "数据表格",
"iframe": "iframe 嵌入",
"videoPlayer": "视频播放器",
"image": "图片",
"formRender": "表单渲染",
"approvalCenter": "审批中心",
"myApps": "我的应用",
"serverMonitor": "服务器信息",
"filterInput": "输入筛选",
"filterSelect": "下拉筛选",
"filterDate": "日期筛选",
"filterDateRange": "日期范围"
},
"defaultProps": {
"statTitle": "统计数据",
"trendLabel": "较昨日",
"progressTitle": "完成进度",
"visitTrend": "访问趋势",
"visits": "访问量",
"downloads": "下载量",
"salesStat": "销售统计",
"trafficSource": "流量来源",
"searchEngine": "搜索引擎",
"directAccess": "直接访问",
"emailMarketing": "邮件营销",
"unionAds": "联盟广告",
"videoAds": "视频广告",
"sysLoad": "系统负载",
"abilityEval": "能力评估",
"sales": "销售",
"mgmt": "管理",
"tech": "技术",
"cs": "客服",
"rd": "研发",
"mkt": "市场",
"budget": "预算",
"actual": "实际",
"convFunnel": "转化漏斗",
"visit": "访问",
"consult": "咨询",
"intent": "意向",
"order": "下单",
"deal": "成交",
"dataDist": "数据分布",
"height": "身高 (cm)",
"weight": "体重 (kg)",
"male": "男性",
"female": "女性",
"kpiDone": "KPI完成度",
"salesVolume": "销售额",
"orderVolume": "订单量",
"customerCount": "客户数",
"weekVisitHeat": "周访问热力",
"stockTrend": "股价走势",
"todoTitle": "待办事项",
"todoItem1": "完成项目报告",
"todoItem2": "团队周会",
"todoItem3": "代码审查",
"latestNotice": "消息通知",
"latestAnnouncement": "最新公告",
"salesRanking": "销售排行",
"welcomeTitle": "欢迎回来",
"welcomeSubtitle": "今天是个好日子",
"countdownTitle": "活动倒计时",
"todayWeather": "今日天气",
"dataList": "数据列表",
"externalPage": "外部页面",
"itemName": "项目",
"imageTitle": "图片",
"myDashboard": "我的仪表盘",
"formRender": "表单数据",
"approvalCenter": "审批中心",
"myApps": "我的应用",
"serverMonitor": "服务器信息",
"filterInput": "关键词",
"filterInputPlaceholder": "请输入关键词筛选...",
"filterSelect": "选择筛选",
"filterSelectPlaceholder": "请选择...",
"filterDate": "日期",
"filterDatePlaceholder": "请选择日期",
"filterDateRange": "日期范围",
"filterStartDate": "开始日期",
"filterEndDate": "结束日期"
}
},
"attribute": {
"title": "属性面板",
"canvasConfig": "画布配置",
"widgetConfig": "组件配置",
"styleConfig": "样式配置",
"dataConfig": "数据配置",
"globalSettings": "仪表盘全局设置",
"dashboardName": "仪表盘名称",
"gridLayout": "网格布局",
"columns": "列数",
"rowHeight": "行高 (px)",
"widgetMargin": "组件间距 (px)",
"horizontal": "水平",
"vertical": "垂直",
"background": "背景",
"backgroundColor": "背景颜色",
"reset": "重置",
"display": "显示",
"outerMargin": "四周边距",
"outerMarginTip": "渲染时显示四周边距",
"widgetTitle": "标题",
"layout": "布局",
"widthGrid": "宽度 (格)",
"heightGrid": "高度 (格)",
"layoutTip": "提示:直接在画布上拖拽调整组件大小",
"iconConfig": "图标配置",
"icon": "图标",
"iconColor": "图标颜色",
"status": "状态",
"strokeWidth": "线条宽度",
"showText": "显示文字",
"border": "边框",
"borderWidth": "边框宽度",
"borderColor": "边框颜色",
"borderStyleLabel": "边框样式",
"borderRadius": "圆角",
"shadow": "阴影",
"enableShadow": "启用阴影",
"shadowColor": "阴影颜色",
"shadowBlur": "模糊半径",
"chartLayout": "图表布局",
"margin": {
"left": "左边距 (%)",
"right": "右边距 (%)",
"top": "上边距 (%)",
"bottom": "下边距 (%)"
},
"tabs": {
"basic": "基础",
"data": "数据",
"style": "样式"
},
"placeholder": {
"name": "请输入名称",
"title": "请输入标题",
"remark": "请输入描述"
},
"iconOptions": {
"trending": "趋势",
"creditCard": "信用卡",
"users": "用户",
"activity": "活动",
"bell": "铃铛",
"award": "奖杯"
},
"progressStatus": {
"default": "默认",
"success": "成功",
"warning": "警告",
"exception": "异常"
},
"colorTheme": {
"label": "颜色主题",
"default": "默认",
"fresh": "清新",
"business": "商务",
"tech": "科技",
"warm": "暖色"
},
"chart": {
"smooth": "平滑曲线",
"showArea": "显示面积",
"showSymbol": "显示数据点",
"symbolSize": "数据点大小",
"lineWidth": "线条宽度",
"axis": "坐标轴",
"xAxisName": "X轴名称",
"yAxisName": "Y轴名称",
"nameLocation": "名称位置",
"legend": "图例",
"showLegend": "显示图例",
"legendPosition": "图例位置",
"barWidth": "柱子宽度",
"barRadius": "圆角大小",
"horizontal": "水平方向",
"stack": "堆叠显示",
"showBackground": "显示背景",
"pieType": "图表类型",
"showLabel": "显示标签",
"labelPosition": "标签位置",
"minValue": "最小值",
"maxValue": "最大值",
"splitNumber": "刻度数量",
"showProgress": "显示进度",
"showMA5": "显示MA5",
"showMA10": "显示MA10",
"shape": "形状",
"sort": "排序方式",
"orient": "方向",
"pointSize": "点大小",
"numerical": "数值配置",
"currentValue": "当前值",
"min": "最小值",
"max": "最大值",
"unit": "单位",
"maConfig": "均线配置",
"prefix": "前缀",
"suffix": "后缀",
"trendConfig": "趋势配置",
"trendValue": "趋势值 (%)",
"trendTip": "正数为上升,负数为下降",
"trendLabel": "趋势说明",
"seriesNamePrefix": "系列",
"itemNamePrefix": "项目",
"placeholder": {
"xAxis": "如:月份",
"yAxis": "如:销量",
"unit": "如:%",
"title": "请输入标题",
"xAxis2": "用逗号分隔,如:1月, 2月, 3月",
"seriesData": "用逗号分隔,如:100, 200, 300",
"seriesName": "系列名称",
"prefix": "如:¥",
"suffix": "如:元、%",
"trendLabel": "如:较昨日",
"subtitle": "请输入副标题"
},
"location": {
"start": "起点",
"middle": "中间",
"end": "末端",
"top": "顶部",
"bottom": "底部",
"left": "左侧",
"right": "右侧"
},
"pieTypes": {
"pie": "饼图",
"ring": "环形图",
"rose": "玫瑰图"
},
"labelPositions": {
"outside": "外部",
"inside": "内部"
},
"barWidthOptions": {
"auto": "自动",
"thin": "细 (30%)",
"medium": "中 (50%)",
"thick": "粗 (70%)"
},
"shapes": {
"polygon": "多边形",
"circle": "圆形"
},
"sortOptions": {
"descending": "降序",
"ascending": "升序",
"none": "不排序"
},
"orientOptions": {
"vertical": "垂直",
"horizontal": "水平"
},
"dataEditMode": "编辑模式",
"dataEditForm": "表单",
"dataEditJson": "JSON",
"jsonPlaceholder": "JSON 格式数据",
"jsonTip": "直接编辑 JSON 数据,失去焦点后自动应用",
"xAxisData": "X轴数据",
"seriesDataLabel": "系列数据",
"addSeries": "添加系列",
"dataItem": "数据项",
"addItem": "添加数据项",
"radarTip": "雷达图数据结构较复杂,建议使用 JSON 模式编辑",
"scatterTip": "散点图数据为坐标点数组,建议使用 JSON 模式编辑",
"ringTip": "环形进度数据建议使用 JSON 模式编辑",
"heatmapTip": "热力图数据建议使用 JSON 模式编辑",
"klineTip": "K线图数据建议使用 JSON 模式编辑",
"sankeyTip": "桑基图数据建议使用 JSON 模式编辑",
"gaugeTip": "仪表盘数据在基础配置中设置\"当前值\""
},
"dataSource": {
"dataType": "数据类型",
"static": "静态数据",
"uploadImage": "图片上传",
"uploadVideo": "视频上传",
"dataSource": "数据源",
"api": "API接口",
"config": "数据源配置",
"select": "选择数据源",
"selectPlaceholder": "请选择数据源",
"mapping": "字段映射",
"sourceField": "源字段",
"targetField": "目标",
"addMapping": "添加映射",
"mappingTip": "提示:数据源已在「数据源管理」中配置好字段映射,此处可添加额外的字段映射覆盖默认配置。",
"refresh": "刷新配置",
"autoRefresh": "自动刷新",
"interval": "刷新间隔",
"intervalUnit": "单位:秒,最小 5 秒",
"apiConfig": "接口配置",
"apiUrl": "请求地址",
"apiUrlPlaceholder": "如:/api/dashboard/stats",
"apiMethod": "请求方式",
"dataPath": "数据路径",
"dataPathPlaceholder": "如:data.list",
"dataPathTip": "从响应中提取数据的路径",
"enableRefresh": "启用刷新",
"intervalSeconds": "刷新间隔 (秒)",
"paramBinding": "参数绑定",
"paramName": "数据源参数",
"globalParam": "筛选器参数",
"addBinding": "添加绑定",
"paramBindingTip": "将筛选器组件的参数绑定到数据源参数,实现筛选联动。左侧选择数据源中定义的参数,右侧选择或输入筛选器的参数键名。",
"required": "必填"
},
"widget": {
"todo": {
"items": "待办项",
"add": "添加待办",
"done": "完成",
"newItem": "新待办",
"priority": {
"high": "高",
"medium": "中",
"low": "低"
}
},
"notice": {
"tip": "通知列表自动从系统消息中获取数据,无需手动配置",
"limit": "显示数量"
},
"announcement": {
"tip": "公告列表自动从系统公告中获取数据,无需手动配置",
"limit": "显示数量"
},
"ranking": {
"items": "排行项",
"add": "添加排行",
"newItem": "新成员"
},
"quickLinks": {
"title": "快捷入口",
"tip": "点击格子选择菜单,图标自动使用菜单配置",
"iconColorDefault": "默认主题色",
"selectMenu": "选择菜单"
},
"welcome": {
"subtitle": "副标题",
"subtitlePlaceholder": "请输入副标题",
"showTime": "显示时间"
},
"countdown": {
"config": "倒计时设置",
"targetTime": "目标时间",
"targetTimePlaceholder": "选择目标时间",
"showDays": "显示天数",
"showHours": "显示小时",
"finishedTextPlaceholder": "已结束"
},
"clock": {
"timezone": "时区"
},
"weather": {
"config": "天气配置",
"cityName": "城市名称",
"cityNamePlaceholder": "如:北京",
"presetCity": "预设城市",
"presetCityPlaceholder": "快速选择城市",
"latitude": "纬度",
"longitude": "经度",
"autoLocate": "自动定位",
"refreshInterval": "刷新间隔(分钟)",
"refreshIntervalTip": "单位:分钟,最小 5 分钟"
},
"iframe": {
"config": "显示设置",
"showBorder": "显示边框",
"allowFullscreen": "允许全屏",
"pageAddress": "页面地址"
},
"video": {
"config": "播放设置",
"autoplay": "自动播放",
"loop": "循环播放",
"muted": "静音",
"controls": "原生控制栏",
"videoAddress": "视频地址",
"poster": "封面图",
"posterPlaceholder": "封面图片 URL(可选)",
"selectFromFileLib": "从文件库选择"
},
"image": {
"title": "图片",
"config": "图片设置",
"alt": "替代文本",
"altPlaceholder": "图片无法显示时的替代文本",
"fit": "填充方式",
"fitOptions": {
"cover": "覆盖 (cover)",
"contain": "包含 (contain)",
"fill": "填充 (fill)",
"none": "无 (none)",
"scaleDown": "缩小 (scale-down)"
},
"lazy": "懒加载",
"previewConfig": "预览设置",
"zIndex": "预览层级",
"closeOnClickModal": "点击遮罩关闭",
"url": "图片 URL",
"urlPlaceholder": "请输入图片URL",
"link": "跳转链接(可选)",
"previewList": "预览图片列表",
"previewListTip": "点击图片时可预览的图片列表,为空时预览当前图片",
"addImage": "添加图片",
"mainImage": "主图"
},
"common": {
"staticDataTip": "当前组件使用静态数据,可在基础配置中修改"
},
"formRender": {
"formConfig": "表单配置",
"formCode": "选择表单",
"formCodePlaceholder": "请选择表单",
"containerType": "容器类型",
"drawer": "抽屉",
"dialog": "弹窗",
"displayConfig": "显示配置",
"showToolbar": "显示工具栏",
"showPagination": "显示分页",
"pageSize": "每页条数",
"buttonConfig": "按钮配置",
"showAdd": "新增按钮",
"showView": "查看按钮",
"showEdit": "编辑按钮",
"showDelete": "删除按钮"
},
"table": {
"config": "表格配置",
"stripe": "斑马纹",
"border": "边框",
"showIndex": "显示序号",
"showHeader": "显示表头",
"highlightCurrentRow": "高亮当前行",
"alignment": "对齐方式",
"headerAlign": "表头对齐",
"cellAlign": "单元格对齐",
"heightConfig": "高度配置",
"height": "固定高度",
"heightPlaceholder": "如:300px 或 100%",
"heightTip": "支持像素值或百分比",
"maxHeight": "最大高度",
"maxHeightPlaceholder": "如:500",
"maxHeightTip": "超出后显示滚动条",
"customization": "自定义",
"emptyText": "空数据提示",
"emptyTextPlaceholder": "暂无数据",
"headerBgColor": "表头背景色",
"summaryConfig": "统计配置",
"showSummary": "显示表尾统计",
"summaryType": "统计类型",
"summaryTypes": {
"sum": "合计",
"avg": "平均值",
"count": "计数",
"max": "最大值",
"min": "最小值"
},
"summaryPrecision": "小数位数",
"summaryColumns": "统计列",
"noColumnsConfigured": "请先配置表格列",
"mergeConfig": "合并配置",
"enableMerge": "启用合并",
"mergeRules": "合并规则",
"mergeTypes": {
"row": "行合并",
"column": "列合并"
},
"selectMergeField": "选择合并字段",
"rowIndex": "行号",
"startCol": "起始列",
"colspan": "合并列数",
"addMergeRule": "添加合并规则",
"columnConfig": "列配置",
"columnConfigJson": "JSON 格式列配置",
"tableData": "表格数据",
"tableDataJson": "JSON 格式表格数据",
"complexDataTip": "表格数据结构较复杂,建议使用 JSON 模式编辑",
"sizeOptions": {
"default": "默认",
"large": "大",
"small": "小"
}
}
},
"fieldLabels": {
"title": "标题",
"value": "数值",
"time": "时间",
"trend": "趋势百分比",
"trendLabel": "趋势说明",
"prefix": "前缀",
"suffix": "后缀",
"percentage": "百分比",
"xAxisData": "X轴数据",
"seriesData": "系列数据",
"currentValue": "当前值",
"min": "最小值",
"max": "最大值",
"listData": "列表数据",
"linkData": "链接数据"
},
"resultType": {
"list": "列表",
"tree": "树形",
"object": "对象",
"value": "单值",
"chartAxis": "轴向图表",
"chartPie": "饼图",
"chartGauge": "仪表盘",
"chartRadar": "雷达图",
"chartScatter": "散点图",
"chartHeatmap": "热力图"
},
"borderStyle": {
"solid": "实线",
"dashed": "虚线",
"dotted": "点线",
"none": "无"
}
},
"widgets": {
"announcement": {
"markAllRead": "全部已读",
"top": "置顶",
"noData": "暂无公告",
"loading": "加载中...",
"noContent": "暂无详细内容",
"publisher": "发布者:",
"priority": {
"normal": "普通",
"important": "重要",
"urgent": "紧急"
},
"time": {
"justNow": "刚刚",
"minutesAgo": "分钟前",
"hoursAgo": "小时前",
"daysAgo": "天前"
}
},
"weather": {
"city": "城市",
"humidity": "湿度",
"wind": "风力",
"loading": "加载天气数据中...",
"error": "获取天气数据失败",
"codes": {
"clear": "晴",
"mainlyClear": "晴间多云",
"partlyCloudy": "多云",
"overcast": "阴",
"fog": "雾",
"drizzle": "毛毛雨",
"rain": "雨",
"snow": "雪",
"showers": "阵雨",
"snowShowers": "阵雪",
"thunderstorm": "雷暴"
},
"windDir": {
"n": "北风",
"ne": "东北风",
"e": "东风",
"se": "东南风",
"s": "南风",
"sw": "西南风",
"w": "西风",
"nw": "西北风"
}
},
"chart": {
"visits": "访问量"
},
"iframe": {
"refresh": "刷新",
"openNew": "新窗口打开",
"placeholder": "iframe 嵌入",
"noUrl": "未设置 URL",
"loading": "加载中...",
"loadFailed": "加载失败",
"retry": "点击重试"
},
"todo": {
"title": "待办事项",
"noData": "暂无待办",
"priority": {
"high": "高",
"medium": "中",
"low": "低"
}
},
"dataTable": {
"status": {
"normal": "正常",
"warning": "警告",
"error": "异常",
"success": "成功",
"failed": "失败",
"processing": "进行中"
},
"summary": {
"sum": "合计",
"avg": "平均",
"count": "计数",
"max": "最大",
"min": "最小"
}
},
"formRender": {
"placeholder": "表单渲染组件",
"noFormCode": "请配置表单编码",
"deleteConfirm": "确定要删除这条数据吗?",
"batchDeleteConfirm": "确定要删除选中的 {count} 条数据吗?",
"importResult": "成功导入 {success} 条,失败 {failed} 条",
"view": "查看"
},
"welcome": {
"greeting": {
"night": "夜深了",
"morning": "早上好",
"morning2": "上午好",
"noon": "中午好",
"afternoon": "下午好",
"evening": "晚上好"
}
},
"countdown": {
"day": "天",
"hour": "时",
"minute": "分",
"second": "秒",
"finished": "已结束"
},
"video": {
"placeholder": "视频播放器"
},
"ranking": {
"tenThousand": "万"
},
"clock": {
"weekdays": ["周日", "周一", "周二", "周三", "周四", "周五", "周六"],
"year": "年",
"month": "月",
"day": "日"
},
"image": {
"title": "图片",
"noData": "暂无图片"
},
"approvalCenter": {
"initiated": "我发起的",
"pending": "我的待办",
"handling": "我的在办",
"signing": "我的待签",
"handled": "我的已办",
"copy": "抄送我的",
"start": "发起流程",
"more": "更多",
"config": "审批中心配置",
"routePrefix": "路由前缀"
},
"myApps": {
"more": "更多",
"noData": "暂无应用"
},
"serverMonitor": {
"config": "服务器信息配置",
"cpuBgColor": "CPU背景色",
"memoryBgColor": "内存背景色",
"diskBgColor": "磁盘背景色",
"networkBgColor": "网络背景色",
"core": "核",
"thread": "线程",
"memory": "内存",
"disk": "磁盘",
"network": "网络",
"read": "读取",
"write": "写入",
"upload": "上传",
"download": "下载",
"totalRW": "累计读/写",
"uptime": "运行时间",
"days": "天",
"hours": "小时",
"minutes": "分钟"
}
},
"filter": {
"config": "筛选器配置",
"paramKey": "参数键名",
"paramKeyPlaceholder": "如:status",
"startParamKey": "开始日期参数",
"startParamKeyPlaceholder": "如:start_date",
"endParamKey": "结束日期参数",
"endParamKeyPlaceholder": "如:end_date",
"label": "标签",
"labelPlaceholder": "请输入标签",
"defaultValue": "默认值",
"noParamKey": "未配置参数键名",
"optionConfig": "选项配置",
"optionSource": "选项来源",
"staticOptions": "静态选项",
"optionLabel": "标签",
"optionValue": "值",
"addOption": "添加选项",
"optionDataSource": "选项数据源",
"labelField": "标签字段",
"valueField": "值字段",
"multiple": "多选",
"dateFormat": "日期格式",
"styleConfig": "样式配置",
"labelPosition": "标签位置",
"labelPositionLeft": "左侧",
"labelPositionTop": "上方",
"labelPositionHidden": "隐藏",
"labelWidth": "标签宽度",
"labelAlign": "标签对齐",
"alignLeft": "左对齐",
"alignCenter": "居中",
"alignRight": "右对齐",
"componentSize": "组件尺寸",
"sizeLarge": "大",
"sizeDefault": "中",
"sizeSmall": "小",
"showBorder": "显示边框",
"borderRadius": "圆角"
},
"gradient": {
"solid": "纯色",
"gradient": "渐变",
"startColor": "起始色",
"endColor": "结束色",
"direction": "方向",
"noColor": "未设置",
"presetTitle": "预设渐变",
"presets": {
"warmSunrise": "暖阳晨曦",
"oceanBreeze": "海洋微风",
"freshMint": "清新薄荷",
"peachGlow": "蜜桃光晕",
"lavenderDream": "薰衣草梦",
"skyBlue": "天空蔚蓝",
"roseWater": "玫瑰花水",
"softGrass": "柔和青草",
"winterNymph": "冬日仙子",
"cottonCandy": "棉花糖",
"sunnyMorning": "阳光早晨",
"crystalClear": "水晶透明"
}
}
}
@@ -0,0 +1,894 @@
{
"title": "儀表板設計",
"preview": "預覽",
"save": "保存",
"saveSuccess": "保存成功",
"clear": "清空",
"clearConfirm": "確定要清空畫布嗎?此操作不可撤銷。",
"clearSuccess": "已清空",
"export": "導出",
"exportSuccess": "導出成功",
"import": "導入",
"importTitle": "導入配置",
"importPlaceholder": "請粘貼 JSON 配置內容...",
"importSuccess": "導入成功",
"importError": "配置格式錯誤",
"importEmpty": "請輸入配置內容",
"viewCode": "查看代碼",
"jsonPreview": "JSON 預覽",
"copyCode": "複製代碼",
"copySuccess": "已複製到剪貼板",
"copyError": "複製失敗",
"undoSuccess": "已撤銷",
"redoSuccess": "已重做",
"copyWidgetSuccess": "已複製",
"pasteWidgetSuccess": "已粘貼",
"deleteWidgetSuccess": "已刪除",
"noWidgetsTip": "請先添加組件",
"noConfigTip": "暫無儀表板配置",
"loadingData": "數據加載中...",
"loadDataError": "數據加載失敗",
"unknownWidget": "未知組件類型",
"widgetCount": "{count} 個組件",
"dragTip": "從左側拖拽組件到此處",
"add": "添加",
"copy": "複製",
"delete": "刪除",
"reset": "重置",
"clean": "清除",
"canvas": {
"title": "設計畫布",
"adaptive": "自適應",
"actualSize": "實際尺寸",
"settings": "畫布設置",
"undo": "撤銷",
"redo": "重做"
},
"months": {
"jan": "1月",
"feb": "2月",
"mar": "3月",
"apr": "4月",
"may": "5月",
"jun": "6月",
"jul": "7月",
"aug": "8月",
"sep": "9月",
"oct": "10月",
"nov": "11月",
"dec": "12月"
},
"weekdaysShort": {
"mon": "週一",
"tue": "週二",
"wed": "週三",
"thu": "週四",
"fri": "週五",
"sat": "週六",
"sun": "週日"
},
"material": {
"title": "組件庫",
"search": "搜索組件...",
"category": {
"common": "通用",
"chart": "圖表",
"filter": "篩選器",
"map": "地圖",
"media": "多媒體",
"other": "其他"
},
"widgets": {
"statCard": "統計卡片",
"progressCard": "進度卡片",
"chartLine": "折線圖",
"chartBar": "柱狀圖",
"chartPie": "餅圖",
"chartGauge": "儀表板",
"chartArea": "面積圖",
"chartRadar": "雷達圖",
"chartFunnel": "漏斗圖",
"chartScatter": "散點圖",
"chartRing": "環形進度",
"chartHeatmap": "熱力圖",
"chartKline": "K線圖",
"chartSankey": "桑基圖",
"todoList": "待辦列表",
"noticeList": "消息通知",
"announcementList": "公告列表",
"rankingList": "排行榜",
"quickLinks": "快捷入口",
"welcomeCard": "歡迎卡片",
"calendar": "日曆",
"countdown": "倒計時",
"clock": "時鐘",
"weather": "天氣",
"imageCarousel": "圖片輪播",
"dataTable": "數據表格",
"iframe": "iframe 嵌入",
"videoPlayer": "視頻播放器",
"image": "圖片",
"formRender": "表單渲染",
"approvalCenter": "審批中心",
"myApps": "我的應用",
"serverMonitor": "服務器信息",
"filterInput": "輸入篩選",
"filterSelect": "下拉篩選",
"filterDate": "日期篩選",
"filterDateRange": "日期範圍"
},
"defaultProps": {
"statTitle": "統計數據",
"trendLabel": "較昨日",
"progressTitle": "完成進度",
"visitTrend": "訪問趨勢",
"visits": "訪問量",
"downloads": "下載量",
"salesStat": "銷售統計",
"trafficSource": "流量來源",
"searchEngine": "搜索引擎",
"directAccess": "直接訪問",
"emailMarketing": "郵件營銷",
"unionAds": "聯盟廣告",
"videoAds": "視頻廣告",
"sysLoad": "系統負載",
"abilityEval": "能力評估",
"sales": "銷售",
"mgmt": "管理",
"tech": "技術",
"cs": "客服",
"rd": "研發",
"mkt": "市場",
"budget": "預算",
"actual": "實際",
"convFunnel": "轉化漏斗",
"visit": "訪問",
"consult": "諮詢",
"intent": "意向",
"order": "下單",
"deal": "成交",
"dataDist": "數據分佈",
"height": "身高 (cm)",
"weight": "體重 (kg)",
"male": "男性",
"female": "女性",
"kpiDone": "KPI完成度",
"salesVolume": "銷售額",
"orderVolume": "訂單量",
"customerCount": "客戶數",
"weekVisitHeat": "周訪問熱力",
"stockTrend": "股價走勢",
"todoTitle": "待辦事項",
"todoItem1": "完成項目報告",
"todoItem2": "團隊週會",
"todoItem3": "代碼審查",
"latestNotice": "消息通知",
"latestAnnouncement": "最新公告",
"salesRanking": "銷售排行",
"welcomeTitle": "歡迎回來",
"welcomeSubtitle": "今天個好日子",
"countdownTitle": "活動倒計時",
"todayWeather": "今日天氣",
"dataList": "數據列表",
"externalPage": "外部頁面",
"itemName": "項目",
"imageTitle": "圖片",
"myDashboard": "我的儀表盤",
"formRender": "表單數據",
"approvalCenter": "審批中心",
"myApps": "我的應用",
"serverMonitor": "服務器信息",
"filterInput": "關鍵詞",
"filterInputPlaceholder": "請輸入關鍵詞篩選...",
"filterSelect": "選擇篩選",
"filterSelectPlaceholder": "請選擇...",
"filterDate": "日期",
"filterDatePlaceholder": "請選擇日期",
"filterDateRange": "日期範圍",
"filterStartDate": "開始日期",
"filterEndDate": "結束日期"
}
},
"attribute": {
"title": "屬性面板",
"canvasConfig": "畫布配置",
"widgetConfig": "組件配置",
"styleConfig": "樣式配置",
"dataConfig": "數據配置",
"globalSettings": "儀表板全局設置",
"dashboardName": "儀表板名稱",
"gridLayout": "網格佈局",
"columns": "列數",
"rowHeight": "行高 (px)",
"widgetMargin": "組件間距 (px)",
"horizontal": "水平",
"vertical": "垂直",
"background": "背景",
"backgroundColor": "背景顏色",
"reset": "重置",
"display": "顯示",
"outerMargin": "四週邊距",
"outerMarginTip": "渲染時顯示四週邊距",
"widgetTitle": "標題",
"layout": "佈局",
"widthGrid": "寬度 (格)",
"heightGrid": "高度 (格)",
"layoutTip": "提示:直接在畫布上拖拽調整組件大小",
"iconConfig": "圖標配置",
"icon": "圖標",
"iconColor": "圖標顏色",
"status": "狀態",
"strokeWidth": "線條寬度",
"showText": "顯示文字",
"border": "邊框",
"borderWidth": "邊框寬度",
"borderColor": "邊框顏色",
"borderStyleLabel": "邊框樣式",
"borderRadius": "圓角",
"shadow": "陰影",
"enableShadow": "啟用陰影",
"shadowColor": "陰影顏色",
"shadowBlur": "模糊半徑",
"chartLayout": "圖表佈局",
"margin": {
"left": "左邊距 (%)",
"right": "右邊距 (%)",
"top": "上邊距 (%)",
"bottom": "下邊距 (%)"
},
"tabs": {
"basic": "基礎",
"data": "數據",
"style": "樣式"
},
"placeholder": {
"name": "請輸入名稱",
"title": "請輸入標題",
"remark": "請輸入描述"
},
"iconOptions": {
"trending": "趨勢",
"creditCard": "信用卡",
"users": "用戶",
"activity": "活動",
"bell": "鈴鐺",
"award": "獎杯"
},
"progressStatus": {
"default": "默認",
"success": "成功",
"warning": "警告",
"exception": "異常"
},
"colorTheme": {
"label": "顏色主題",
"default": "默認",
"fresh": "清新",
"business": "商務",
"tech": "科技",
"warm": "暖色"
},
"chart": {
"smooth": "平滑曲線",
"showArea": "顯示面積",
"showSymbol": "顯示數據點",
"symbolSize": "數據點大小",
"lineWidth": "線條寬度",
"axis": "座標軸",
"xAxisName": "X軸名稱",
"yAxisName": "Y軸名稱",
"nameLocation": "名稱位置",
"legend": "圖例",
"showLegend": "顯示圖例",
"legendPosition": "圖例位置",
"barWidth": "柱子寬度",
"barRadius": "圓角大小",
"horizontal": "水平方向",
"stack": "堆疊顯示",
"showBackground": "顯示背景",
"pieType": "圖表類型",
"showLabel": "顯示標籤",
"labelPosition": "標籤位置",
"minValue": "最小值",
"maxValue": "最大值",
"splitNumber": "刻度數量",
"showProgress": "顯示進度",
"showMA5": "顯示MA5",
"showMA10": "顯示MA10",
"shape": "形狀",
"sort": "排序方式",
"orient": "方向",
"pointSize": "點大小",
"numerical": "數值配置",
"currentValue": "當前值",
"min": "最小值",
"max": "最大值",
"unit": "單位",
"maConfig": "均線配置",
"prefix": "前綴",
"suffix": "後綴",
"trendConfig": "趨勢配置",
"trendValue": "趨勢值 (%)",
"trendTip": "正數為上升,負數為下降",
"trendLabel": "趨勢說明",
"seriesNamePrefix": "系列",
"itemNamePrefix": "項目",
"placeholder": {
"xAxis": "如:月份",
"yAxis": "如:銷量",
"unit": "如:%",
"title": "請輸入標題",
"xAxis2": "用逗號分隔,如:1月, 2月, 3月",
"seriesData": "用逗號分隔,如:100, 200, 300",
"seriesName": "系列名稱",
"prefix": "如:¥",
"suffix": "如:元、%",
"trendLabel": "如:較昨日",
"subtitle": "請輸入副標題"
},
"location": {
"start": "起點",
"middle": "中間",
"end": "末端",
"top": "頂部",
"bottom": "底部",
"left": "左側",
"right": "右側"
},
"pieTypes": {
"pie": "餅圖",
"ring": "環形圖",
"rose": "玫瑰圖"
},
"labelPositions": {
"outside": "外部",
"inside": "內部"
},
"barWidthOptions": {
"auto": "自動",
"thin": "細 (30%)",
"medium": "中 (50%)",
"thick": "粗 (70%)"
},
"shapes": {
"polygon": "多邊形",
"circle": "圓形"
},
"sortOptions": {
"descending": "降序",
"ascending": "升序",
"none": "不排序"
},
"orientOptions": {
"vertical": "垂直",
"horizontal": "水平"
},
"dataEditMode": "編輯模式",
"dataEditForm": "表單",
"dataEditJson": "JSON",
"jsonPlaceholder": "JSON 格式數據",
"jsonTip": "直接編輯 JSON 數據,失去焦點後自動應用",
"xAxisData": "X軸數據",
"seriesDataLabel": "系列數據",
"addSeries": "添加系列",
"dataItem": "數據項",
"addItem": "添加數據項",
"radarTip": "雷達圖數據結構較複雜,建議使用 JSON 模式編輯",
"scatterTip": "散點圖數據為座標點數組,建議使用 JSON 模式編輯",
"ringTip": "環形進度數據建議使用 JSON 模式編輯",
"heatmapTip": "熱力圖數據建議使用 JSON 模式編輯",
"klineTip": "K線圖數據建議使用 JSON 模式編輯",
"sankeyTip": "桑基圖數據建議使用 JSON 模式編輯",
"gaugeTip": "儀表盤數據在基礎配置中設置\"當前值\""
},
"dataSource": {
"dataType": "數據類型",
"static": "靜態數據",
"uploadImage": "圖片上傳",
"uploadVideo": "視頻上傳",
"dataSource": "數據源",
"api": "API接口",
"config": "數據源配置",
"select": "選擇數據源",
"selectPlaceholder": "請選擇數據源",
"mapping": "字段映射",
"sourceField": "源字段",
"targetField": "目標",
"addMapping": "添加映射",
"mappingTip": "提示:數據源已在「數據源管理」中配置好字段映射,此處可添加額外的字段映射覆蓋默認配置。",
"refresh": "刷新配置",
"autoRefresh": "自動刷新",
"interval": "刷新間隔",
"intervalUnit": "單位:秒,最小 5 秒",
"apiConfig": "接口配置",
"apiUrl": "請求地址",
"apiUrlPlaceholder": "如:/api/dashboard/stats",
"apiMethod": "請求方式",
"dataPath": "數據路徑",
"dataPathPlaceholder": "如:data.list",
"dataPathTip": "從響應中提取數據的路徑",
"enableRefresh": "啟用刷新",
"intervalSeconds": "刷新間隔 (秒)",
"paramBinding": "參數綁定",
"paramName": "數據源參數",
"globalParam": "篩選器參數",
"addBinding": "添加綁定",
"paramBindingTip": "將篩選器組件的參數綁定到數據源參數,實現篩選聯動。左側選擇數據源中定義的參數,右側選擇或輸入篩選器的參數鍵名。",
"required": "必填"
},
"widget": {
"todo": {
"items": "待辦項",
"add": "添加待辦",
"done": "完成",
"newItem": "新待辦",
"priority": {
"high": "高",
"medium": "中",
"low": "低"
}
},
"notice": {
"tip": "通知列表自動從系統消息中獲取數據,無需手動配置",
"limit": "顯示數量"
},
"announcement": {
"tip": "公告列表自動從系統公告中獲取數據,無需手動配置",
"limit": "顯示數量"
},
"ranking": {
"items": "排行項",
"add": "添加排行",
"newItem": "新成員"
},
"quickLinks": {
"title": "快捷入口",
"tip": "點擊格子選擇菜單,圖標自動使用菜單配置",
"iconColorDefault": "默認主題色",
"selectMenu": "選擇菜單"
},
"welcome": {
"subtitle": "副標題",
"subtitlePlaceholder": "請輸入副標題",
"showTime": "顯示時間"
},
"countdown": {
"config": "倒計時設置",
"targetTime": "目標時間",
"targetTimePlaceholder": "選擇目標時間",
"showDays": "顯示天數",
"showHours": "顯示小時",
"showMinutes": "顯示分鐘",
"showSeconds": "顯示秒數",
"finishedText": "結束文字",
"finishedTextPlaceholder": "如:已結束"
},
"clock": {
"config": "時鐘設置",
"showDate": "顯示日期",
"showSeconds": "顯示秒數",
"format24": "24小時制",
"timezone": "時區",
"timezoneOptions": {
"local": "本地時間",
"utc": "UTC",
"beijing": "北京時間",
"tokyo": "東京時間",
"newyork": "紐約時間",
"london": "倫敦時間"
}
},
"weather": {
"config": "天氣配置",
"cityName": "城市名稱",
"cityNamePlaceholder": "如:北京",
"presetCity": "預設城市",
"presetCityPlaceholder": "快速選擇城市",
"latitude": "緯度",
"longitude": "經度",
"autoLocate": "自動定位",
"refreshInterval": "刷新間隔(分鐘)",
"refreshIntervalTip": "單位:分鐘,最小 5 分鐘"
},
"carousel": {
"config": "輪播設置",
"autoplay": "自動播放",
"interval": "切換間隔",
"intervalUnit": "毫秒",
"showIndicator": "顯示指示器",
"showArrow": "顯示箭頭",
"imageList": "圖片列表",
"addImage": "添加圖片",
"selectFromGallery": "從圖庫選擇",
"selectedImages": "已選圖片",
"selectTip": "請從圖庫選擇圖片",
"newItem": "新圖片"
},
"table": {
"config": "表格設置",
"stripe": "斑馬紋",
"border": "邊框",
"showIndex": "顯示序號",
"showHeader": "顯示表頭",
"highlightCurrentRow": "高亮當前行",
"alignment": "對齊方式",
"headerAlign": "表頭對齊",
"cellAlign": "單元格對齊",
"heightConfig": "高度配置",
"height": "固定高度",
"heightPlaceholder": "如:300px 或 100%",
"heightTip": "支持像素值或百分比",
"maxHeight": "最大高度",
"maxHeightPlaceholder": "如:500",
"maxHeightTip": "超出後顯示滾動條",
"customization": "自定義",
"emptyText": "空數據提示",
"emptyTextPlaceholder": "暫無數據",
"headerBgColor": "表頭背景色",
"summaryConfig": "統計配置",
"showSummary": "顯示表尾統計",
"summaryType": "統計類型",
"summaryTypes": {
"sum": "合計",
"avg": "平均值",
"count": "計數",
"max": "最大值",
"min": "最小值"
},
"summaryPrecision": "小數位數",
"summaryColumns": "統計列",
"noColumnsConfigured": "請先配置表格列",
"mergeConfig": "合併配置",
"enableMerge": "啟用合併",
"mergeRules": "合併規則",
"mergeTypes": {
"row": "行合併",
"column": "列合併"
},
"selectMergeField": "選擇合併字段",
"rowIndex": "行號",
"startCol": "起始列",
"colspan": "合併列數",
"addMergeRule": "添加合併規則",
"size": "尺寸",
"sizeOptions": {
"default": "默認",
"large": "大",
"small": "小"
},
"columnConfig": "列配置",
"columnConfigJson": "列配置 JSON",
"tableData": "表格數據",
"tableDataJson": "表格數據 JSON",
"complexDataTip": "表格數據結構較複雜,建議使用 JSON 模式編輯"
},
"iframe": {
"config": "顯示設置",
"showBorder": "顯示邊框",
"allowFullscreen": "允許全屏",
"pageAddress": "頁面地址"
},
"video": {
"config": "播放設置",
"autoplay": "自動播放",
"loop": "循環播放",
"muted": "靜音",
"controls": "原生控制欄",
"videoAddress": "視頻地址",
"poster": "封面圖",
"posterPlaceholder": "封面圖片 URL(選填)",
"selectFromFileLib": "從文件庫選擇"
},
"image": {
"title": "圖片",
"config": "圖片設置",
"alt": "替代文本",
"altPlaceholder": "圖片無法顯示時的替代文本",
"fit": "填充方式",
"fitOptions": {
"cover": "覆蓋 (cover)",
"contain": "包含 (contain)",
"fill": "填充 (fill)",
"none": "無 (none)",
"scaleDown": "縮小 (scale-down)"
},
"lazy": "懶加載",
"previewConfig": "預覽設置",
"zIndex": "預覽層級",
"closeOnClickModal": "點擊遮罩關閉",
"url": "圖片 URL",
"urlPlaceholder": "請輸入圖片URL",
"link": "跳轉鏈接(選填)",
"previewList": "預覽圖片列表",
"previewListTip": "點擊圖片時可預覽的圖片列表,為空時預覽當前圖片",
"addImage": "添加圖片",
"mainImage": "主圖"
},
"common": {
"staticDataTip": "當前組件使用靜態數據,可在基礎配置中修改"
},
"formRender": {
"formConfig": "表單配置",
"formCode": "選擇表單",
"formCodePlaceholder": "請選擇表單",
"containerType": "容器類型",
"drawer": "抽屜",
"dialog": "彈窗",
"displayConfig": "顯示配置",
"showToolbar": "顯示工具欄",
"showPagination": "顯示分頁",
"pageSize": "每頁條數",
"buttonConfig": "按鈕配置",
"showAdd": "新增按鈕",
"showView": "查看按鈕",
"showEdit": "編輯按鈕",
"showDelete": "刪除按鈕"
}
},
"fieldLabels": {
"title": "標題",
"value": "數值",
"time": "時間",
"trend": "趨勢百分比",
"trendLabel": "趨勢說明",
"prefix": "前綴",
"suffix": "後綴",
"percentage": "百分比",
"xAxisData": "X軸數據",
"seriesData": "系列數據",
"currentValue": "當前值",
"min": "最小值",
"max": "最大值",
"listData": "列表數據",
"linkData": "鏈接數據"
},
"resultType": {
"list": "列表",
"tree": "樹形",
"object": "對象",
"value": "單值",
"chartAxis": "軸向圖表",
"chartPie": "餅圖",
"chartGauge": "儀表盤",
"chartRadar": "雷達圖",
"chartScatter": "散點圖",
"chartHeatmap": "熱力圖"
},
"borderStyle": {
"solid": "實線",
"dashed": "虛線",
"dotted": "點線",
"none": "無"
}
},
"widgets": {
"announcement": {
"markAllRead": "全部已讀",
"top": "置頂",
"noData": "暫無公告",
"loading": "加載中...",
"noContent": "暫無詳細內容",
"publisher": "發佈者:",
"priority": {
"normal": "普通",
"important": "重要",
"urgent": "緊急"
},
"time": {
"justNow": "剛剛",
"minutesAgo": "分鐘前",
"hoursAgo": "小時前",
"daysAgo": "天前"
}
},
"weather": {
"city": "城市",
"humidity": "濕度",
"wind": "風力",
"loading": "載入天氣資料中...",
"error": "取得天氣資料失敗",
"codes": {
"clear": "晴",
"mainlyClear": "晴間多雲",
"partlyCloudy": "多雲",
"overcast": "陰",
"fog": "霧",
"drizzle": "毛毛雨",
"rain": "雨",
"snow": "雪",
"showers": "陣雨",
"snowShowers": "陣雪",
"thunderstorm": "雷暴"
},
"windDir": {
"n": "北風",
"ne": "東北風",
"e": "東風",
"se": "東南風",
"s": "南風",
"sw": "西南風",
"w": "西風",
"nw": "西北風"
}
},
"chart": {
"visits": "訪問量"
},
"iframe": {
"refresh": "刷新",
"openNew": "新窗口打開",
"placeholder": "iframe 嵌入",
"noUrl": "未設置 URL",
"loading": "加載中...",
"loadFailed": "加載失敗",
"retry": "點擊重試"
},
"todo": {
"title": "待辦事項",
"noData": "暫無待辦",
"priority": {
"high": "高",
"medium": "中",
"low": "低"
}
},
"dataTable": {
"status": {
"normal": "正常",
"warning": "警告",
"error": "異常",
"success": "成功",
"failed": "失敗",
"processing": "進行中"
},
"summary": {
"sum": "合計",
"avg": "平均",
"count": "計數",
"max": "最大",
"min": "最小"
}
},
"formRender": {
"placeholder": "表單渲染組件",
"noFormCode": "請配置表單編碼",
"deleteConfirm": "確定要刪除這條數據嗎?",
"batchDeleteConfirm": "確定要刪除選中的 {count} 條數據嗎?",
"importResult": "成功導入 {success} 條,失敗 {failed} 條",
"view": "查看"
},
"welcome": {
"greeting": {
"night": "夜深了",
"morning": "早上好",
"morning2": "上午好",
"noon": "中午好",
"afternoon": "下午好",
"evening": "晚上好"
}
},
"countdown": {
"day": "天",
"hour": "時",
"minute": "分",
"second": "秒",
"finished": "已結束"
},
"video": {
"placeholder": "視頻播放器"
},
"ranking": {
"tenThousand": "萬"
},
"clock": {
"weekdays": ["週日", "週一", "週二", "週三", "週四", "週五", "週六"],
"year": "年",
"month": "月",
"day": "日"
},
"image": {
"title": "圖片",
"noData": "暫無圖片"
},
"approvalCenter": {
"initiated": "我發起的",
"pending": "我的待辦",
"handling": "我的在辦",
"signing": "我的待簽",
"handled": "我的已辦",
"copy": "抄送我的",
"start": "發起流程",
"more": "更多",
"config": "審批中心配置",
"routePrefix": "路由前綴"
},
"myApps": {
"more": "更多",
"noData": "暫無應用"
},
"serverMonitor": {
"config": "服務器信息配置",
"cpuBgColor": "CPU背景色",
"memoryBgColor": "記憶體背景色",
"diskBgColor": "磁碟背景色",
"networkBgColor": "網絡背景色",
"core": "核",
"thread": "線程",
"memory": "記憶體",
"disk": "磁碟",
"network": "網絡",
"read": "讀取",
"write": "寫入",
"upload": "上傳",
"download": "下載",
"totalRW": "累計讀/寫",
"uptime": "運行時間",
"days": "天",
"hours": "小時",
"minutes": "分鐘"
}
},
"filter": {
"config": "篩選器配置",
"paramKey": "參數鍵名",
"paramKeyPlaceholder": "如:status",
"startParamKey": "開始日期參數",
"startParamKeyPlaceholder": "如:start_date",
"endParamKey": "結束日期參數",
"endParamKeyPlaceholder": "如:end_date",
"label": "標籤",
"labelPlaceholder": "請輸入標籤",
"defaultValue": "默認值",
"noParamKey": "未配置參數鍵名",
"optionConfig": "選項配置",
"optionSource": "選項來源",
"staticOptions": "靜態選項",
"optionLabel": "標籤",
"optionValue": "值",
"addOption": "添加選項",
"optionDataSource": "選項數據源",
"labelField": "標籤字段",
"valueField": "值字段",
"multiple": "多選",
"dateFormat": "日期格式",
"styleConfig": "樣式配置",
"labelPosition": "標籤位置",
"labelPositionLeft": "左側",
"labelPositionTop": "上方",
"labelPositionHidden": "隱藏",
"labelWidth": "標籤寬度",
"labelAlign": "標籤對齊",
"alignLeft": "左對齊",
"alignCenter": "居中",
"alignRight": "右對齊",
"componentSize": "組件尺寸",
"sizeLarge": "大",
"sizeDefault": "中",
"sizeSmall": "小",
"showBorder": "顯示邊框",
"borderRadius": "圓角"
},
"gradient": {
"solid": "純色",
"gradient": "漸變",
"startColor": "起始色",
"endColor": "結束色",
"direction": "方向",
"noColor": "未設置",
"presetTitle": "預設漸變",
"presets": {
"warmSunrise": "暖陽晨曦",
"oceanBreeze": "海洋微風",
"freshMint": "清新薄荷",
"peachGlow": "蜜桃光暈",
"lavenderDream": "薰衣草夢",
"skyBlue": "天空蔚藍",
"roseWater": "玫瑰花水",
"softGrass": "柔和青草",
"winterNymph": "冬日仙子",
"cottonCandy": "棉花糖",
"sunnyMorning": "陽光早晨",
"crystalClear": "水晶透明"
}
}
}
@@ -0,0 +1,85 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { ElEmpty, ElMessage, ElScrollbar } from 'element-plus';
import { getPageByCodeApi } from '#/api/online-dev/page-manager';
import DashboardRenderer from '#/components/dashboard-design/DashboardRenderer.vue';
defineOptions({ name: 'PageRender' });
const route = useRoute();
const loading = ref(false);
const pageConfig = ref<string>('');
const pageName = ref('');
const pageCode = ref('');
// 获取页面编码
function getPageCode(): string {
// 优先从 query 获取
if (route.query.pageCode) {
return route.query.pageCode as string;
}
// 其次从 params 获取
if (route.params.code) {
return route.params.code as string;
}
// 最后从路径中提取(路径格式:/page-render/xxx
const pathParts = route.path.split('/').filter(Boolean);
const length = pathParts.length;
if (length >= 2 && pathParts[length - 2] === 'page-render') {
return pathParts[length - 1] || '';
}
return '';
}
// 加载页面数据
async function loadPageData() {
const code = getPageCode();
if (!code) {
ElMessage.error('页面编码不能为空');
return;
}
pageCode.value = code;
loading.value = true;
try {
const page = await getPageByCodeApi(code);
pageName.value = page.name;
pageConfig.value =
page.page_config && Object.keys(page.page_config).length > 0
? JSON.stringify(page.page_config)
: '';
} catch (error: any) {
ElMessage.error(error?.message || '加载页面失败');
} finally {
loading.value = false;
}
}
onMounted(() => {
loadPageData();
});
// 监听路由变化
watch(
() => route.fullPath,
() => {
loadPageData();
},
);
</script>
<template>
<ElScrollbar class="rounded-[8px] py-3">
<div v-loading="loading" class="h-[calc(100vh-120px)] rounded-[8px] px-3">
<DashboardRenderer v-if="pageConfig" :config="pageConfig" />
<ElEmpty v-else-if="!loading" description="暂无页面配置" />
</div>
</ElScrollbar>
</template>