feat: lighten ai agent admin frontend

This commit is contained in:
2026-06-11 18:03:43 +08:00
parent ecf29e2278
commit 15229cc5ab
316 changed files with 898 additions and 60944 deletions
@@ -1,143 +0,0 @@
<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 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 [];
const columns = dashboardConfig.value.columns || 12;
return dashboardConfig.value.widgets.map((w) => ({
...w,
columnEnd: `span ${Math.min(w.w, columns)}`,
columnStart: Math.max(1, w.x + 1),
i: w.i,
minHeight: `${w.h * dashboardConfig.value!.rowHeight}px`,
rowEnd: `span ${w.h}`,
rowStart: Math.max(1, w.y + 1),
}));
});
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)`,
};
});
const gridStyle = computed(() => {
const config = dashboardConfig.value;
if (!config) return {};
const margin = config.margin || [12, 12];
return {
...outerMarginStyle.value,
display: 'grid',
gap: `${margin[1]}px ${margin[0]}px`,
gridAutoFlow: 'dense',
gridAutoRows: `${config.rowHeight}px`,
gridTemplateColumns: `repeat(${config.columns || 12}, minmax(0, 1fr))`,
};
});
</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">
<div :style="gridStyle">
<div
v-for="item in layout"
:key="item.i"
class="dashboard-widget"
:style="{
gridColumnEnd: item.columnEnd,
gridColumnStart: item.columnStart,
gridRowEnd: item.rowEnd,
gridRowStart: item.rowStart,
minHeight: item.minHeight,
}"
>
<WidgetRenderer
v-if="getWidget(item.i)"
:widget="getWidget(item.i)!"
:is-design-mode="false"
:animation-delay="getAnimationDelay(item.i)"
/>
</div>
</div>
</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>
@@ -1,319 +0,0 @@
<script setup lang="ts">
import type {
DashboardWidget,
WidgetStyle,
} from '../types';
import {
computed,
defineAsyncComponent,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { Loader2 } from '@vben/icons';
import { $t } from '@vben/locales';
import { useDashboardRuntime } from '../runtime';
import { createRefreshTimer, fetchWidgetData } from '../utils/dataFetcher';
const props = defineProps<{
animationDelay?: number; // 入场动画延迟(毫秒)
isDesignMode?: boolean;
widget: DashboardWidget;
}>();
// 入场动画状态
const isEntered = ref(false);
// 数据更新动画状态
const isUpdating = ref(false);
// 组件映射
const widgetComponents: Record<
string,
ReturnType<typeof defineAsyncComponent>
> = {
'announcement-list': defineAsyncComponent(
() => import('./widgets/AnnouncementList.vue'),
),
'notice-list': defineAsyncComponent(() => import('./widgets/NoticeList.vue')),
'quick-links': defineAsyncComponent(() => import('./widgets/QuickLinks.vue')),
'server-monitor': defineAsyncComponent(
() => import('./widgets/ServerMonitor.vue'),
),
weather: defineAsyncComponent(() => import('./widgets/WeatherWidget.vue')),
'welcome-card': defineAsyncComponent(
() => import('./widgets/WelcomeCard.vue'),
),
};
const defaultWidgetStyle: WidgetStyle = {
backgroundColor: '',
borderWidth: 0,
borderColor: '',
borderStyle: 'solid',
borderRadius: 8,
shadowEnabled: true,
shadowColor: 'rgba(0, 0, 0, 0.1)',
shadowBlur: 4,
shadowOffsetX: 0,
shadowOffsetY: 1,
padding: 16,
titleShow: true,
titleFontSize: 14,
titleColor: '',
titleAlign: 'left',
titleFontWeight: 'normal',
};
const { globalParams, globalParamsVersion } = useDashboardRuntime();
const currentComponent = computed(() => {
return widgetComponents[props.widget.type];
});
// 从 paramBindings 解析出实际参数值
function resolveParams(): Record<string, any> | undefined {
const bindings = props.widget.dataSource?.paramBindings;
if (!bindings || bindings.length === 0) return undefined;
const params: Record<string, any> = {};
for (const b of bindings) {
if (b.paramName && b.globalKey) {
const val = globalParams.value[b.globalKey];
if (val !== undefined && val !== '') {
params[b.paramName] = val;
}
}
}
return Object.keys(params).length > 0 ? params : undefined;
}
// 动态数据
const dynamicProps = ref<Record<string, any>>({});
const isLoading = ref(false);
const hasError = ref(false);
// 合并后的 widget(静态 props + 动态 props
const mergedWidget = computed(() => {
return {
...props.widget,
props: {
...props.widget.props,
...dynamicProps.value,
},
};
});
// 计算样式(只应用容器级别的样式,不影响组件内部)
const widgetStyle = computed(() => {
const style = { ...defaultWidgetStyle, ...props.widget.style };
const css: Record<string, string> = {};
// 背景(默认使用主题背景色,支持渐变色)
const bgValue = style.backgroundColor || 'var(--el-bg-color)';
if (bgValue.includes('gradient')) {
css.background = bgValue;
} else {
css.backgroundColor = bgValue;
}
// 边框
if (style.borderWidth && style.borderWidth > 0) {
css.borderWidth = `${style.borderWidth}px`;
css.borderStyle = style.borderStyle || 'solid';
css.borderColor = style.borderColor || 'var(--el-border-color)';
}
// 圆角
if (style.borderRadius !== undefined) {
css.borderRadius = `${style.borderRadius}px`;
}
// 阴影
if (style.shadowEnabled) {
const color = style.shadowColor || 'rgba(0, 0, 0, 0.1)';
const blur = style.shadowBlur || 4;
const x = style.shadowOffsetX || 0;
const y = style.shadowOffsetY || 1;
css.boxShadow = `${x}px ${y}px ${blur}px ${color}`;
} else {
css.boxShadow = 'none';
}
return css;
});
// 加载数据
const loadData = async () => {
if (!props.widget.dataSource || props.widget.dataSource.type === 'static') {
dynamicProps.value = {};
return;
}
isLoading.value = true;
hasError.value = false;
try {
const params = resolveParams();
const result = await fetchWidgetData(
props.widget.dataSource,
props.widget.props,
params,
);
// 数据更新动画
if (isEntered.value && Object.keys(dynamicProps.value).length > 0) {
isUpdating.value = true;
setTimeout(() => {
isUpdating.value = false;
}, 300);
}
dynamicProps.value = result.props;
} catch (error) {
console.error('[WidgetRenderer] loadData error', error);
hasError.value = true;
} finally {
isLoading.value = false;
}
};
// 刷新定时器清理函数
let cleanupTimer: (() => void) | null = null;
// 设置刷新定时器
const setupRefreshTimer = () => {
if (cleanupTimer) {
cleanupTimer();
cleanupTimer = null;
}
// 支持 api 和 dataSource 类型的自动刷新
const dsType = props.widget.dataSource?.type;
if (!props.isDesignMode && (dsType === 'api' || dsType === 'dataSource')) {
cleanupTimer = createRefreshTimer(props.widget.dataSource, loadData);
}
};
// 监听数据源变化
watch(
() => props.widget.dataSource,
() => {
loadData();
setupRefreshTimer();
},
{ deep: true },
);
// 监听全局筛选参数变化,有 paramBindings 的组件重新加载数据
watch(
() => globalParamsVersion.value,
() => {
if (props.isDesignMode) return;
const bindings = props.widget.dataSource?.paramBindings;
if (bindings && bindings.length > 0) {
loadData();
}
},
);
onMounted(() => {
loadData();
setupRefreshTimer();
// 入场动画
const delay = props.animationDelay || 0;
setTimeout(() => {
isEntered.value = true;
}, delay);
});
onUnmounted(() => {
if (cleanupTimer) {
cleanupTimer();
}
});
// 动画类名
const animationClass = computed(() => {
return {
'widget-enter': true,
'widget-entered': isEntered.value,
'widget-updating': isUpdating.value,
};
});
</script>
<template>
<div
class="widget-renderer relative h-full w-full overflow-hidden"
:class="animationClass"
:style="widgetStyle"
>
<!-- 加载状态 -->
<div
v-if="isLoading"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/50"
>
<Loader2 class="text-primary h-6 w-6 animate-spin" />
</div>
<!-- 错误状态 -->
<div v-if="hasError && !isLoading" class="absolute right-2 top-2 z-10">
<span class="text-xs text-red-500">{{
$t('dashboard-design.loadDataError')
}}</span>
</div>
<!-- 组件内容 -->
<component
:is="currentComponent"
v-if="currentComponent"
:widget="mergedWidget"
:is-design-mode="isDesignMode"
/>
<div
v-else
class="flex h-full w-full items-center justify-center text-gray-400"
>
{{ $t('dashboard-design.unknownWidget') }}: {{ widget.type }}
</div>
</div>
</template>
<style scoped>
@keyframes pulse-update {
0% {
transform: scale(1);
}
50% {
box-shadow: 0 0 0 2px var(--el-color-primary-light-5);
transform: scale(1.02);
}
100% {
transform: scale(1);
}
}
.widget-enter {
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.widget-entered {
opacity: 1;
transform: translateY(0) scale(1);
}
/* 数据更新动画 */
.widget-updating {
animation: pulse-update 0.3s ease-in-out;
}
/* 入场动画 */
</style>
@@ -1,298 +0,0 @@
<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>
@@ -1,118 +0,0 @@
<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>
@@ -1,71 +0,0 @@
<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>
@@ -1,125 +0,0 @@
<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>
@@ -1,155 +0,0 @@
<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>
@@ -1,285 +0,0 @@
<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>
@@ -1,127 +0,0 @@
<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>
@@ -1,159 +0,0 @@
<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>
@@ -1,139 +0,0 @@
<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>
@@ -1,180 +0,0 @@
<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>
@@ -1,202 +0,0 @@
<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>
@@ -1,252 +0,0 @@
<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>
@@ -1,117 +0,0 @@
<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>
@@ -1,130 +0,0 @@
<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;
}>();
type AppLink = Pick<ApplicationListItem, 'code' | 'icon' | 'id' | 'name'> & {
path?: string;
};
// 应用列表
const appList = ref<AppLink[]>([]);
const loading = ref(false);
const configuredApps = computed<AppLink[]>(() => props.widget.props.apps || []);
// 最大显示数量
const maxCount = computed(() => props.widget.props.maxCount || 8);
// 显示的应用列表
const displayApps = computed(() =>
(configuredApps.value.length > 0 ? configuredApps.value : appList.value).slice(
0,
maxCount.value,
),
);
// 加载已发布的应用列表
const loadApps = async () => {
if (configuredApps.value.length > 0) {
appList.value = configuredApps.value;
return;
}
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: AppLink) => {
const path = app.path || `/app/${app.code}`;
if (path.startsWith('http://') || path.startsWith('https://')) {
window.open(path, '_blank');
return;
}
window.open(`${window.location.origin}${path}`, '_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>
@@ -1,269 +0,0 @@
<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>
@@ -1,28 +0,0 @@
<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>
@@ -1,120 +0,0 @@
<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>
@@ -1,71 +0,0 @@
<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>
@@ -1,294 +0,0 @@
<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>
@@ -1,91 +0,0 @@
<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>
@@ -1,81 +0,0 @@
<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>
@@ -1,277 +0,0 @@
<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>
@@ -1,364 +0,0 @@
<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>
@@ -1,115 +0,0 @@
<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>
@@ -1,10 +0,0 @@
export { default as DashboardRenderer } from './DashboardRenderer.vue';
export type {
DashboardConfig,
DashboardWidget,
DataSourceConfig,
DataSourceType,
WidgetStyle,
WidgetType,
} from './types';
export { createRefreshTimer, fetchWidgetData } from './utils/dataFetcher';
@@ -1,23 +0,0 @@
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,
};
}
@@ -1,115 +0,0 @@
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[];
}
@@ -1,200 +0,0 @@
import type {
DataSourceConfig,
FieldMapping,
} from '../types';
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);
}
@@ -1,109 +0,0 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElInput } from 'element-plus';
export interface AppDesignData {
type: 'app_design';
title: string;
data: {
content: string;
title: string;
};
nodeId?: string;
}
const props = defineProps<{
design?: AppDesignData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
const isFullscreen = ref(false);
const editContent = ref('');
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const panelTitle = computed(
() => props.design?.data?.title || props.design?.title || '应用设计方案',
);
watch(
() => props.design,
(design) => {
editContent.value = design?.data?.content || '';
},
{ immediate: true },
);
function handleConfirm() {
emit('confirm', {
content: editContent.value,
title: panelTitle.value,
});
}
function handleClose() {
dialogVisible.value = false;
emit('close');
}
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<div
class="border-border bg-muted/50 flex shrink-0 items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">{{ panelTitle }}</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="isFullscreen = !isFullscreen"
/>
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col gap-4 overflow-hidden p-4">
<div class="rounded-md border bg-muted/30 p-3 text-sm text-muted-foreground">
AI 已生成应用设计方案确认前可以直接调整正文确认后将继续后续编排节点
</div>
<ElInput
v-model="editContent"
class="min-h-0 flex-1"
resize="none"
type="textarea"
placeholder="请输入应用设计方案"
/>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
}
</style>
@@ -1,536 +0,0 @@
<script lang="ts" setup>
/**
* 应用设置面板
* 用于配置应用设置,包括应用配置、Logo配置、内置主题和布局
*/
import type { BuiltinThemeType, LayoutType, ThemeModeType } from '@vben/types';
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import {
Block,
BuiltinTheme,
Layout,
Theme,
} from '@vben/layouts/preferences-blocks';
import { $t } from '@vben/locales';
import {
ElButton,
ElDivider,
ElInput,
ElMessage,
ElOption,
ElScrollbar,
ElSelect,
ElSwitch,
} from 'element-plus';
import { uploadFile } from '#/api/core/file';
import { getFileUrlPublic } from '#/composables/useFileUrl';
// 应用设置数据接口
export interface AppSettingsData {
type: 'app_settings';
title: string;
data: {
app?: {
defaultHomePath?: string;
dynamicTitle?: boolean;
enableCheckUpdates?: boolean;
enablePreferences?: boolean;
layout?: string;
locale?: string;
name?: string;
watermark?: boolean;
watermarkContent?: string;
};
breadcrumb?: Record<string, any>;
copyright?: Record<string, any>;
footer?: Record<string, any>;
header?: Record<string, any>;
logo?: {
enable?: boolean;
fit?: string;
source?: string;
};
navigation?: Record<string, any>;
shortcutKeys?: Record<string, any>;
sidebar?: Record<string, any>;
tabbar?: Record<string, any>;
theme?: {
builtinType?: string;
colorPrimary?: string;
mode?: string;
radius?: string;
semiDarkHeader?: boolean;
semiDarkSidebar?: boolean;
};
transition?: Record<string, any>;
widget?: Record<string, any>;
};
nodeId?: string;
layoutOptions?: Array<{ label: string; value: string }>;
themeOptions?: Array<{ label: string; value: string }>;
}
interface Props {
visible: boolean;
settings?: AppSettingsData;
}
const props = defineProps<Props>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
// 应用配置
const appName = ref('');
const appDefaultHomePath = ref('/analytics');
const appEnablePreferences = ref(true);
const appLayout = ref<LayoutType>('sidebar-nav');
// Logo配置
const logoEnable = ref(true);
const logoSource = ref('');
const logoFit = ref('contain');
const logoDisplayUrl = ref('');
// 主题配置
const themeMode = ref<ThemeModeType>('light');
const themeBuiltinType = ref<BuiltinThemeType>('default');
const themeColorPrimary = ref('hsl(212 100% 45%)');
const themeSemiDarkSidebar = ref(false);
const themeSemiDarkHeader = ref(false);
// 适应方式选项
const fitOptions = computed(() => [
{ label: $t('ai-platform.appSettings.fitOptions.contain'), value: 'contain' },
{ label: $t('ai-platform.appSettings.fitOptions.cover'), value: 'cover' },
{ label: $t('ai-platform.appSettings.fitOptions.fill'), value: 'fill' },
{ label: $t('ai-platform.appSettings.fitOptions.none'), value: 'none' },
{
label: $t('ai-platform.appSettings.fitOptions.scale-down'),
value: 'scale-down',
},
]);
// 面板标题
const panelTitle = computed(() => {
return props.settings?.title || $t('ai-platform.appSettings.title');
});
// 监听设置数据变化
watch(
() => props.settings,
(newSettings) => {
if (newSettings?.data) {
const data = newSettings.data;
// 应用配置
appName.value = data.app?.name || '';
appDefaultHomePath.value = data.app?.defaultHomePath || '/analytics';
appEnablePreferences.value = data.app?.enablePreferences ?? true;
appLayout.value = (data.app?.layout || 'sidebar-nav') as LayoutType;
// Logo配置
logoEnable.value = data.logo?.enable ?? true;
logoSource.value = data.logo?.source || '';
logoFit.value = data.logo?.fit || 'contain';
loadLogoDisplayUrl(logoSource.value);
// 主题配置
themeMode.value = (data.theme?.mode || 'light') as ThemeModeType;
themeBuiltinType.value = (data.theme?.builtinType ||
'default') as BuiltinThemeType;
themeColorPrimary.value = data.theme?.colorPrimary || 'hsl(212 100% 45%)';
themeSemiDarkSidebar.value = data.theme?.semiDarkSidebar ?? false;
themeSemiDarkHeader.value = data.theme?.semiDarkHeader ?? false;
}
},
{ immediate: true },
);
// 监听logoSource变化,加载显示URL
watch(logoSource, (newSource) => {
loadLogoDisplayUrl(newSource);
});
// 判断是否为文件ID
function isFileId(value: string): boolean {
return (
/^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i.test(value) ||
/^\d+$/.test(value) ||
/^[\w-]{10,30}$/.test(value)
);
}
// 加载Logo显示URL
function loadLogoDisplayUrl(source: string) {
if (!source) {
logoDisplayUrl.value = '';
return;
}
if (
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('/') ||
source.startsWith('data:')
) {
logoDisplayUrl.value = source;
return;
}
if (isFileId(source)) {
logoDisplayUrl.value = getFileUrlPublic(source);
return;
}
logoDisplayUrl.value = source;
}
// 文件上传
const uploadInputRef = ref<HTMLInputElement>();
function openFileSelector() {
uploadInputRef.value?.click();
}
async function handleFileInputChange(event: Event) {
const target = event.target as HTMLInputElement;
const file = target.files?.[0];
if (!file) return;
try {
const response = await uploadFile(file, {
isPublic: true,
source: 'avatar',
});
if (response?.id) {
logoSource.value = getFileUrlPublic(String(response.id));
}
} catch {
ElMessage.error($t('ui-config.loadError'));
}
target.value = '';
}
function clearLogo() {
logoSource.value = '';
}
// 构建设置数据
function buildSettingsData(): Record<string, any> {
return {
app: {
name: appName.value,
defaultHomePath: appDefaultHomePath.value,
enablePreferences: appEnablePreferences.value,
layout: appLayout.value,
},
logo: {
enable: logoEnable.value,
source: logoSource.value,
fit: logoFit.value,
},
theme: {
mode: themeMode.value,
builtinType: themeBuiltinType.value,
colorPrimary: themeColorPrimary.value,
semiDarkSidebar: themeSemiDarkSidebar.value,
semiDarkHeader: themeSemiDarkHeader.value,
},
// 保留原有的其他配置
sidebar: props.settings?.data?.sidebar,
header: props.settings?.data?.header,
footer: props.settings?.data?.footer,
copyright: props.settings?.data?.copyright,
navigation: props.settings?.data?.navigation,
tabbar: props.settings?.data?.tabbar,
breadcrumb: props.settings?.data?.breadcrumb,
transition: props.settings?.data?.transition,
widget: props.settings?.data?.widget,
shortcutKeys: props.settings?.data?.shortcutKeys,
};
}
// 确认并继续
function handleConfirm() {
emit('confirm', buildSettingsData());
}
// 关闭
function handleClose() {
emit('update:visible', false);
emit('close');
}
</script>
<template>
<div
v-if="visible"
class="app-settings-panel border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ panelTitle }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容区域 -->
<ElScrollbar class="flex-1">
<div class="grid grid-cols-2 gap-6 p-6">
<!-- 左列应用配置 + Logo配置 -->
<div class="space-y-6">
<!-- 应用配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.appConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.appName')">
<ElInput
v-model="appName"
:placeholder="
$t('ai-platform.appSettings.appNamePlaceholder')
"
clearable
/>
</Block>
<Block :title="$t('ai-platform.appSettings.defaultHomePath')">
<ElInput
v-model="appDefaultHomePath"
:placeholder="
$t('ai-platform.appSettings.defaultHomePathPlaceholder')
"
clearable
/>
</Block>
<Block :title="$t('ai-platform.appSettings.enablePreferences')">
<ElSwitch v-model="appEnablePreferences" />
</Block>
</div>
</div>
<ElDivider />
<!-- Logo配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.logoConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.logoEnable')">
<ElSwitch v-model="logoEnable" />
</Block>
<template v-if="logoEnable">
<Block :title="$t('ai-platform.appSettings.logoSource')">
<div class="flex flex-col gap-4">
<!-- Logo 预览和上传 -->
<div class="logo-preview-container flex">
<div class="flex items-center gap-4">
<div>
<div
v-if="logoDisplayUrl"
class="logo-preview"
@click="openFileSelector"
>
<img
:src="logoDisplayUrl"
alt="Logo"
class="logo-image"
/>
<div class="logo-overlay">
<span class="text-sm text-white">{{
$t('common.replace')
}}</span>
</div>
</div>
<div
v-else
class="logo-placeholder"
@click="openFileSelector"
>
<span class="text-muted-foreground text-sm">{{
$t(
'ai-platform.appSettings.logoSourcePlaceholder',
)
}}</span>
</div>
</div>
<ElButton
v-if="logoDisplayUrl"
size="small"
@click="clearLogo"
>
{{ $t('common.clear') }}
</ElButton>
</div>
</div>
<input
ref="uploadInputRef"
type="file"
accept="image/*"
style="display: none"
@change="handleFileInputChange"
/>
<ElInput
v-model="logoSource"
:placeholder="
$t('ai-platform.appSettings.logoSourcePlaceholder')
"
clearable
/>
</div>
</Block>
<Block :title="$t('ai-platform.appSettings.logoFit')">
<ElSelect v-model="logoFit" class="w-full">
<ElOption
v-for="item in fitOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</Block>
</template>
</div>
</div>
</div>
<!-- 右列布局配置 + 主题配置 -->
<div class="space-y-6">
<!-- 布局配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.layoutConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.layout')">
<Layout v-model="appLayout" />
</Block>
</div>
</div>
<ElDivider />
<!-- 主题配置 -->
<div class="config-section">
<h4 class="mb-4 text-sm font-semibold">
{{ $t('ai-platform.appSettings.themeConfig') }}
</h4>
<div class="space-y-4">
<Block :title="$t('ai-platform.appSettings.themeMode')">
<Theme
v-model="themeMode"
v-model:theme-semi-dark-sidebar="themeSemiDarkSidebar"
v-model:theme-semi-dark-header="themeSemiDarkHeader"
/>
</Block>
<Block :title="$t('ai-platform.appSettings.builtinTheme')">
<BuiltinTheme
v-model="themeBuiltinType"
v-model:theme-color-primary="themeColorPrimary"
:is-dark="themeMode === 'dark'"
/>
</Block>
</div>
</div>
</div>
</div>
</ElScrollbar>
</div>
</template>
<style scoped>
.app-settings-panel {
min-width: 600px;
}
.logo-preview-container {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.logo-preview {
position: relative;
width: 100px;
height: 100px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
overflow: hidden;
cursor: pointer;
}
.logo-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.logo-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: rgb(0 0 0 / 50%);
opacity: 0;
transition: opacity 0.2s;
}
.logo-preview:hover .logo-overlay {
opacity: 1;
}
.logo-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
border: 2px dashed hsl(var(--border));
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
}
.logo-placeholder:hover {
border-color: hsl(var(--primary));
}
</style>
@@ -1,117 +0,0 @@
<script lang="ts" setup>
/**
* 表单基础信息编辑组件
* 可用于:表单管理器、工作流设计预览编辑
*/
import { computed } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElOption,
ElSelect,
} from 'element-plus';
export interface BasicFormData {
name: string;
code: string;
form_type: 'normal' | 'workflow';
sort: number;
description: string;
}
interface Props {
modelValue: BasicFormData;
disabled?: boolean;
showTitle?: boolean;
labelWidth?: string;
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
showTitle: true,
labelWidth: '100px',
});
const emit = defineEmits<{
'update:modelValue': [value: BasicFormData];
}>();
const formData = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
// 更新单个字段
function updateField<K extends keyof BasicFormData>(key: K, value: BasicFormData[K]) {
emit('update:modelValue', { ...props.modelValue, [key]: value });
}
</script>
<template>
<div class="basic-info-editor">
<h3 v-if="showTitle" class="mb-6 text-center text-lg font-medium">
{{ $t('form-manager.editor.steps.basic') }}
</h3>
<ElForm
:model="formData"
:label-width="labelWidth"
label-position="right"
:disabled="disabled"
>
<ElFormItem :label="$t('form-manager.name')" required>
<ElInput
:model-value="formData.name"
:placeholder="$t('form-manager.placeholder.name')"
clearable
@update:model-value="updateField('name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.code')" required>
<ElInput
:model-value="formData.code"
:placeholder="$t('form-manager.placeholder.code')"
clearable
@update:model-value="updateField('code', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.type')" required>
<ElSelect
:model-value="formData.form_type"
:placeholder="$t('form-manager.placeholder.type')"
class="w-full"
@update:model-value="updateField('form_type', $event)"
>
<ElOption :label="$t('form-manager.typeMap.normal')" value="normal" />
<ElOption :label="$t('form-manager.typeMap.workflow')" value="workflow" />
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInput
:model-value="formData.sort"
type="number"
placeholder="0"
@update:model-value="updateField('sort', Number($event))"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.description')">
<ElInput
:model-value="formData.description"
type="textarea"
:rows="4"
:placeholder="$t('form-manager.editor.placeholder.remark')"
@update:model-value="updateField('description', $event)"
/>
</ElFormItem>
</ElForm>
</div>
</template>
<style scoped>
.basic-info-editor {
width: 100%;
}
</style>
@@ -1,122 +0,0 @@
<script setup lang="ts">
/**
* 仪表盘基础信息确认面板
* 复用 PageBasicInfoEditor 公共组件
*/
import type { PageBasicInfo } from './PageBasicInfoEditor.vue';
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import PageBasicInfoEditor from './PageBasicInfoEditor.vue';
export interface DashboardBasicInfoData {
type: string;
title: string;
data: {
category: string;
code: string;
description: string;
name: string;
sort: number;
};
nodeId: string;
}
const props = defineProps<{
basicInfo?: DashboardBasicInfoData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const form = ref<PageBasicInfo>({
name: '',
code: '',
category: 'dashboard',
description: '',
sort: 0,
});
watch(
() => props.basicInfo,
(info) => {
if (info?.data) {
form.value = {
name: info.data.name || '',
code: info.data.code || '',
category: info.data.category || 'dashboard',
description: info.data.description || '',
sort: info.data.sort || 0,
};
}
},
{ immediate: true },
);
const handleConfirm = () => {
emit('confirm', { ...form.value });
};
const handleClose = () => {
dialogVisible.value = false;
emit('close');
};
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ basicInfo?.title || $t('ai-platform.dashboard.basicInfo.title') }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容 -->
<div class="flex-1 overflow-y-auto p-6">
<PageBasicInfoEditor v-model="form" />
</div>
</div>
</template>
@@ -1,137 +0,0 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElMessage } from 'element-plus';
export interface DashboardDesignData {
type: string;
title: string;
data: {
dashboard_code?: string;
design_suggestion?: string;
design_title?: string;
page_config?: Record<string, any>;
};
nodeId: string;
}
const props = defineProps<{
design?: DashboardDesignData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
const isFullscreen = ref(false);
const configText = ref('{}');
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
watch(
() => props.design,
(design) => {
configText.value = JSON.stringify(design?.data?.page_config || {}, null, 2);
},
{ immediate: true },
);
function handleConfirm() {
try {
emit('confirm', {
design_title:
props.design?.data?.design_title ||
$t('ai-platform.dashboard.design.title'),
page_config: JSON.parse(configText.value || '{}'),
});
} catch {
ElMessage.error('页面配置不是有效 JSON');
}
}
function handleClose() {
dialogVisible.value = false;
emit('close');
}
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<div
class="border-border bg-muted/50 flex shrink-0 items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground flex items-center gap-3 font-medium">
<div class="bg-primary flex h-8 w-8 items-center justify-center rounded">
<span class="text-sm font-bold text-white">D</span>
</div>
<span>{{ design?.title || $t('ai-platform.dashboard.design.title') }}</span>
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<div class="bg-border mx-1 h-5 w-px"></div>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="isFullscreen = !isFullscreen"
/>
</div>
</div>
<div class="grid min-h-0 flex-1 grid-cols-[320px_minmax(0,1fr)] gap-4 overflow-hidden p-4">
<div class="space-y-4 overflow-auto rounded-md border bg-muted/20 p-4">
<div>
<div class="text-sm font-medium">仪表盘编码</div>
<div class="mt-1 text-sm text-muted-foreground">
{{ design?.data?.dashboard_code || '-' }}
</div>
</div>
<div>
<div class="text-sm font-medium">设计标题</div>
<div class="mt-1 text-sm text-muted-foreground">
{{ design?.data?.design_title || '-' }}
</div>
</div>
<div>
<div class="text-sm font-medium">设计建议</div>
<div class="mt-1 whitespace-pre-wrap text-sm text-muted-foreground">
{{ design?.data?.design_suggestion || '暂无设计建议' }}
</div>
</div>
</div>
<ElInput
v-model="configText"
class="min-h-0"
resize="none"
type="textarea"
placeholder="页面配置 JSON"
/>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>
@@ -1,137 +0,0 @@
<script setup lang="ts">
/**
* 仪表盘发布确认面板
* 复用 PagePublishInfoEditor 公共组件
*/
import type { PagePublishInfo } from './PagePublishInfoEditor.vue';
import { computed, ref, watch } from 'vue';
import { Maximize, Minimize } from '@vben/icons';
import { $t } from '@vben/locales';
import { ElButton } from 'element-plus';
import PagePublishInfoEditor from './PagePublishInfoEditor.vue';
export interface DashboardPublishData {
type: string;
title: string;
data: {
application_id?: string;
dashboard_code: string;
dashboard_id: string;
menu_icon: string;
menu_name: string;
menu_order: number;
menu_parent_id?: string;
};
nodeId: string;
}
const props = defineProps<{
publishData?: DashboardPublishData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: Record<string, any>];
'update:visible': [value: boolean];
}>();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const form = ref<PagePublishInfo>({
menu_name: '',
menu_parent_id: undefined,
menu_icon: 'lucide:layout-dashboard',
menu_order: 0,
});
watch(
() => props.publishData,
(data) => {
if (data?.data) {
form.value = {
menu_name: data.data.menu_name || '',
menu_parent_id: data.data.menu_parent_id || undefined,
menu_icon: data.data.menu_icon || 'lucide:layout-dashboard',
menu_order: data.data.menu_order || 0,
};
}
},
{ immediate: true },
);
const handleConfirm = () => {
const confirmData = {
dashboard_id: props.publishData?.data.dashboard_id,
dashboard_code: props.publishData?.data.dashboard_code,
menu_name: form.value.menu_name,
menu_parent_id: form.value.menu_parent_id || '',
menu_icon: form.value.menu_icon,
menu_order: form.value.menu_order,
application_id: props.publishData?.data.application_id || '',
};
console.log('[DashboardPublishConfirmPanel] 确认数据:', confirmData);
emit('confirm', confirmData);
};
const handleClose = () => {
dialogVisible.value = false;
emit('close');
};
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ publishData?.title || $t('ai-platform.dashboard.publish.title') }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ $t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
{{ $t('common.confirmAndContinue') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容 -->
<div class="flex-1 overflow-y-auto p-6">
<PagePublishInfoEditor
v-model="form"
:page-code="publishData?.data.dashboard_code || ''"
:show-route-info="true"
route-prefix="/page-render/"
/>
</div>
</div>
</template>
@@ -1,288 +0,0 @@
<script lang="ts" setup>
/**
* 设计编辑面板
* 用于工作流中每步设计完成后的编辑
* 使用 FormEditorContent 公共组件,根据当前步骤显示对应内容
*/
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { Maximize, Minimize } from '@vben/icons';
import { ElButton } from 'element-plus';
import FormEditorContent from './FormEditorContent.vue';
// 设计类型
export type DesignType = 'form_basic_info' | 'database_design' | 'form_ui_design' | 'list_config' | 'form_publish' | 'dashboard_basic_info' | 'dashboard_design' | 'dashboard_publish';
// 设计数据
export interface DesignData {
type: DesignType;
title: string;
data: Record<string, any>;
nodeId: string;
form_fields?: any[]; // 表单字段列表(用于列表设计)
table_configs?: any[]; // 数据表配置(用于表单设计)
}
interface Props {
visible: boolean;
design?: DesignData;
}
const props = defineProps<Props>();
const emit = defineEmits<{
'update:visible': [value: boolean];
'save': [data: Record<string, any>];
'confirm': [data: Record<string, any>];
'close': [];
}>();
// 编辑器组件引用
const editorRef = ref<InstanceType<typeof FormEditorContent>>();
// 根据设计类型映射到步骤索引
const stepMap: Record<DesignType, number> = {
'form_basic_info': 0,
'database_design': 1,
'form_ui_design': 2,
'list_config': 3,
'form_publish': 4,
'dashboard_basic_info': 0,
'dashboard_design': 0,
'dashboard_publish': 0,
};
// 当前步骤
const currentStep = ref(0);
// 面板标题
const panelTitle = computed(() => {
if (!props.design) return '';
return props.design.title;
});
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
// 监听设计数据变化,设置当前步骤和初始化数据
watch(
() => props.design,
(newDesign) => {
if (!newDesign) return;
// 根据设计类型设置当前步骤
currentStep.value = stepMap[newDesign.type] ?? 0;
// 初始化编辑器数据
if (editorRef.value) {
initEditorData(newDesign);
}
},
{ immediate: true }
);
// 监听编辑器引用变化,初始化数据
watch(
() => editorRef.value,
(editor) => {
if (editor && props.design) {
initEditorData(props.design);
}
}
);
// 初始化编辑器数据
function initEditorData(design: DesignData) {
if (!editorRef.value) return;
switch (design.type) {
case 'form_basic_info': {
const data = design.data || {};
editorRef.value.setData({
basicForm: {
name: data.name || '',
code: data.code || '',
form_type: data.form_type || 'normal',
sort: data.sort || 0,
description: data.description || '',
},
});
break;
}
case 'database_design': {
const tableData = design.data?.table || design.data || {};
// 优先从 meta 中读取 schema,兼容旧数据从顶层读取
const metaData = tableData.meta || {};
editorRef.value.setData({
tableConfigs: [{
id: 'main-table',
type: 'main',
tableName: tableData.tableName || '',
alias: tableData.alias || tableData.tableName || '',
fields: tableData.fields || [],
meta: {
schema: metaData.schema || tableData.schema || '',
schemaRaw: metaData.schemaRaw || tableData.schemaRaw || '',
database: metaData.database || tableData.database || '',
},
}],
});
break;
}
case 'form_ui_design': {
const formConfig = design.data || {};
// 从 design 中获取 table_configs(后端在 waiting_config 中传递)
const tableConfigs = design.table_configs || design.data?.table_configs || [];
editorRef.value.setData({
tableConfigs: tableConfigs,
formConfig: {
items: formConfig.items || [],
labelWidth: formConfig.labelWidth || 120,
labelPosition: formConfig.labelPosition || 'right',
size: formConfig.size || 'default',
formPadding: formConfig.formPadding || 20,
formMargin: formConfig.formMargin || 0,
itemSpacing: formConfig.itemSpacing || 18,
formWidth: formConfig.formWidth || '100%',
formMaxWidth: formConfig.formMaxWidth || 0,
formBackground: formConfig.formBackground || false,
formBorder: formConfig.formBorder || false,
formBorderRadius: formConfig.formBorderRadius || 4,
formShadow: formConfig.formShadow || false,
disabled: formConfig.disabled || false,
tableConfigs: tableConfigs,
},
});
break;
}
case 'list_config': {
// 从 design 中获取 form_fields(后端在 waiting_config 中传递)
const formFields = design.form_fields || design.data?.form_fields || [];
editorRef.value.setData({
listConfig: design.data || {},
formFields: formFields,
});
break;
}
case 'form_publish': {
const publishData = design.data || {};
editorRef.value.setData({
publishData: {
menu_name: publishData.menu_name || '',
menu_parent_id: publishData.menu_parent_id,
menu_icon: publishData.menu_icon || 'lucide:file-text',
menu_order: publishData.menu_order ?? 1,
},
});
break;
}
}
}
// 获取当前编辑的数据
function getCurrentData(): Record<string, any> {
if (!editorRef.value) return {};
const data = editorRef.value.getData();
const designType = props.design?.type;
switch (designType) {
case 'form_basic_info':
return { ...data.basicForm };
case 'database_design':
return {
type: data.tableConfigs[0]?.type || 'main', // 添加 type 字段,默认为 main
table: data.tableConfigs[0] ? {
tableName: data.tableConfigs[0].tableName,
alias: data.tableConfigs[0].alias,
fields: data.tableConfigs[0].fields,
meta: {
schema: data.tableConfigs[0].meta?.schema || '',
schemaRaw: data.tableConfigs[0].meta?.schemaRaw || '',
database: data.tableConfigs[0].meta?.database || '',
},
} : {},
dbConfig: props.design?.data?.dbConfig || 'default', // 保留原有的 dbConfig
};
case 'form_ui_design':
return {
items: data.formConfig.items,
labelWidth: data.formConfig.labelWidth,
labelPosition: data.formConfig.labelPosition,
};
case 'list_config':
return { ...data.listConfig };
case 'form_publish':
return { ...data.publishData };
default:
return {};
}
}
// 确认并继续
function handleConfirm() {
const data = getCurrentData();
emit('confirm', data);
}
// 关闭
function handleClose() {
emit('update:visible', false);
emit('close');
}
</script>
<template>
<div
v-if="visible"
:class="[
'border-border bg-card flex flex-col rounded-lg',
isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full'
]"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ panelTitle }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">{{ $t('common.cancel') }}</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">{{ $t('common.confirmAndContinue') }}</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容区域使用 FormEditorContent 公共组件 -->
<div class="flex-1 overflow-hidden">
<FormEditorContent
ref="editorRef"
:step="currentStep"
:show-steps="true"
:show-actions="false"
:workflow-mode="true"
@update:step="currentStep = $event"
/>
</div>
</div>
</template>
<style scoped>
.design-editor-panel {
/* box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1); */
}
</style>
@@ -1,329 +0,0 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElButton,
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElMessage,
ElOption,
ElSelect,
ElSteps,
ElStep,
} from 'element-plus';
import PublishInfoEditor, { type FormPublishInput } from './PublishInfoEditor.vue';
export interface BasicFormData {
code: string;
description: string;
form_type: 'normal' | 'workflow';
name: string;
sort: number;
}
export interface TableConfig {
alias?: string;
fields?: any[];
id?: string;
meta?: Record<string, any>;
tableName?: string;
type?: string;
}
export interface FormEditorData {
basicForm: BasicFormData;
formConfig: {
disabled: boolean;
formBackground: boolean;
formBorder: boolean;
formBorderRadius: number;
formMargin: number;
formMaxWidth: number;
formPadding: number;
formShadow: boolean;
formWidth: string;
items: any[];
itemSpacing: number;
labelPosition: 'left' | 'right' | 'top';
labelWidth: number;
lifecycleHooks?: any[];
size: 'default' | 'large' | 'small';
tableConfigs: TableConfig[];
};
formFields: any[];
listConfig: Record<string, any>;
publishData?: FormPublishInput;
tableConfigs: TableConfig[];
}
const props = withDefaults(
defineProps<{
initialBasicForm?: BasicFormData;
initialListDesign?: Record<string, any>;
initialTableConfigs?: TableConfig[];
readonly?: boolean;
showActions?: boolean;
showSteps?: boolean;
step?: number;
workflowMode?: boolean;
}>(),
{
initialBasicForm: undefined,
initialListDesign: undefined,
initialTableConfigs: undefined,
readonly: false,
showActions: true,
showSteps: true,
step: undefined,
workflowMode: false,
},
);
const emit = defineEmits<{
cancel: [];
save: [data: FormEditorData];
'step-change': [step: number];
'update:step': [step: number];
}>();
const internalStep = ref(0);
const currentStep = computed({
get: () => (props.step === undefined ? internalStep.value : props.step),
set: (val) => {
if (props.step === undefined) internalStep.value = val;
else emit('update:step', val);
emit('step-change', val);
},
});
const basicForm = ref<BasicFormData>({
code: '',
description: '',
form_type: 'normal',
name: '',
sort: 0,
});
const tableConfigs = ref<TableConfig[]>([]);
const formConfigText = ref('{}');
const listConfigText = ref('{}');
const formFields = ref<any[]>([]);
const publishData = ref<FormPublishInput>({
menu_icon: 'lucide:file-text',
menu_name: '',
menu_order: 0,
menu_parent_id: undefined,
});
const steps = computed(() => {
const base = [
{ title: $t('form-manager.editor.steps.basic') || '基础信息' },
{ title: $t('form-manager.editor.steps.database') || '数据表' },
{ title: $t('form-manager.editor.steps.form') || '表单设计' },
{ title: $t('form-manager.editor.steps.list') || '列表设计' },
];
if (props.workflowMode) {
base.push({ title: $t('form-manager.editor.steps.publish') || '发布配置' });
}
return base;
});
const canGoPrev = computed(() => currentStep.value > 0);
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
watch(
() => props.initialBasicForm,
(val) => {
if (val) basicForm.value = { ...val };
},
{ immediate: true },
);
watch(
() => props.initialTableConfigs,
(val) => {
if (val) tableConfigs.value = [...val];
},
{ immediate: true },
);
watch(
() => props.initialListDesign,
(val) => {
if (val) listConfigText.value = JSON.stringify(val, null, 2);
},
{ immediate: true },
);
watch(
() => basicForm.value.name,
(name) => {
if (props.workflowMode && name && !publishData.value.menu_name) {
publishData.value.menu_name = name;
}
},
);
function parseJson(text: string, fallback: any) {
if (!text.trim()) return fallback;
return JSON.parse(text);
}
function buildDefaultFormConfig(items: any[] = []) {
return {
disabled: false,
formBackground: false,
formBorder: false,
formBorderRadius: 4,
formMargin: 0,
formMaxWidth: 0,
formPadding: 20,
formShadow: false,
formWidth: '100%',
items,
itemSpacing: 18,
labelPosition: 'right' as const,
labelWidth: 120,
size: 'default' as const,
tableConfigs: tableConfigs.value,
};
}
function getData(): FormEditorData {
const formConfig = {
...buildDefaultFormConfig(),
...parseJson(formConfigText.value, {}),
};
const listConfig = parseJson(listConfigText.value, {});
return {
basicForm: { ...basicForm.value },
formConfig,
formFields: [...formFields.value],
listConfig,
publishData: props.workflowMode ? { ...publishData.value } : undefined,
tableConfigs: [...tableConfigs.value],
};
}
function setData(data: Partial<FormEditorData>) {
if (data.basicForm) basicForm.value = { ...data.basicForm };
if (data.tableConfigs) tableConfigs.value = [...data.tableConfigs];
if (data.formConfig) {
formConfigText.value = JSON.stringify(data.formConfig, null, 2);
formFields.value = data.formFields || data.formConfig.items || [];
}
if (data.listConfig) listConfigText.value = JSON.stringify(data.listConfig, null, 2);
if (data.formFields) formFields.value = [...data.formFields];
if (data.publishData) publishData.value = { ...publishData.value, ...data.publishData };
}
function handlePrev() {
if (canGoPrev.value) currentStep.value -= 1;
}
function handleNext() {
if (!isLastStep.value) currentStep.value += 1;
}
function handleSave() {
try {
emit('save', getData());
} catch {
ElMessage.error('JSON 配置格式不正确');
}
}
defineExpose({ getData, setData });
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="showSteps" class="border-border border-b px-6 py-4">
<ElSteps :active="currentStep" align-center finish-status="success">
<ElStep v-for="item in steps" :key="item.title" :title="item.title" />
</ElSteps>
</div>
<div class="min-h-0 flex-1 overflow-auto p-6">
<ElForm v-if="currentStep === 0" :model="basicForm" label-width="100px">
<ElFormItem label="名称" required>
<ElInput v-model="basicForm.name" :disabled="readonly" />
</ElFormItem>
<ElFormItem label="编码" required>
<ElInput v-model="basicForm.code" :disabled="readonly" />
</ElFormItem>
<ElFormItem label="类型">
<ElSelect v-model="basicForm.form_type" :disabled="readonly" class="w-full">
<ElOption label="普通表单" value="normal" />
<ElOption label="流程表单" value="workflow" />
</ElSelect>
</ElFormItem>
<ElFormItem label="排序">
<ElInputNumber v-model="basicForm.sort" :disabled="readonly" class="w-full" />
</ElFormItem>
<ElFormItem label="描述">
<ElInput v-model="basicForm.description" :disabled="readonly" type="textarea" />
</ElFormItem>
</ElForm>
<div v-else-if="currentStep === 1" class="space-y-3">
<div class="text-sm text-muted-foreground">
确认 AI 生成的数据表结构保存后将作为后续表单和列表配置的数据基础
</div>
<ElInput
:model-value="JSON.stringify(tableConfigs, null, 2)"
readonly
resize="none"
type="textarea"
/>
</div>
<div v-else-if="currentStep === 2" class="h-full min-h-[420px]">
<ElInput
v-model="formConfigText"
:readonly="readonly"
class="h-full"
resize="none"
type="textarea"
placeholder="表单配置 JSON"
/>
</div>
<div v-else-if="currentStep === 3" class="h-full min-h-[420px]">
<ElInput
v-model="listConfigText"
:readonly="readonly"
class="h-full"
resize="none"
type="textarea"
placeholder="列表配置 JSON"
/>
</div>
<PublishInfoEditor
v-else
v-model="publishData"
:form-code="basicForm.code"
/>
</div>
<div v-if="showActions" class="border-border flex justify-end gap-2 border-t px-6 py-4">
<ElButton @click="emit('cancel')">{{ $t('common.cancel') }}</ElButton>
<ElButton :disabled="!canGoPrev" @click="handlePrev">上一步</ElButton>
<ElButton v-if="!isLastStep" type="primary" @click="handleNext">下一步</ElButton>
<ElButton v-else type="primary" @click="handleSave">{{ $t('common.save') }}</ElButton>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>
@@ -1,140 +0,0 @@
<script lang="ts" setup>
/**
* 页面基础信息编辑器
* 供 page-manager 和工作流节点共用
*/
import { watch } from 'vue';
import { $t } from '@vben/locales';
import {
ElForm,
ElFormItem,
ElInput,
ElInputNumber,
ElOption,
ElSelect,
} from 'element-plus';
export interface PageBasicInfo {
name: string;
code: string;
category: string;
description: string;
sort: number;
}
interface Props {
modelValue: PageBasicInfo;
isEditMode?: boolean;
showTitle?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
isEditMode: false,
showTitle: false,
});
const emit = defineEmits<{
'update:modelValue': [value: PageBasicInfo];
}>();
// 更新表单数据
function updateFormData(key: keyof PageBasicInfo, value: any) {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
}
// 分类选项
const categoryOptions = [
{ label: $t('page-manager.categoryMap.dashboard'), value: 'dashboard' },
{ label: $t('page-manager.categoryMap.portal'), value: 'portal' },
{ label: $t('page-manager.categoryMap.databoard'), value: 'databoard' },
{ label: $t('page-manager.categoryMap.other'), value: 'other' },
];
// 监听 modelValue 变化
watch(
() => props.modelValue,
() => {},
{ deep: true },
);
</script>
<template>
<div>
<h3 v-if="showTitle" class="mb-6 text-center text-lg font-medium">
{{ $t('page-manager.editor.steps.basic') }}
</h3>
<ElForm :model="modelValue" label-width="100px" label-position="right">
<ElFormItem :label="$t('page-manager.name')" required>
<ElInput
:model-value="modelValue.name"
:placeholder="$t('page-manager.placeholder.name')"
clearable
@update:model-value="updateFormData('name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.code')" required>
<ElInput
:model-value="modelValue.code"
:placeholder="$t('page-manager.placeholder.code')"
clearable
:disabled="isEditMode"
@update:model-value="updateFormData('code', $event)"
/>
<template #error>
<div
v-if="
modelValue.code &&
!/^[a-zA-Z][a-zA-Z0-9_]*$/.test(modelValue.code)
"
class="el-form-item__error"
>
{{ $t('page-manager.codeFormatError') }}
</div>
</template>
</ElFormItem>
<ElFormItem :label="$t('page-manager.category')">
<ElSelect
:model-value="modelValue.category"
:placeholder="$t('page-manager.placeholder.category')"
class="w-full"
clearable
@update:model-value="updateFormData('category', $event)"
>
<ElOption
v-for="opt in categoryOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInputNumber
:model-value="modelValue.sort"
:min="0"
:max="9999"
class="w-full"
@update:model-value="updateFormData('sort', $event ?? 0)"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.description')">
<ElInput
:model-value="modelValue.description"
type="textarea"
:rows="4"
:placeholder="$t('page-manager.placeholder.description')"
@update:model-value="updateFormData('description', $event)"
/>
</ElFormItem>
</ElForm>
</div>
</template>
@@ -1,210 +0,0 @@
<script lang="ts" setup>
import type { PageBasicInfo } from './PageBasicInfoEditor.vue';
import type { PagePublishInfo } from './PagePublishInfoEditor.vue';
import { computed, ref, watch } from 'vue';
import { $t } from '@vben/locales';
import { ElButton, ElInput, ElMessage, ElStep, ElSteps } from 'element-plus';
import PageBasicInfoEditor from './PageBasicInfoEditor.vue';
import PagePublishInfoEditor from './PagePublishInfoEditor.vue';
export interface PageEditorData {
basicForm: PageBasicInfo;
pageConfig: Record<string, any>;
publishData?: PagePublishInfo;
}
const props = withDefaults(
defineProps<{
initialBasicForm?: PageBasicInfo;
initialPageConfig?: Record<string, any>;
initialPublishData?: PagePublishInfo;
isEditMode?: boolean;
readonly?: boolean;
showActions?: boolean;
showSteps?: boolean;
step?: number;
workflowMode?: boolean;
}>(),
{
initialBasicForm: undefined,
initialPageConfig: undefined,
initialPublishData: undefined,
isEditMode: false,
readonly: false,
showActions: true,
showSteps: true,
step: undefined,
workflowMode: false,
},
);
const emit = defineEmits<{
cancel: [];
save: [data: PageEditorData];
'step-change': [step: number];
'update:step': [step: number];
}>();
const internalStep = ref(0);
const currentStep = computed({
get: () => (props.step === undefined ? internalStep.value : props.step),
set: (val) => {
if (props.step === undefined) internalStep.value = val;
else emit('update:step', val);
emit('step-change', val);
},
});
const basicForm = ref<PageBasicInfo>({
category: 'dashboard',
code: '',
description: '',
name: '',
sort: 0,
});
const pageConfigText = ref('{}');
const publishData = ref<PagePublishInfo>({
menu_icon: 'lucide:layout-dashboard',
menu_name: '',
menu_order: 0,
menu_parent_id: undefined,
});
const steps = computed(() => {
const base = [
{ title: $t('page-manager.editor.steps.basic') || '基础信息' },
{ title: $t('page-manager.editor.steps.design') || '页面设计' },
];
if (props.workflowMode) {
base.push({ title: $t('page-manager.editor.steps.publish') || '发布配置' });
}
return base;
});
const canGoPrev = computed(() => currentStep.value > 0);
const isLastStep = computed(() => currentStep.value === steps.value.length - 1);
watch(
() => props.initialBasicForm,
(val) => {
if (val) basicForm.value = { ...val };
},
{ immediate: true },
);
watch(
() => props.initialPageConfig,
(val) => {
if (val) pageConfigText.value = JSON.stringify(val, null, 2);
},
{ immediate: true },
);
watch(
() => props.initialPublishData,
(val) => {
if (val) publishData.value = { ...val };
},
{ immediate: true },
);
watch(
() => basicForm.value.name,
(name) => {
if (props.workflowMode && name && !publishData.value.menu_name) {
publishData.value.menu_name = name;
}
},
);
function getData(): PageEditorData {
const pageConfig = pageConfigText.value.trim()
? JSON.parse(pageConfigText.value)
: {};
return {
basicForm: { ...basicForm.value },
pageConfig,
publishData: props.workflowMode ? { ...publishData.value } : undefined,
};
}
function setData(data: Partial<PageEditorData>) {
if (data.basicForm) basicForm.value = { ...data.basicForm };
if (data.pageConfig) pageConfigText.value = JSON.stringify(data.pageConfig, null, 2);
if (data.publishData) publishData.value = { ...publishData.value, ...data.publishData };
}
function handlePrev() {
if (canGoPrev.value) currentStep.value -= 1;
}
function handleNext() {
if (!isLastStep.value) currentStep.value += 1;
}
function handleSave() {
try {
emit('save', getData());
} catch {
ElMessage.error('页面配置不是有效 JSON');
}
}
defineExpose({ getData, setData });
</script>
<template>
<div class="flex h-full min-h-0 flex-col">
<div v-if="showSteps" class="border-border border-b px-6 py-4">
<ElSteps :active="currentStep" align-center finish-status="success">
<ElStep v-for="item in steps" :key="item.title" :title="item.title" />
</ElSteps>
</div>
<div class="min-h-0 flex-1 overflow-auto p-6">
<PageBasicInfoEditor
v-if="currentStep === 0"
v-model="basicForm"
:is-edit-mode="isEditMode"
/>
<div v-else-if="currentStep === 1" class="h-full min-h-[460px]">
<div class="mb-3 text-sm text-muted-foreground">
确认 AI 生成的页面配置保存后将作为仪表盘发布和菜单挂载的数据基础
</div>
<ElInput
v-model="pageConfigText"
:readonly="readonly"
class="h-full"
resize="none"
type="textarea"
placeholder="页面配置 JSON"
/>
</div>
<PagePublishInfoEditor
v-else
v-model="publishData"
:page-code="basicForm.code"
/>
</div>
<div v-if="showActions" class="border-border flex justify-end gap-2 border-t px-6 py-4">
<ElButton @click="emit('cancel')">{{ $t('common.cancel') }}</ElButton>
<ElButton :disabled="!canGoPrev" @click="handlePrev">上一步</ElButton>
<ElButton v-if="!isLastStep" type="primary" @click="handleNext">下一步</ElButton>
<ElButton v-else type="primary" @click="handleSave">{{ $t('common.save') }}</ElButton>
</div>
</div>
</template>
<style scoped>
:deep(.el-textarea),
:deep(.el-textarea__inner) {
height: 100%;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
}
</style>
@@ -1,129 +0,0 @@
<script lang="ts" setup>
/**
* 页面发布信息编辑器
* 供 page-manager 和工作流节点共用
*/
import type { MenuItem } from '#/components/zq-form/zq-menu-selector/types';
import { watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
import { ZqMenuSelector } from '#/components/zq-form/zq-menu-selector';
import { useAppContextStore } from '#/store/app-context';
export interface PagePublishInfo {
menu_name: string;
menu_parent_id?: string;
menu_icon: string;
menu_order: number;
}
interface Props {
modelValue: PagePublishInfo;
pageCode?: string;
showRouteInfo?: boolean;
routePrefix?: string;
}
const props = withDefaults(defineProps<Props>(), {
pageCode: '',
showRouteInfo: true,
routePrefix: '/page/',
});
const emit = defineEmits<{
'update:modelValue': [value: PagePublishInfo];
}>();
const appContextStore = useAppContextStore();
// 更新表单数据
function updateFormData(key: keyof PagePublishInfo, value: any) {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
}
// 菜单选择回调
function handleMenuChange(menu: MenuItem | MenuItem[] | null) {
if (menu && !Array.isArray(menu)) {
updateFormData('menu_parent_id', menu.id);
} else {
updateFormData('menu_parent_id', undefined);
}
}
// 监听 modelValue 变化
watch(
() => props.modelValue,
() => {},
{ deep: true },
);
</script>
<template>
<ElForm :model="modelValue" label-width="100px">
<!-- 菜单配置 -->
<div class="mb-4 font-medium">
{{ $t('page-manager.publishDialog.menuConfig') }}
</div>
<ElFormItem :label="$t('page-manager.publishDialog.menuName')" required>
<ElInput
:model-value="modelValue.menu_name"
:placeholder="$t('page-manager.placeholder.name')"
@update:model-value="updateFormData('menu_name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.publishDialog.parentMenu')">
<ZqMenuSelector
:model-value="modelValue.menu_parent_id || null"
mode="dialog"
:placeholder="$t('page-manager.publishDialog.parentMenuPlaceholder')"
:application-id="appContextStore.currentApp?.id"
@change="handleMenuChange"
/>
</ElFormItem>
<ElFormItem :label="$t('page-manager.publishDialog.menuIcon')">
<ZqIconPicker
:model-value="modelValue.menu_icon"
prefix="lucide"
:auto-fetch-api="false"
class="w-full"
@update:model-value="updateFormData('menu_icon', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInputNumber
:model-value="modelValue.menu_order"
:min="0"
:max="9999"
class="w-full"
@update:model-value="updateFormData('menu_order', $event ?? 0)"
/>
</ElFormItem>
<!-- 路由信息 -->
<template v-if="showRouteInfo && pageCode">
<div class="mb-4 mt-6 font-medium">
{{ $t('page-manager.publishDialog.routeInfo') }}
</div>
<ElFormItem :label="$t('page-manager.publishDialog.accessPath')">
<ElInput
:model-value="`${routePrefix}${pageCode}`"
disabled
class="text-muted-foreground"
/>
</ElFormItem>
</template>
</ElForm>
</template>
@@ -1,122 +0,0 @@
<script lang="ts" setup>
import type { MenuItem } from '#/components/zq-form/zq-menu-selector/types';
import { watch } from 'vue';
import { $t } from '@vben/locales';
import { ElForm, ElFormItem, ElInput, ElInputNumber } from 'element-plus';
import { ZqIconPicker } from '#/components/zq-form/zq-icon-picker';
import { ZqMenuSelector } from '#/components/zq-form/zq-menu-selector';
import { useAppContextStore } from '#/store/app-context';
interface Props {
modelValue: FormPublishInput;
formCode?: string;
showRouteInfo?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
formCode: '',
showRouteInfo: true,
});
const emit = defineEmits<{
'update:modelValue': [value: FormPublishInput];
}>();
const appContextStore = useAppContextStore();
// 更新表单数据
function updateFormData(key: keyof FormPublishInput, value: any) {
emit('update:modelValue', {
...props.modelValue,
[key]: value,
});
}
// 菜单选择回调
function handleMenuChange(menu: MenuItem | MenuItem[] | null) {
if (menu && !Array.isArray(menu)) {
updateFormData('menu_parent_id', menu.id);
} else {
updateFormData('menu_parent_id', undefined);
}
}
// 监听 modelValue 变化,确保响应式
watch(
() => props.modelValue,
() => {},
{ deep: true },
);
</script>
<template>
<ElForm :model="modelValue" label-width="100px">
<!-- 菜单配置 -->
<div class="mb-4 font-medium">
{{ $t('form-manager.publishDialog.menuConfig') }}
</div>
<ElFormItem :label="$t('form-manager.publishDialog.menuName')" required>
<ElInput
:model-value="modelValue.menu_name"
:placeholder="$t('form-manager.placeholder.name')"
@update:model-value="updateFormData('menu_name', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.publishDialog.parentMenu')">
<ZqMenuSelector
:model-value="modelValue.menu_parent_id || null"
mode="dialog"
:placeholder="$t('form-manager.publishDialog.parentMenuPlaceholder')"
:application-id="appContextStore.currentApp?.id"
@change="handleMenuChange"
/>
</ElFormItem>
<ElFormItem :label="$t('form-manager.publishDialog.menuIcon')">
<ZqIconPicker
:model-value="modelValue.menu_icon"
prefix="lucide"
:auto-fetch-api="false"
class="w-full"
@update:model-value="updateFormData('menu_icon', $event)"
/>
</ElFormItem>
<ElFormItem :label="$t('common.sort')">
<ElInputNumber
:model-value="modelValue.menu_order"
:min="0"
:max="999"
class="w-full"
@update:model-value="updateFormData('menu_order', $event)"
/>
</ElFormItem>
<!-- 路由信息 -->
<template v-if="showRouteInfo && formCode">
<div class="mb-4 mt-6 font-medium">
{{ $t('form-manager.publishDialog.routeInfo') }}
</div>
<ElFormItem :label="$t('form-manager.publishDialog.accessPath')">
<ElInput
:model-value="`/form-render/${formCode}`"
disabled
class="text-muted-foreground"
/>
</ElFormItem>
</template>
</ElForm>
</template>
export interface FormPublishInput {
menu_icon: string;
menu_name: string;
menu_order: number;
menu_parent_id?: string;
}
@@ -1,317 +0,0 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import {
AppWindow,
CircleCheck,
ExternalLink,
FileText,
LayoutDashboard,
Maximize,
Minimize,
} from '@vben/icons';
import { useI18n } from '@vben/locales';
import { ElButton, ElCard, ElEmpty } from 'element-plus';
export interface SystemSummaryData {
type: 'system_summary';
title: string;
data: {
app?: {
code: string;
description: string;
icon: string;
id: string;
link: string;
name: string;
};
base_url?: string;
created_at: string;
dashboard?: {
code: string;
description: string;
icon: string;
id: string;
link: string;
name: string;
};
forms: Array<{
code: string;
description: string;
icon: string;
id: string;
link: string;
name: string;
}>;
statistics?: {
forms_count: number;
has_app: boolean;
has_dashboard: boolean;
total_modules: number;
};
title: string;
};
nodeId: string;
}
const props = defineProps<{
data?: SystemSummaryData;
visible: boolean;
}>();
const emit = defineEmits<{
close: [];
confirm: [data: any];
'update:visible': [value: boolean];
}>();
const { t } = useI18n();
// 全屏状态
const isFullscreen = ref(false);
// 切换全屏
const toggleFullscreen = () => {
isFullscreen.value = !isFullscreen.value;
};
const dialogVisible = computed({
get: () => props.visible,
set: (val) => emit('update:visible', val),
});
const summaryData = computed(() => props.data?.data || ({} as any));
const statistics = computed(() => summaryData.value.statistics);
const app = computed(() => summaryData.value.app);
const forms = computed(() => summaryData.value.forms || []);
const dashboard = computed(() => summaryData.value.dashboard);
const handleConfirm = () => {
emit('confirm', summaryData.value);
};
const handleClose = () => {
dialogVisible.value = false;
emit('close');
};
const openLink = (link: string) => {
if (link) {
const baseUrl = summaryData.value.base_url || '';
const fullUrl = baseUrl ? `${baseUrl}${link}` : link;
window.open(fullUrl, '_blank');
}
};
</script>
<template>
<div
v-if="visible"
class="border-border bg-card flex flex-col rounded-lg"
:class="[isFullscreen ? 'fixed inset-0 z-50 ml-0' : 'ml-3 h-full w-full']"
>
<!-- 头部 -->
<div
class="border-border bg-muted/50 flex items-center justify-between border-b px-4 py-3"
>
<div class="text-foreground font-medium">
{{ data?.title || t('ai.systemSummary.title') }}
</div>
<div class="flex items-center gap-2">
<ElButton size="small" @click="handleClose">
{{ t('common.cancel') }}
</ElButton>
<ElButton size="small" type="primary" @click="handleConfirm">
<CircleCheck class="mr-1 size-4" />
{{ t('ai.systemSummary.complete') }}
</ElButton>
<ElButton
link
:icon="isFullscreen ? Minimize : Maximize"
:title="isFullscreen ? '退出全屏' : '全屏'"
@click="toggleFullscreen"
/>
</div>
</div>
<!-- 内容 -->
<div class="flex-1 overflow-auto p-6">
<div
class="mb-6 flex items-center gap-3 border-b border-green-200 pb-4 dark:border-green-800"
>
<div
class="flex size-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-900"
>
<CircleCheck class="size-6 text-green-600 dark:text-green-400" />
</div>
<div>
<h2 class="text-xl font-semibold text-green-700 dark:text-green-300">
{{ summaryData.title || t('ai.systemSummary.title') }}
</h2>
<p class="text-muted-foreground text-sm">
{{ t('ai.systemSummary.subtitle') }}
</p>
</div>
</div>
<div v-if="statistics" class="mb-6 grid grid-cols-3 gap-4">
<div
class="rounded-lg border bg-blue-50 p-4 text-center dark:bg-blue-950"
>
<div class="text-3xl font-bold text-blue-600 dark:text-blue-400">
{{ statistics.has_app ? 1 : 0 }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ t('ai.systemSummary.app') }}
</div>
</div>
<div
class="rounded-lg border bg-purple-50 p-4 text-center dark:bg-purple-950"
>
<div class="text-3xl font-bold text-purple-600 dark:text-purple-400">
{{ statistics.forms_count }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ t('ai.systemSummary.formModules') }}
</div>
</div>
<div
class="rounded-lg border bg-orange-50 p-4 text-center dark:bg-orange-950"
>
<div class="text-3xl font-bold text-orange-600 dark:text-orange-400">
{{ statistics.has_dashboard ? 1 : 0 }}
</div>
<div class="text-muted-foreground mt-1 text-sm">
{{ t('ai.systemSummary.dashboard') }}
</div>
</div>
</div>
<div v-if="app" class="mb-6">
<h3 class="mb-3 flex items-center gap-2 font-medium">
<AppWindow class="size-5 text-blue-500" />
{{ t('ai.systemSummary.appInfo') }}
</h3>
<ElCard
shadow="hover"
class="cursor-pointer"
@click="openLink(app.link)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900"
>
<AppWindow class="size-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<div class="font-medium">{{ app.name }}</div>
<div class="text-muted-foreground text-sm">
{{ app.description || app.code }}
</div>
</div>
</div>
<ElButton
v-if="app.link"
type="primary"
link
@click.stop="openLink(app.link)"
>
<ExternalLink class="mr-1 size-4" />
{{ t('ai.systemSummary.visit') }}
</ElButton>
</div>
</ElCard>
</div>
<div v-if="forms.length > 0" class="mb-6">
<h3 class="mb-3 flex items-center gap-2 font-medium">
<FileText class="size-5 text-purple-500" />
{{ t('ai.systemSummary.formModulesTitle') }}
<span class="text-muted-foreground text-sm">({{ forms.length }})</span>
</h3>
<div class="space-y-3">
<ElCard
v-for="form in forms"
:key="form.id || form.code"
shadow="hover"
class="cursor-pointer"
@click="openLink(form.link)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-lg bg-purple-100 dark:bg-purple-900"
>
<FileText
class="size-5 text-purple-600 dark:text-purple-400"
/>
</div>
<div>
<div class="font-medium">{{ form.name }}</div>
<div class="text-muted-foreground text-sm">
{{ form.description || form.code }}
</div>
</div>
</div>
<ElButton
v-if="form.link"
type="primary"
link
@click.stop="openLink(form.link)"
>
<ExternalLink class="mr-1 size-4" />
{{ t('ai.systemSummary.visit') }}
</ElButton>
</div>
</ElCard>
</div>
</div>
<div v-if="dashboard" class="mb-6">
<h3 class="mb-3 flex items-center gap-2 font-medium">
<LayoutDashboard class="size-5 text-orange-500" />
{{ t('ai.systemSummary.dashboardTitle') }}
</h3>
<ElCard
shadow="hover"
class="cursor-pointer"
@click="openLink(dashboard.link)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div
class="flex size-10 items-center justify-center rounded-lg bg-orange-100 dark:bg-orange-900"
>
<LayoutDashboard
class="size-5 text-orange-600 dark:text-orange-400"
/>
</div>
<div>
<div class="font-medium">{{ dashboard.name }}</div>
<div class="text-muted-foreground text-sm">
{{ dashboard.description || dashboard.code }}
</div>
</div>
</div>
<ElButton
v-if="dashboard.link"
type="primary"
link
@click.stop="openLink(dashboard.link)"
>
<ExternalLink class="mr-1 size-4" />
{{ t('ai.systemSummary.visit') }}
</ElButton>
</div>
</ElCard>
</div>
<ElEmpty
v-if="!app && forms.length === 0 && !dashboard"
:description="t('ai.systemSummary.noData')"
/>
</div>
</div>
</template>
@@ -1,40 +0,0 @@
/**
* 表单编辑器公共组件
* 可用于:表单管理器、工作流设计预览编辑
*/
export { default as BasicInfoEditor } from './BasicInfoEditor.vue';
export type { BasicFormData } from './BasicInfoEditor.vue';
export { default as DesignEditorPanel } from './DesignEditorPanel.vue';
export type { DesignData, DesignType } from './DesignEditorPanel.vue';
export { default as FormEditorContent } from './FormEditorContent.vue';
export type { FormEditorData, BasicFormData as FormBasicData } from './FormEditorContent.vue';
export { default as AppDesignPanel } from './AppDesignPanel.vue';
export type { AppDesignData } from './AppDesignPanel.vue';
export { default as AppSettingsPanel } from './AppSettingsPanel.vue';
export type { AppSettingsData } from './AppSettingsPanel.vue';
export { default as DashboardBasicInfoConfirmPanel } from './DashboardBasicInfoConfirmPanel.vue';
export type { DashboardBasicInfoData } from './DashboardBasicInfoConfirmPanel.vue';
export { default as DashboardDesignConfirmPanel } from './DashboardDesignConfirmPanel.vue';
export type { DashboardDesignData } from './DashboardDesignConfirmPanel.vue';
export { default as PageBasicInfoEditor } from './PageBasicInfoEditor.vue';
export type { PageBasicInfo } from './PageBasicInfoEditor.vue';
export { default as PagePublishInfoEditor } from './PagePublishInfoEditor.vue';
export type { PagePublishInfo } from './PagePublishInfoEditor.vue';
export { default as PageEditorContent } from './PageEditorContent.vue';
export type { PageEditorData } from './PageEditorContent.vue';
export { default as DashboardPublishConfirmPanel } from './DashboardPublishConfirmPanel.vue';
export type { DashboardPublishData } from './DashboardPublishConfirmPanel.vue';
export { default as SystemSummaryConfirmPanel } from './SystemSummaryConfirmPanel.vue';
export type { SystemSummaryData } from './SystemSummaryConfirmPanel.vue';
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Page } from '@vben/common-ui/es/page';
import { PanelLeft } from '@vben/icons';
import { ElCard, ElScrollbar, ElSplitter, ElSplitterPanel } from 'element-plus';
@@ -1,50 +0,0 @@
<script lang="ts" setup>
import { computed, defineAsyncComponent } from 'vue';
import { ZqDialog } from '#/components/zq-dialog';
import { $t } from '#/locales';
defineOptions({
name: 'UserProfileDialog',
});
const OrgChartPanel = defineAsyncComponent(
() => import('#/views/_core/org-chart/modules/OrgChartPanel.vue'),
);
const props = defineProps<{
modelValue: boolean;
userId: string;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
});
</script>
<template>
<ZqDialog
v-model="visible"
:title="$t('user-avatar.profile.organization')"
width="80%"
:show-footer="false"
content-height="70vh"
destroy-on-close
>
<div class="org-chart-wrapper">
<OrgChartPanel :user-id="userId" :show-mode-toggle="true" />
</div>
</ZqDialog>
</template>
<style lang="scss" scoped>
.org-chart-wrapper {
height: 65vh;
overflow: hidden;
}
</style>