企业工作台/首页设计:可拖拽磁贴 + 待办聚合 + 数据卡片实现
🌐 演示地址: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 排应用。

▲ 工作台全景:设计器/渲染器分层、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/。
一、目录结构与页面分层

▲ 员工登录后的工作台首页:欢迎语+天气磁贴、应用中心(发起流程/用印申请/请假销假等高频入口)、待办任务/我的单据/已办/抄送多 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 列磁贴布局

▲ 首页设计器:左侧「组件库」按统计卡片/图表/列表/快捷导航分组,中间是 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 注册表设计

所有可拖拽到工作台的磁贴,在 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 组件清单
| 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 新增磁贴四步
四、待办聚合:workbench-task-list.vue
4.1 四 Tab 设计

workbench-task-list 是企业工作台的核心磁贴——把 BPM 四条线合并为一个 Tab 面板:
| 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 统一列展示:
| 单据类型 | 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 功能概述

应用中心磁贴展示用户「常用应用」图标墙,支持:
- 从 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() 从系统默认应用模板初始化,避免空白磁贴。
| 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 模式加载。

▲ 首页管理列表:支持维护多套首页模板(默认工作台、自定义首页…),每套可「设计 / 设置为首页 / 启用」——配合按角色下发不同默认首页,即可实现「千人千面」的工作台
与 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
- 📍 登录后默认进入工作台首页
推荐体验流程
本地启动
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 支持一下!






