欢迎光临
我们一直在努力

企业工作台/首页设计:可拖拽磁贴 + 待办聚合 + 数据卡片实现

企业工作台/首页设计:可拖拽磁贴 + 待办聚合 + 数据卡片实现

🌐 演示地址:http://ruoyioffice.com | 📦 源码1·GitHub:ruoyi-office | 📦 源码2·GitCode:ruoyi-office | 📦 源码3·Gitee:ruoyi-office | 💬 微信:17156169080(备注「RuoYi Office」)

员工每天打开系统的第一个页面,决定了他对整套 ERP/OA 的第一印象。钉钉工作台、飞书多维表格首页——头部产品都在做可拖拽磁贴 + 待办聚合 + 数据卡片。本文不重复「千人千面数据模型」的理论(详见 custom-homepage-personalized-experience.md),而是聚焦 RuoYi Office 工作台的前端实操:grid-layout-plus 怎么拖、registry.ts 怎么注册 11 个磁贴、workbench-task-list 怎么聚合四路 BPM 待办、workbench-app-center 怎么用 vuedraggable 排应用。

workbench-architecture.png

▲ 工作台全景:设计器/渲染器分层、grid-layout-plus 24 列栅格、11 个内置磁贴组件、四 Tab 待办聚合与 vuedraggable 应用中心一图看懂

引言:工作台设计到底难在哪?

「首页」和「工作台」在企业系统里常被混用,但工程上它们要解决不同问题:

问题一:布局不可写死。不同角色需要不同磁贴组合——欢迎区、待办、公告、日程、统计卡片——硬编码 <div> 布局无法支撑千人千面。

问题二:拖拽与渲染要分离。设计器需要拖拽/缩放;员工日常使用时不能误触改变布局——同一套 GridLayout 组件,两种模式参数截然不同。

问题三:待办来源分散。待办任务、我的单据、已办、抄送——四路 BPM API,Tab 切换、Badge 统计、跳转审批详情,逻辑集中在一个磁贴里。

问题四:应用中心与权限菜单联动。「常用应用」必须从 RBAC 菜单树提取叶子节点,拖拽排序后持久化到用户维度——不能展示无权限的入口。

问题五:组件扩展不能改框架。新增「合同到期提醒」磁贴, ideally 只写 Vue 组件 + 一行 registerComponent,不动 index.vue。

痛点不处理的后果
布局写死 每改一次首页发版
拖拽不分离 员工误拖布局,体验灾难
待办分散 4 个菜单才能办完一件事
应用无权限过滤 点击 403,信任崩塌
组件不注册 设计器选不到新磁贴

RuoYi Office 的解法是:grid-layout-plus 栅格 + 组件注册制 + 设计/渲染双模式。代码路径:ruoyi-office-vben/apps/web-antd/src/views/dashboard/home/。


一、目录结构与页面分层

image.png

▲ 员工登录后的工作台首页:欢迎语+天气磁贴、应用中心(发起流程/用印申请/请假销假等高频入口)、待办任务/我的单据/已办/抄送多 Tab 聚合、通知公告与日程待办——全部由磁贴拼装而成

1.1 前端目录地图

dashboard/home/
├── index.vue # 员工工作台入口(加载我的首页)
├── renderer/
│ └── layout-renderer.vue # 只读渲染(grid-layout-plus static)
├── designer/
│ ├── index.vue # 可视化设计器
│ └── components/
│ ├── designer-canvas.vue # 可拖拽画布
│ ├── component-panel.vue # 左侧组件库
│ └── config-panel.vue # 右侧属性配置
├── components/
│ ├── registry.ts # ★ 11 个磁贴注册表
│ ├── wrapper/component-wrapper.vue # 动态组件加载
│ ├── welcome/workbench-welcome.vue
│ ├── app-center/workbench-app-center.vue # ★ vuedraggable
│ ├── taskLists/workbench-task-list.vue # ★ 四 Tab 待办
│ ├── notice/workbench-notice.vue
│ ├── schedule/workbench-schedule.vue
│ ├── navigation/workbench-quick-nav.vue
│ ├── lists/workbench-project.vue
│ ├── lists/workbench-trends.vue
│ ├── statistics/analytics-visits.vue
│ ├── statistics/analytics-visits-data.vue
│ └── charts/analytics-visits-source.vue
├── manage/ # 首页模板管理(管理员)
└── types/layout.ts # GridLayoutItem 类型定义

1.2 运行时分层

层级文件职责
入口 index.vue 调用 getMyHomePage() 获取当前用户首页 ID
渲染 layout-renderer.vue 加载布局 JSON,只读展示磁贴
设计 designer-canvas.vue 拖拽/缩放/删除,保存到后端
调度 component-wrapper.vue 按 componentCode 从 registry 取 Vue 组件
磁贴 workbench-*.vue 各业务 UI 实现

index.vue
│ getMyHomePage()

LayoutRenderer (pageId)
│ getHomePageLayoutList(pageId)

GridLayout + GridItem[]
│ componentCode

ComponentWrapper → registry.getComponent(code)


二、grid-layout-plus:24 列磁贴布局

image.png

▲ 首页设计器:左侧「组件库」按统计卡片/图表/列表/快捷导航分组,中间是 24 列栅格画布,从组件库拖拽磁贴到画布即可布局,右侧「属性配置」编辑选中磁贴参数,顶部「预览 / 保存」实时生效

2.1 为什么选 grid-layout-plus

grid-layout-plus 是 Vue3 版 react-grid-layout,支持拖拽、缩放、碰撞检测、响应式栅格。RuoYi Office 采用 24 列设计(与 Ant Design 栅格习惯一致),rowHeight=60px,磁贴宽高以「格」为单位存储。

参数设计模式渲染模式
colNum 24 24
rowHeight 60 60
isDraggable true false
isResizable true false
static false true
margin [10, 10] [10, 10]

结论前置:设计器与渲染器共用 GridLayout 组件,通过 isDraggable/isResizable/static 三个开关区分模式,避免维护两套布局引擎。

2.2 layout-renderer.vue:只读渲染核心

员工打开工作台时,layout-renderer.vue 从后端拉取布局列表,转换为 GridLayoutItem[]:

import { GridItem, GridLayout } from 'grid-layout-plus';
import { getHomePageLayoutList } from '#/api/system/home';

const layoutConfig = ref({
colNum: 24,
rowHeight: 60,
isDraggable: false, // 渲染模式不可拖拽
isResizable: false,
margin: [10, 10],
containerPadding: [10, 10],
verticalCompact: false,
});

async function loadLayout() {
const layoutItems = await getHomePageLayoutList(props.pageId);
layout.value = layoutItems.map((item) => ({
i: `item-${item.id}`,
x: item.positionX,
y: item.positionY,
w: item.width,
h: item.height,
componentCode: item.componentCode,
config: item.config ? JSON.parse(item.config) : {},
isDraggable: false,
isResizable: false,
static: true,
}));
}

模板中每个 GridItem 包裹 ComponentWrapper,把 componentCode 和 config 传给具体磁贴:

<GridItem v-for="item in layout" :key="item.i" :x="item.x" :y="item.y"
:w="item.w" :h="item.h" :i="item.i" :static="item.static">
<div class="layout-item-content h-full w-full overflow-hidden rounded bg-white shadow-sm">
<ComponentWrapper :component-code="item.componentCode" :config="item.config" />
</div>
</GridItem>

2.3 designer-canvas.vue:设计模式差异

设计器打开 isDraggable=true、isResizable=true,布局变更通过 emit('update:layout') 回传父组件保存:

function handleLayoutUpdated(newLayout: any[]) {
const updatedLayout = newLayout.map((item) => {
const existingItem = localLayout.value.find((l) => l.i === item.i);
return { existingItem, x: item.x, y: item.y, w: item.w, h: item.h };
});
emit('update:layout', updatedLayout);
}

2.4 GridLayoutItem 数据结构

export interface GridLayoutItem {
i: string; // 唯一标识 item-{id}
x: number; // 列位置 0-23
y: number; // 行位置
w: number; // 宽度(格数)
h: number; // 高度(格数)
componentCode: string; // 磁贴编码,对应 registry
config?: Record<string, any>; // 磁贴私有配置 JSON
static?: boolean;
}

后端表 system_home_page_layout 存储 position_x/y、width/height、component_code、config。


三、11 个内置磁贴:registry.ts 注册制

3.1 注册表设计

image.png

所有可拖拽到工作台的磁贴,在 components/registry.ts 统一注册:

export interface ComponentRegistryItem {
code: string;
component: Component;
name: string;
description?: string;
}

const componentRegistry = new Map<string, ComponentRegistryItem>();

export function registerComponent(item: ComponentRegistryItem) {
componentRegistry.set(item.code, item);
}

export function getComponent(code: string): Component | undefined {
return componentRegistry.get(code)?.component;
}

3.2 完整 11 组件清单

code组件文件名称功能
workbench_welcome workbench-welcome.vue 欢迎组件 用户名、问候语、天气
workbench_app_center workbench-app-center.vue 应用中心 常用应用 + vuedraggable
workbench_task_list workbench-task-list.vue 任务列表 四 Tab BPM 待办聚合
workbench_notice workbench-notice.vue 通知公告 系统公告列表
workbench_schedule workbench-schedule.vue 我的日程 日历 + 待办事项
workbench_quick_nav workbench-quick-nav.vue 快捷导航 自定义快捷入口
workbench_project workbench-project.vue 项目列表 项目卡片
workbench_trends workbench-trends.vue 动态列表 最新动态
analytics_visits analytics-visits.vue 访问统计 访问量 KPI 卡片
analytics_visits_data analytics-visits-data.vue 数据统计 访问详情
analytics_visits_source analytics-visits-source.vue 访问来源 饼图分析

注册示例:

registerComponent({
code: 'workbench_task_list',
component: WorkbenchTaskList,
name: '任务列表',
description: '展示我的单据、待办任务、已办任务、抄送我的',
});

3.3 ComponentWrapper 动态加载

<script setup lang="ts">
import { computed } from 'vue';
import { getComponent } from '../registry';

const props = defineProps<{
componentCode: string;
config?: Record<string, any>;
}>();

const DynamicComponent = computed(() => getComponent(props.componentCode));
</script>

<template>
<component :is="DynamicComponent" v-if="DynamicComponent" v-bind="config" />
<div v-else>组件 {{ componentCode }} 未注册</div>
</template>

3.4 新增磁贴四步

  • 在 components/ 下新建 workbench-xxx.vue
  • 在 registry.ts 中 registerComponent({ code: 'workbench_xxx', … })
  • 后端 system_home_component 表录入组件元数据(设计器组件库展示)
  • 在设计器中拖入画布,调整 w/h,保存布局

  • 四、待办聚合:workbench-task-list.vue

    4.1 四 Tab 设计

    image.png

    workbench-task-list 是企业工作台的核心磁贴——把 BPM 四条线合并为一个 Tab 面板:

    Tab Key标签API说明
    todo 待办任务 getTaskTodoPage 当前用户待审批
    myBill 我的单据 getProcessInstanceMyPage 我发起的流程
    done 已办任务 getTaskDonePage 我已处理的
    copy 抄送我的 getProcessInstanceCopyPage 抄送通知

    type TabKey = 'todo' | 'done' | 'myBill' | 'copy';

    const tabs = computed(() => [
    { key: 'todo', label: '待办任务', count: statistics.value.todo },
    { key: 'myBill', label: '我的单据', count: statistics.value.myBill },
    { key: 'done', label: '已办任务', count: statistics.value.done },
    { key: 'copy', label: '抄送我的', count: statistics.value.copy },
    ]);

    4.2 列定义与 Tab 差异

    不同 Tab 返回的数据结构略有差异(Task vs ProcessInstance vs Copy),组件用 customRender 统一列展示:

    列todo/donemyBillcopy
    单据类型 processInstance.name name processInstanceName
    单据编号 processInstance.billCode billCode billCode
    审批状态 DictTag DictTag + 流程图
    摘要 summary JSON summary summary
    特殊列 任务节点/审批时间 发起时间 抄送节点/抄送时间

    4.3 统计 Badge 与跳转

    组件挂载时并行请求四路 API 的 total 填充 Badge;点击行跳转 BPM 审批详情:

    function handleViewDetail(record: any) {
    const tab = activeTab.value;
    let path = '';
    if (tab === 'todo' || tab === 'done') {
    path = `/bpm/task/detail?id=${record.id}`;
    } else if (tab === 'myBill') {
    path = `/bpm/process-instance/detail?id=${record.id}`;
    } else if (tab === 'copy') {
    path = `/bpm/process-instance/detail?id=${record.processInstanceId}`;
    }
    if (path) router.push(path);
    }

    4.4 可配置 props

    interface Props {
    maxRecordNum?: number; // 默认 10,控制表格最大行数
    }

    设计器 config JSON 可覆盖 maxRecordNum,小磁贴显示 5 条,大磁贴显示 20 条。


    五、应用中心:workbench-app-center.vue + vuedraggable

    5.1 功能概述

    image.png

    应用中心磁贴展示用户「常用应用」图标墙,支持:

    • 从 RBAC 权限菜单选择叶子节点添加
    • vuedraggable 拖拽排序
    • 删除应用(Popconfirm)
    • 排序持久化 updateUserAppSort

    5.2 从权限菜单提取可选应用

    const menuOptions = computed(() => {
    const menus = accessStore.accessMenus || [];
    const options = [];

    const processMenus = (menuList: any[], parents: any[] = []) => {
    for (const menu of menuList) {
    const children = Array.isArray(menu.children) ? menu.children : [];
    const isLeaf = children.length === 0;
    if (isLeaf && menu.path) {
    const id = menu.id ?? menu.menuId ?? menu.meta?.id;
    if (!id) return;
    options.push({
    id: Number(id),
    name: menu.meta?.title ?? menu.name,
    path: menu.path,
    icon: menu.icon ?? menu.meta?.icon ?? 'carbon:application',
    });
    }
    if (children.length > 0) processMenus(children, [parents, menu]);
    }
    };

    processMenus(menus);
    return options;
    });

    设计要点:只取有 path 的叶子菜单,天然过滤目录节点;无 ID 的菜单跳过并 warn,避免后端保存失败。

    5.3 vuedraggable 排序持久化

    <Draggable
    v-model="appList"
    item-key="id"
    :disabled="!enableDrag"
    ghost-class="app-ghost"
    @start="isDragging = true"
    @end="handleDragEnd"
    >
    <template #item="{ element }">
    <div class="app-item" @click="handleAppClick(element)">
    <IconifyIcon :icon="element.icon" />
    <span>{{ element.name }}</span>
    </div>
    </template>
    </Draggable>

    async function handleDragEnd() {
    isDragging.value = false;
    const sortList = appList.value.map((app, index) => ({
    id: app.id,
    sort: index + 1,
    }));
    await updateUserAppSort(sortList);
    message.success('排序已保存');
    }

    5.4 首次初始化

    用户首次进入且无应用数据时,调用 initUserApp() 从系统默认应用模板初始化,避免空白磁贴。

    Props默认说明
    maxAppCount 12 最大展示应用数
    gridCols 4 图标网格列数
    enableDrag true 是否允许拖拽排序

    六、index.vue 入口与预览模式

    const homePageInfo = ref<any>(null);

    async function loadHomePage() {
    homePageInfo.value = await getMyHomePage();
    }

    const previewPageId = computed(() => {
    const pageId = route.query.preview;
    return pageId ? Number(pageId) : null;
    });

    const currentPageId = computed(() => {
    return previewPageId.value || homePageInfo.value?.id;
    });

    • 预览模式:URL ?preview={pageId},设计器保存前预览

    其余磁贴(welcome、notice、schedule、analytics 数据卡片)按同一 ComponentWrapper 模式加载。

    image.png

    ▲ 首页管理列表:支持维护多套首页模板(默认工作台、自定义首页…),每套可「设计 / 设置为首页 / 启用」——配合按角色下发不同默认首页,即可实现「千人千面」的工作台

    与 custom-homepage-personalized-experience.md 的分工:该文讲数据表与后端服务;本文讲 grid-layout 拖拽、registry 注册、待办聚合、vuedraggable 应用中心前端实操。


    七、技术亮点总结

    设计要点实现方式价值
    磁贴布局 grid-layout-plus 拖拽/缩放/栅格化
    双模式 isDraggable/static 开关 设计灵活、使用稳定
    组件扩展 registry.ts 新增磁贴不改框架
    动态加载 ComponentWrapper 按 code 懒渲染
    待办聚合 4 Tab + 4 API 一屏办完审批
    应用中心 vuedraggable 用户自定义排序
    权限联动 accessStore.accessMenus 不展示无权限入口
    预览 ?preview=pageId 设计器即时验证

    八、快速体验

    在线演示

    • 🌐 地址:http://ruoyioffice.com/web/
    • 👤 账号:admin / admin123
    • 📍 登录后默认进入工作台首页

    推荐体验流程

  • 登录后观察默认工作台磁贴布局(欢迎 + 待办 + 应用中心)
  • 点击待办 Tab 切换「待办任务 / 我的单据 / 已办 / 抄送」
  • 在应用中心拖拽图标调整顺序,刷新页面验证持久化
  • 进入 工作台 → 首页管理(或 /workspace/home/manage)
  • 打开设计器,从左侧组件库拖入「我的日程」磁贴
  • 调整大小位置,保存后在员工首页查看效果
  • 阅读 registry.ts 理解组件注册机制
  • 本地启动

    cd W:\\ruoyi-office\\ruoyi-office-vben
    pnpm dev:antd
    # 访问 http://localhost:5800

    源码路径

    ruoyi-office-vben/apps/web-antd/src/views/dashboard/home/


    常见问题(FAQ)

    RuoYi Office 工作台支持拖拽布局吗?

    支持。基于 grid-layout-plus 24 列栅格,管理员在设计器中拖拽/缩放磁贴,员工端只读渲染。

    内置有哪些磁贴组件?

    共 11 个:welcome、app_center、task_list、notice、schedule、quick_nav、project、trends,以及 3 个 analytics 数据卡片。见 components/registry.ts。

    如何新增自定义磁贴?

    新建 Vue 组件 → registerComponent 注册 → 后端组件库录入 → 设计器拖入。无需修改 index.vue。

    待办聚合包含哪些类型?

    四 Tab:待办任务、我的单据、已办任务、抄送我的,分别对接 BPM Task 和 ProcessInstance API。

    结语

    企业工作台不是「把几个 Chart 拼到首页」——它是员工每天工作的操作系统桌面。RuoYi Office 用 grid-layout-plus 解决布局、用 registry 解决扩展、用四 Tab 聚合解决待办分散、用 vuedraggable 解决应用排序——四个齿轮咬合,才能支撑 14 大模块、不同角色的千人千面。

    如果你正在做 OA/ERP 首页改造,或基于 RuoYi Office 二开新磁贴(如「合同到期提醒」「库存预警」),这套 dashboard/home/ 目录就是最好的起点。欢迎 Star,也欢迎微信 17156169080(备注「RuoYi Office」)交流工作台定制经验。

    你的团队首页有哪些「离不开的磁贴」?待办、日程还是数据看板?评论区聊聊。


    💡 想要体验 RuoYi Office 的工作台?

    🌐 在线演示:http://ruoyioffice.com/web/(账号 admin / admin123)

    📦 源码仓库:GitCode | GitHub

    💬 技术咨询:添加微信 17156169080,备注「RuoYi Office」

    ⭐ 如果觉得不错,请给个 Star 支持一下!

    赞(0)
    未经允许不得转载:171主机测评 » 企业工作台/首页设计:可拖拽磁贴 + 待办聚合 + 数据卡片实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址