企业工作台/首页设计:可拖拽磁贴 + 待办聚合 + 数据卡片实现
🌐演示地址: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';constlayoutConfig=ref({colNum:24,rowHeight:60,isDraggable:false,// 渲染模式不可拖拽isResizable:false,margin:[10,10],containerPadding:[10,10],verticalCompact:false,});asyncfunctionloadLayout(){constlayoutItems=awaitgetHomePageLayoutList(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')回传父组件保存:
functionhandleLayoutUpdated(newLayout:any[]){constupdatedLayout=newLayout.map((item)=>{constexistingItem=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 数据结构
exportinterfaceGridLayoutItem{i:string;// 唯一标识 item-{id}x:number;// 列位置 0-23y:number;// 行位置w:number;// 宽度(格数)h:number;// 高度(格数)componentCode:string;// 磁贴编码,对应 registryconfig?:Record<string,any>;// 磁贴私有配置 JSONstatic?:boolean;}后端表system_home_page_layout存储position_x/y、width/height、component_code、config。
三、11 个内置磁贴:registry.ts 注册制
3.1 注册表设计
所有可拖拽到工作台的磁贴,在components/registry.ts统一注册:
exportinterfaceComponentRegistryItem{code:string;component:Component;name:string;description?:string;}constcomponentRegistry=newMap<string,ComponentRegistryItem>();exportfunctionregisterComponent(item:ComponentRegistryItem){componentRegistry.set(item.code,item);}exportfunctiongetComponent(code:string):Component|undefined{returncomponentRegistry.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 设计
workbench-task-list是企业工作台的核心磁贴——把 BPM 四条线合并为一个 Tab 面板:
| Tab Key | 标签 | API | 说明 |
|---|---|---|---|
todo | 待办任务 | getTaskTodoPage | 当前用户待审批 |
myBill | 我的单据 | getProcessInstanceMyPage | 我发起的流程 |
done | 已办任务 | getTaskDonePage | 我已处理的 |
copy | 抄送我的 | getProcessInstanceCopyPage | 抄送通知 |
typeTabKey='todo'|'done'|'myBill'|'copy';consttabs=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/done | myBill | copy |
|---|---|---|---|
| 单据类型 | processInstance.name | name | processInstanceName |
| 单据编号 | processInstance.billCode | billCode | billCode |
| 审批状态 | DictTag | DictTag + 流程图 | — |
| 摘要 | summary JSON | summary | summary |
| 特殊列 | 任务节点/审批时间 | 发起时间 | 抄送节点/抄送时间 |
4.3 统计 Badge 与跳转
组件挂载时并行请求四路 API 的total填充 Badge;点击行跳转 BPM 审批详情:
functionhandleViewDetail(record:any){consttab=activeTab.value;letpath='';if(tab==='todo'||tab==='done'){path=`/bpm/task/detail?id=${record.id}`;}elseif(tab==='myBill'){path=`/bpm/process-instance/detail?id=${record.id}`;}elseif(tab==='copy'){path=`/bpm/process-instance/detail?id=${record.processInstanceId}`;}if(path)router.push(path);}4.4 可配置 props
interfaceProps{maxRecordNum?:number;// 默认 10,控制表格最大行数}设计器 config JSON 可覆盖maxRecordNum,小磁贴显示 5 条,大磁贴显示 20 条。
五、应用中心:workbench-app-center.vue + vuedraggable
5.1 功能概述
应用中心磁贴展示用户「常用应用」图标墙,支持:
- 从 RBAC 权限菜单选择叶子节点添加
- vuedraggable拖拽排序
- 删除应用(Popconfirm)
- 排序持久化
updateUserAppSort
5.2 从权限菜单提取可选应用
constmenuOptions=computed(()=>{constmenus=accessStore.accessMenus||[];constoptions=[];constprocessMenus=(menuList:any[],parents:any[]=[])=>{for(constmenuofmenuList){constchildren=Array.isArray(menu.children)?menu.children:[];constisLeaf=children.length===0;if(isLeaf&&menu.path){constid=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);returnoptions;});设计要点:只取有
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>asyncfunctionhandleDragEnd(){isDragging.value=false;constsortList=appList.value.map((app,index)=>({id:app.id,sort:index+1,}));awaitupdateUserAppSort(sortList);message.success('排序已保存');}5.4 首次初始化
用户首次进入且无应用数据时,调用initUserApp()从系统默认应用模板初始化,避免空白磁贴。
| Props | 默认 | 说明 |
|---|---|---|
maxAppCount | 12 | 最大展示应用数 |
gridCols | 4 | 图标网格列数 |
enableDrag | true | 是否允许拖拽排序 |
六、index.vue 入口与预览模式
consthomePageInfo=ref<any>(null);asyncfunctionloadHomePage(){homePageInfo.value=awaitgetMyHomePage();}constpreviewPageId=computed(()=>{constpageId=route.query.preview;returnpageId?Number(pageId):null;});constcurrentPageId=computed(()=>{returnpreviewPageId.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 - 📍 登录后默认进入工作台首页
推荐体验流程
- 登录后观察默认工作台磁贴布局(欢迎 + 待办 + 应用中心)
- 点击待办 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-plus24 列栅格,管理员在设计器中拖拽/缩放磁贴,员工端只读渲染。
内置有哪些磁贴组件?
共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 支持一下!