简介一份基于Furion框架与Vue.js构建的OA协同办公系统开源项目面向需要快速搭建内部办公平台的企业团队也适合希望掌握C#后端与TypeScript前端整合技巧的全栈开发者。项目后端采用C#实现前端以Vue和TypeScript为主并使用View UI组件库完成界面覆盖用户、菜单、报销、会议、工作管理等常见办公模块整体模块化设计便于按业务需求灵活扩展与维护。整个源码包共334个文件大小3.73MB其中Vue文件136个、TypeScript文件99个、C#文件58个另有JSON配置、SCSS样式、Markdown文档及项目工程文件等目录分层清晰可快速定位到控制器、服务层、实体类与前端页面降低上手成本。已有503人学习下载既可作为中小企业办公自动化落地方案也可作为Furion框架与Vue前后端分离开发的实战参考。1. 从 Furion 与 Vue 看 OA 系统开源代码怎么选型做 OA 协同办公系统大部分人不是败在表单多、报表难而是权限模型和审批流从零开始写太费劲。Furion 这个国内开源的 .NET 框架把动态 API、认证授权、ORM、定时任务、状态机都收进了一套约定里前后端一结合OA 里最常见的用户、部门、角色、菜单、审批节点全都变成了一张张可复用的表。基于 Furion 与 Vue 的 OA 协同办公系统开源代码解决的正是「从 git clone 到流程跑通」这段路怎么走的问题。这篇文章按我实际交付 OA 项目的顺序展开先把 Furion 后端和 Vue 前端的工程骨架讲清楚再落地 RBAC 权限和动态路由然后用状态机把审批流转做实最后告诉你如何让一张普通业务表接进审批流以及打包部署时最容易踩的坑。适合准备用 C# 技术栈交付 OA 项目的人也适合把开源 OA 代码拿来做二次开发的同学。2. 初始化 Furion 后端与 Vue 前端约定、目录与最小可运行代码2.1 Furion 在 OA 这类管理系统里到底省了什么Furion 是基于 ASP.NET Core 的国产应用框架它对 OA 项目价值最大的三个能力是动态 WebAPI、内置认证授权和状态机组件。动态 WebAPI 意味着你在业务类上标一个特性方法就会自动暴露成 HTTP 接口不用再手写 Controller 和路由配置这对 CRUD 密集的 OA 系统来说能省掉大量样板代码。Furion 的认证授权默认集成 JWT 和多种权限校验策略菜单、按钮、接口三级权限都有现成封装。内置状态机组件则正好落在 OA 的核心难点——审批流上它把状态、事件、动作拆成声明式配置比自己在数据库里硬写 if else 判断要可靠得多。2.2 后端的创建与目录分层先用 CLI 创建解决方案再手动拆成四个工程这是我给 OA 项目用的标准分层dotnet new sln -n OaSuite dotnet new classlib -n OaSuite.Core dotnet new classlib -n OaSuite.Application dotnet new webapi -n OaSuite.Web dotnet sln add OaSuite.Core OaSuite.Application OaSuite.WebCore 层放实体、枚举、领域服务接口Application 层放业务实现、DTO、动态 API 类Web 层只放启动配置和中间件。如果要用 SqlSugar 或 EF Core单独建一个 OaSuite.EntityFramework 放 DbContext 和迁移文件。不要在 Core 层引用 Web 层这是能持续扩展的基本底线。在 OaSuite.Web 的 Program.cs 中Furion 的启动代码非常简单var builder WebApplication.CreateBuilder(args); // Furion 会自动扫描 Application 层的动态 API、服务注册和配置项 builder.Services.AddFurion(); var app builder.Build(); // 自动启用 CORS、认证、授权、规范化结果中间件 app.UseHttpsRedirection(); app.UseAuthentication(); app.UseAuthorization(); app.UseInject(); app.Run();AddFurion()会完成服务注册、动态 API 扫描、配置绑定、SQL 日志等初始化。UseInject()是 Furion 的规范化输出中间件它会把接口返回值统一包装成{ code, data, errors }结构前端不用自己做一层响应拦截适配。OA 系统接口多这个约定能让前后端联调省下大量沟通成本。2.3 Vue 3 前端的依赖与路由初始形态前端部分我直接用 Vite 创建 Vue 3 工程并安装 OA 需要的依赖npm create vitelatest oa-web -- --template vue cd oa-web npm install npm install element-plus pinia vue-router4 axioselement-plus提供表格、表单、树形控件这些 OA 高频组件pinia负责保存用户信息、token 和权限标识集合vue-router4用动态路由配合后端权限axios封装请求。这样的依赖组合已经能覆盖大多数 OA 业务场景。路由初始化时的注意事项是静态路由只注册登录页、404 和布局框架其余业务页面全部等登录后根据用户权限动态挂载。vue-router的动态路由不能放在路由表里直接写死否则每次刷新路由就丢失用户一按 F5 就跳回登录页。常见做法是登录后请求/api/auth/me拿菜单和权限再调用router.addRoute()注册。3. RBAC 权限模型Furion 动态 API 与 Vue 动态路由协同3.1 五张核心表的结构与关系OA 权限模型我建议用标准 RBAC 加一层数据范围控制表结构如下表名关键字段说明sys_userid, dept_id, username, password, status用户表关联部门sys_deptid, parent_id, name部门树用于数据范围过滤sys_roleid, name, code角色表code 用于代码判定sys_menuid, parent_id, title, path, component, perm菜单和按钮权限perm 形如oa:leave:addsys_user_roleuser_id, role_id用户角色关联在前端做动态菜单时sys_menu的path对应 vue-router 的路径component对应视图文件相对路径perm对应按钮唯一标识。后端做接口鉴权时不用关心path只校验登录用户的权限标识是否包含当前接口所需的perm。这个设计让菜单和接口权限共用同一张表减少了维护成本。3.2 Furion 里的动态 API 与权限校验在后端 Application 层创建用户服务Furion 会自动把方法暴露为 HTTP 接口[DynamicApiController] public class UserService : IDynamicApiController { private readonly SqlSugarRepositorySysUser _userRepo; public UserService(SqlSugarRepositorySysUser userRepo) { _userRepo userRepo; } /// summary /// 获取当前登录用户信息、角色标识与权限集合 /// /summary [AllowAnonymous] public async TaskLoginUserDto Me() { var userId App.User.FindFirst(uid)?.Value; var user await _userRepo.AsQueryable() .Includes(u u.Roles) .FirstAsync(u u.Id int.Parse(userId)); var perms await _userRepo.Context.QueryableSysMenu() .InnerJoinSysRoleMenu((m, rm) m.Id rm.MenuId) .InnerJoinSysUserRole((m, rm, ur) ur.RoleId rm.RoleId) .Where((m, rm, ur) ur.UserId user.Id m.Perm ! null) .Select((m, rm, ur) m.Perm) .Distinct() .ToListAsync(); return new LoginUserDto { UserId user.Id, UserName user.UserName, DeptId user.DeptId, Roles user.Roles.Select(r r.Code), Perms perms }; } }[DynamicApiController]让类下的公共方法自动路由为/api/user/me这类接口。[AllowAnonymous]仅用于登录后的用户信息查询因为此时 token 已在请求头里再要求接口权限会形成循环依赖。权限标识集合会返回到前端存进 Pinia按钮级显隐和路由级守卫都基于它。3.3 前端动态路由与按钮权限前端先封装 axios 实例在响应拦截器里做状态码处理和登录失效跳转// src/utils/request.js import axios from axios import { ElMessage } from element-plus import { useUserStore } from /store/user import router from /router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const userStore useUserStore() if (userStore.token) { config.headers[Authorization] Bearer ${userStore.token} } return config }) service.interceptors.response.use( response { const res response.data // Furion 约定格式code200 表示成功 if (res.code ! 200) { ElMessage.error(res.errors || 请求失败) return Promise.reject(new Error(res.errors)) } return res.data }, error { if (error.response?.status 401) { const userStore useUserStore() userStore.resetState() router.push(/login) } return Promise.reject(error) } ) export default service请求成功时直接返回res.data业务代码里就不需要每次再解包一层。401说明 token 失效清空用户状态并跳转登录页是最直接的处理方式。动态路由的权限过滤逻辑放在 Pinia 的 user store 里登录后先请求后端菜单与权限数据再生成可访问路由表// src/store/user.js import { defineStore } from pinia import { login, getMe, getMenus } from /api/auth import { generateRoutes } from /router/dynamic export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(oa_token) || , userInfo: {}, perms: [], menus: [], routes: [] }), actions: { async fetchMe() { const data await getMe() this.userInfo data this.perms data.perms return data }, async generateRoutes() { const menuData await getMenus() this.menus menuData const routes generateRoutes(menuData) this.routes routes routes.forEach(route router.addRoute(route)) } } })generateRoutes将后端菜单树映射成 vue-router 的 RouteRecordRaw。映射时有一个容易忽略的细节父级菜单的component应该指向布局组件子级菜单的component才指向具体业务页面没有子级却带component的菜单要视为单页直接注册。如果映射结果有遗漏登录后首屏会白屏且控制台没有明确报错排查时先打印映射后的 routes 数组看缺少的是外层还是内层。按钮级权限用自定义指令实现最直接// src/directives/permission.js import { useUserStore } from /store/user const permission { mounted(el, binding) { const required binding.value const userStore useUserStore() if (!userStore.perms.includes(required)) { el.parentNode?.removeChild(el) } } } export default permission在模板中使用el-button v-permissionoa:leave:add新增请假/el-button指令的值取后端返回的权限标识。用户没有权限标识时直接从 DOM 中移除按钮服务端接口依旧需要同样的权限校验前端隐藏只能算体验增强不能当作安全防线。4. 审批状态机从草稿到归档的全过程设计4.1 为什么 OA 审批不能用简单的状态字段很多自研 OA 系统在审批表上放一个Status字段用 0、1、2、3 表示待提交、审批中、通过、驳回。功能少的时候够用一旦出现「部门主管审批通过后转人事审批」「会签需要多人都通过才算过」「发起人撤回后重新提交」这些场景状态数量会爆炸式增长而且状态迁移路径完全不可控谁都能把一条已归档的记录改回审批中。状态机的思路是把状态和动作拆开状态是节点动作是边一个动作只能从指定状态迁移到目标状态。这样非法流转在配置层就被拦截业务代码里也看不到散落的 if else。Furion 内置的StateMachine组件正是为这种场景设计的它可以集中管理 OA 里所有流程的状态定义。4.2 审批状态机的表设计审批一般涉及三类数据流程实例一次请假申请、流程节点设计好的审批步骤、流转记录每一步的操作结果。表结构如下表名关键字段说明oa_process_definitionid, code, name, form_schema, node_json流程定义node_json 存节点配置oa_process_instanceid, process_code, business_id, current_node_id, status, create_by流程实例关联业务表单oa_process_nodeid, process_def_id, node_type, approver_type, approver_value节点配置审批人类型与取值oa_process_recordid, instance_id, node_id, action, comment, operator_id, create_time流转记录形成操作历史node_type常见取值有approve、cc、conditionapprover_type可以是user、role、dept_manager、form_user四类。form_user表示从表单字段里读取提交人、部门负责人等动态值这是 OA 常用的人员变量能力。business_id就是业务表单主键用流程与业务解耦一张请假表、一张报销表共用一套流程引擎。4.3 用 Furion 状态机实现核心流转Furion 的状态机配置采用声明式写法先定义状态集合再配置每个动作的迁移规则public class ApprovalStateMachine { public const string Draft 草稿; public const string Pending 审批中; public const string Approved 已通过; public const string Rejected 已驳回; public const string Withdrawn 已撤回; public const string Archived 已归档; public void Configure(IStateMachine machine) { machine.Configure(Draft) .Permit(Submit, Pending) .Permit(Withdraw, Withdrawn); machine.Configure(Pending) .Permit(Approve, Approved) .Permit(Reject, Rejected) .Permit(Withdraw, Withdrawn) .Permit(Transfer, Pending); machine.Configure(Approved) .Permit(Archive, Archived); machine.Configure(Rejected) .Permit(Submit, Pending) .Permit(Withdraw, Withdrawn); } }这个配置表达了几条关键规则草稿只能提交或撤回审批中可以同意或驳回也可以转办或撤回驳回后允许重新提交已归档是终态不允许再迁移。Transfer动作代表节点转办给其他人状态不变但审批人变更这属于业务层面的人选替换不影响状态机本身。审批动作统一走一个应用服务处理完状态迁移后写流转记录public class ProcessInstanceService : IDynamicApiController { private readonly IStateMachine _stateMachine; public ProcessInstanceService(IStateMachine stateMachine) { _stateMachine stateMachine; } public async Task Approve(long instanceId, string comment) { var instance await _instanceRepo.FirstAsync(i i.Id instanceId); if (instance null) { throw Oops.Bah(流程实例不存在); } var machine _stateMachine.GetMachine(instance.ProcessCode); machine.Fire(Approve, instance); var nextNode GetNextNode(instance.ProcessCode, instance.CurrentNodeId); if (nextNode ! null) { instance.CurrentNodeId nextNode.Id; instance.Status ApprovalStateMachine.Pending; } else { instance.Status ApprovalStateMachine.Approved; } await _recordRepo.InsertAsync(new OaProcessRecord { InstanceId instanceId, NodeId instance.CurrentNodeId, Action Approve, Comment comment, OperatorId App.User.FindFirst(uid)?.Value }); await _instanceRepo.UpdateAsync(instance); } }machine.Fire(Approve, instance)会先校验当前状态是否允许 Approve 动作不允许就直接抛异常省去了手写状态判断。审批通过后需要判断当前节点是否为最后一个节点还有后继节点就更新当前节点并保持Pending没有后继节点就把整个实例置为Approved。这一判断基于oa_process_node里的排序值不要在代码里写死节点数量。4.4 会签、或签与驳回后重新提交会签和或签在节点配置上加一个approval_mode字段取值为all或any。后端的处理逻辑是any模式节点下任何一个审批人执行 Approve该节点直接通过。all模式节点下所有审批人都 Approve 后节点才通过其中任一审批人 Reject整个流程直接驳回。实现时给oa_process_record增加node_status字段记录每个审批人在当前节点的状态。all模式下每次 Approve 都检查是否全部通过通过后再推进到下一节点。驳回后重新提交时流程回到上一个审批节点还是重新走一遍完整流程取决于流程定义里的reject_type字段OA 系统通常提供「驳回到发起人」和「驳回到上一节点」两种策略前者简单直接后者更贴近线下审批习惯。5. 让业务表单跑进审批领域模型适配与部署常见问题修正5.1 业务表单与审批流的适配接口新业务要接进审批流时最忌讳在业务 Service 里直接操作流程实例表。我在工程里定义了一个统一的适配接口所有需要审批的业务表都实现它public interface IApprovalBusiness { string ProcessCode { get; } long BusinessId { get; } string BusinessType { get; } Taskobject GetDetailAsync(); Task ChangeStatusAsync(string approvalStatus); }请假表LeaveRequest实现这个接口提交审批时统一走一个入口public class LeaveRequestService : IDynamicApiController { private readonly IProcessEngine _processEngine; public async Task Submit(long leaveId) { var leave await _leaveRepo.FirstAsync(l l.Id leaveId); if (leave null) throw Oops.Bah(请假记录不存在); leave.Status 审批中; await _leaveRepo.UpdateAsync(leave); await _processEngine.StartAsync(new ProcessStartRequest { ProcessCode leave, BusinessId leave.Id, BusinessType LeaveRequest, Title ${leave.ApplicantName} 的请假申请, FormData new { leave.StartDate, leave.EndDate, leave.Reason } }); } }启动流程时把表单关键字段塞进FormData审批页在待办列表里直接渲染这些字段不需要反向查业务表。business_id用来在审批通过后回调具体的业务实现类更新状态。这套适配方式让引擎层完全感知不到具体业务逻辑新增审批业务时只需实现接口并在注册表中登记类型映射即可。5.2 Vue 打包后布局异常的常见修正Vue 工程npm run build后部署到 Nginx 子目录时经常出现样式丢失、路由刷新 404、接口路径不对三类问题。样式丢失优先检查vite.config.js里的base配置如果部署在域名子路径必须设置base: /oa-web/。路由刷新 404 是因为 vue-router 使用了 HTML5 history 模式Nginx 需要把所有未命中的路径转发到 index.htmlserver { listen 80; root /var/www/oa-web; index index.html; location / { try_files $uri $uri/ /index.html; } }在部署目录之外单独放置后端站点将/api路径转交给后端端口。前端请求统一走/api前缀Nginx 上按前缀区分前端与后端即可不需要改动 Vue 里的接口地址。构建后如果发现index.html引用的 JS 路径是绝对路径多半就是base配置问题打开浏览器开发者工具看 Network 里失败请求的 URL 就能快速定位。5.3 上线前建议做的三项检查第一审核sys_menu表里每个perm标识是否与后端接口权限点一一对应空掉的标识会导致按钮显示但接口报 403。第二在审批流的Approved动作里加一个手动归档操作批量将超过 90 天且状态终态的实例移动到历史表避免oa_process_record单表膨胀拖慢待办查询。第三在开发环境开启 Furion 的 SQL 日志截取请假列表和待办列表生成的 SQL确认关联查询没有被拆成 N 次单表查询特别是部门树节点多时树形查询要使用递归 CTE 而非逐层查库。把IApprovalBusiness接口和动态路由这两块做好你会发现开源 OA 代码不只是改改页面而是在把权限、流程、菜单这三条主线牢牢放进同一个骨架里。后续接新模块时照着请假这个例子复制一套就能保持整个系统在结构上的一致性。本文还有配套的精品资源点击获取