HarmonyOS开发:企业OA应用——办公自动化
HarmonyOS开发:企业OA应用——办公自动化
📌 核心要点:企业OA不是简单地把网页搬到手机上——鸿蒙端OA要解决的是架构分层、数据隔离、安全合规和跨端协同,一套代码跑手机跑平板跑PC,这才是鸿蒙OA的正确打开方式。
背景与动机
你接到一个需求:给公司做一个OA App。你心想,不就是登录、审批、公告、考勤那点事吗?一周搞定。
结果刚开干就傻眼了——
老板说:手机上能用,平板上也要能用,最好PC端也能用。你一个App要适配三种屏幕。
安全说:企业数据不能明文存储,离线数据要加密,截屏要防,分享要管。你一个App要过安全审计。
运维说:不同部门看到的菜单不一样,不同职级能操作的功能不一样,领导要看全局报表,员工只看自己的。你一个App要管十几种角色。
IT说:接口要对接现有的ERP、HR、财务系统,数据格式各不相同,同步策略要考虑断网情况。你一个App要跟五六个系统打交道。
一个"简单"的OA App,背后是企业级架构设计、安全合规、数据隔离、多端适配、系统集成五大难题。哪个处理不好,上线就是事故。
鸿蒙做OA有个天然优势——一次开发多端部署。手机、平板、PC、甚至智慧屏,一套ArkTS代码搞定。但这个优势要发挥出来,架构设计必须从一开始就做对。
核心原理
企业OA的架构核心:分层解耦 + 数据隔离 + 权限驱动 + 跨端适配。
flowchart TB
subgraph 展示层
A[手机端<br/>紧凑布局]
B[平板端<br/>分栏布局]
C[PC端<br/>多栏布局]
end
subgraph 业务层
D[审批模块]
E[文档模块]
F[考勤模块]
G[通讯录模块]
H[会议模块]
end
subgraph 能力层
I[权限引擎]
J[数据同步]
K[安全加密]
L[消息推送]
end
subgraph 数据层
M[本地RDB]
N[分布式数据]
O[云端API]
P[文件缓存]
end
A --> D & E & F & G & H
B --> D & E & F & G & H
C --> D & E & F & G & H
D --> I & J & K & L
E --> I & J & K & L
F --> I & J & K & L
G --> I & J & K & L
H --> I & J & K & L
I --> M & N & O & P
J --> M & N & O & P
K --> M & N & O & P
L --> M & N & O & P
classDef ui fill:#1565C0,color:#fff,stroke:#0D47A1
classDef biz fill:#2E7D32,color:#fff,stroke:#1B5E20
classDef cap fill:#E65100,color:#fff,stroke:#BF360C
classDef data fill:#6A1B9A,color:#fff,stroke:#4A148C
class A,B,C ui
class D,E,F,G,H biz
class I,J,K,L cap
class M,N,O,P data
四层架构详解
展示层:根据设备类型自动切换布局。手机用Stack导航,平板用Split导航,PC用侧边栏+多面板。同一个页面组件,不同设备渲染不同的布局。
业务层:每个业务模块独立,模块之间通过事件总线通信,不直接依赖。审批模块挂了不影响考勤模块,文档模块升级不影响通讯录。
能力层:公共能力下沉。权限判断、数据同步、加密解密、消息推送,所有业务模块共用。改一次,全局生效。
数据层:本地RDB存离线数据,分布式数据跨设备同步,云端API对接后端,文件缓存管理附件。
数据隔离策略
企业OA最敏感的问题:数据隔离。销售看不到研发的文档,普通员工看不到薪资审批,实习生只能看公告不能看报表。
数据隔离分三层:
- 租户隔离:不同公司(多租户场景)的数据物理隔离,数据库都不在同一个
- 部门隔离:同一公司不同部门的数据逻辑隔离,通过权限过滤
- 角色隔离:同一部门不同角色的数据权限隔离,通过菜单+数据双重控制
技术选型
| 技术点 | 推荐方案 | 理由 |
|---|---|---|
| UI框架 | ArkUI声明式 | 一套代码多端部署 |
| 本地存储 | RDB关系数据库 | 结构化数据,支持复杂查询 |
| 跨设备同步 | 分布式数据对象 | 实时同步,自动冲突解决 |
| 网络请求 | @kit.NetworkKit | HTTP/HTTPS/WebSocket全支持 |
| 安全加密 | @kit.CryptoArchitectureKit | 国密算法+AES,过等保 |
| 消息推送 | Push Kit | 离线消息不丢失 |
| 文件管理 | @kit.CoreFileKit | 沙箱隔离,安全合规 |
代码实战
基础用法:OA应用架构搭建
先把骨架搭起来——分层架构、路由管理、权限控制。
// OaApp.ets - OA应用入口与架构管理
import { UIAbility, AbilityConstant, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
import { relationalStore } from '@kit.ArkData';
import { OaRouter } from '../router/OaRouter';
import { PermissionEngine } from '../security/PermissionEngine';
import { DataSyncManager } from '../sync/DataSyncManager';
export default class OaApp extends UIAbility {
// 全局数据库实例
private rdbStore: relationalStore.RdbStore | null = null;
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 1. 初始化数据库
this.initDatabase();
// 2. 初始化权限引擎
PermissionEngine.getInstance().init(this.context);
// 3. 初始化数据同步
DataSyncManager.getInstance().init(this.context);
// 4. 初始化路由
OaRouter.getInstance().init();
}
// 初始化本地数据库
private async initDatabase(): Promise<void> {
const config: relationalStore.StoreConfig = {
name: 'oa_database.db',
securityLevel: relationalStore.SecurityLevel.S3, // 企业级安全等级
encrypt: true, // 开启加密
};
try {
this.rdbStore = await relationalStore.getRdbStore(this.context, config);
// 创建核心表
await this.createTables();
console.info('[OaApp] 数据库初始化完成');
} catch (error) {
console.error(`[OaApp] 数据库初始化失败: ${JSON.stringify(error)}`);
}
}
// 创建核心数据表
private async createTables(): Promise<void> {
if (!this.rdbStore) return;
// 用户信息表
const createUserTable = `
CREATE TABLE IF NOT EXISTS user_info (
user_id TEXT PRIMARY KEY,
name TEXT NOT NULL,
dept_id TEXT NOT NULL,
role TEXT NOT NULL,
avatar_url TEXT,
phone TEXT,
email TEXT,
last_login INTEGER,
status INTEGER DEFAULT 1
)`;
// 审批记录表
const createApprovalTable = `
CREATE TABLE IF NOT EXISTS approval_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
approval_id TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
type TEXT NOT NULL,
applicant_id TEXT NOT NULL,
current_node TEXT,
status TEXT DEFAULT 'pending',
create_time INTEGER,
update_time INTEGER
)`;
// 公告表
const createNoticeTable = `
CREATE TABLE IF NOT EXISTS notice (
id INTEGER PRIMARY KEY AUTOINCREMENT,
notice_id TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
content TEXT,
publisher_id TEXT NOT NULL,
publish_time INTEGER,
is_read INTEGER DEFAULT 0,
priority TEXT DEFAULT 'normal'
)`;
try {
await this.rdbStore.executeSql(createUserTable);
await this.rdbStore.executeSql(createApprovalTable);
await this.rdbStore.executeSql(createNoticeTable);
console.info('[OaApp] 数据表创建完成');
} catch (error) {
console.error(`[OaApp] 建表失败: ${JSON.stringify(error)}`);
}
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/Index', (err) => {
if (err.code) {
console.error(`[OaApp] 加载首页失败: ${JSON.stringify(err)}`);
return;
}
console.info('[OaApp] 首页加载完成');
});
}
}
进阶用法:权限引擎与数据隔离
权限是OA的命脉。谁能看什么、能做什么,全靠权限引擎控制。
// PermissionEngine.ets - 权限引擎
import { common } from '@kit.AbilityKit';
import { relationalStore } from '@kit.ArkData';
import { emitter } from '@kit.BasicServicesKit';
// 权限类型
export enum PermissionType {
MENU = 'menu', // 菜单权限
DATA = 'data', // 数据权限
OPERATION = 'operation' // 操作权限
}
// 权限结果
export interface PermissionResult {
allowed: boolean;
reason?: string;
}
// 角色定义
export interface Role {
roleId: string;
roleName: string;
menuIds: string[]; // 可访问菜单
dataScope: string[]; // 数据范围(部门ID列表)
operations: string[]; // 可执行操作
}
export class PermissionEngine {
private static instance: PermissionEngine;
private context: common.UIAbilityContext | null = null;
private currentRole: Role | null = null;
private currentUserId: string = '';
private currentDeptId: string = '';
// 权限缓存,避免频繁查库
private permissionCache: Map<string, PermissionResult> = new Map();
private constructor() {}
static getInstance(): PermissionEngine {
if (!PermissionEngine.instance) {
PermissionEngine.instance = new PermissionEngine();
}
return PermissionEngine.instance;
}
init(context: common.UIAbilityContext): void {
this.context = context;
}
// 设置当前用户信息
setCurrentUser(userId: string, deptId: string, role: Role): void {
this.currentUserId = userId;
this.currentDeptId = deptId;
this.currentRole = role;
this.permissionCache.clear(); // 切换用户时清空缓存
console.info(`[Permission] 用户切换: ${userId}, 角色: ${role.roleName}`);
}
// 检查菜单权限
checkMenuPermission(menuId: string): PermissionResult {
const cacheKey = `menu_${menuId}`;
const cached = this.permissionCache.get(cacheKey);
if (cached) return cached;
if (!this.currentRole) {
return { allowed: false, reason: '未登录' };
}
const allowed = this.currentRole.menuIds.includes(menuId);
const result: PermissionResult = {
allowed,
reason: allowed ? undefined : `角色[${this.currentRole.roleName}]无权访问菜单[${menuId}]`
};
this.permissionCache.set(cacheKey, result);
return result;
}
// 检查数据权限——某个部门的数据当前用户能不能看
checkDataPermission(targetDeptId: string): PermissionResult {
if (!this.currentRole) {
return { allowed: false, reason: '未登录' };
}
// 自己部门的数据一定能看
if (targetDeptId === this.currentDeptId) {
return { allowed: true };
}
// 检查数据范围
const allowed = this.currentRole.dataScope.includes(targetDeptId);
return {
allowed,
reason: allowed ? undefined : `角色[${this.currentRole.roleName}]无权查看部门[${targetDeptId}]的数据`
};
}
// 检查操作权限
checkOperationPermission(operation: string): PermissionResult {
if (!this.currentRole) {
return { allowed: false, reason: '未登录' };
}
const allowed = this.currentRole.operations.includes(operation);
return {
allowed,
reason: allowed ? undefined : `角色[${this.currentRole.roleName}]无权执行操作[${operation}]`
};
}
// 过滤菜单列表——根据权限返回用户可见菜单
filterMenus(allMenus: MenuConfig[]): MenuConfig[] {
return allMenus.filter(menu => {
const result = this.checkMenuPermission(menu.id);
if (!result.allowed) {
console.info(`[Permission] 菜单已过滤: ${menu.id} - ${result.reason}`);
}
return result.allowed;
});
}
// 获取当前用户的数据可见部门列表
getVisibleDeptIds(): string[] {
if (!this.currentRole) return [];
return this.currentRole.dataScope;
}
}
// 菜单配置
export interface MenuConfig {
id: string;
title: string;
icon: Resource;
page: string;
children?: MenuConfig[];
}
完整示例:多端适配的OA首页
把架构、权限、多端适配串起来,做一个完整的OA首页。
// Index.ets - OA首页(多端适配)
import { PermissionEngine, MenuConfig } from '../security/PermissionEngine';
import { BreakpointType } from '@kit.ArkUI';
// 全部菜单配置——根据角色动态过滤
const ALL_MENUS: MenuConfig[] = [
{ id: 'approval', title: '审批中心', icon: $r('app.media.ic_approval'), page: 'pages/ApprovalPage' },
{ id: 'document', title: '文档管理', icon: $r('app.media.ic_document'), page: 'pages/DocumentPage' },
{ id: 'attendance', title: '移动考勤', icon: $r('app.media.ic_attendance'), page: 'pages/AttendancePage' },
{ id: 'contacts', title: '企业通讯录', icon: $r('app.media.ic_contacts'), page: 'pages/ContactsPage' },
{ id: 'meeting', title: '视频会议', icon: $r('app.media.ic_meeting'), page: 'pages/MeetingPage' },
{ id: 'email', title: '企业邮箱', icon: $r('app.media.ic_email'), page: 'pages/EmailPage' },
{ id: 'schedule', title: '日程管理', icon: $r('app.media.ic_schedule'), page: 'pages/SchedulePage' },
{ id: 'portal', title: '企业门户', icon: $r('app.media.ic_portal'), page: 'pages/PortalPage' },
{ id: 'report', title: '数据报表', icon: $r('app.media.ic_report'), page: 'pages/ReportPage' },
{ id: 'admin', title: '系统管理', icon: $r('app.media.ic_admin'), page: 'pages/AdminPage' },
];
@Entry
@Component
struct Index {
@State currentBreakpoint: string = 'sm';
@State visibleMenus: MenuConfig[] = [];
@State selectedIndex: number = 0;
@State userInfo: UserInfo = {
userId: '', name: '', deptName: '', avatarUrl: '', role: ''
};
// 侧边栏控制器(平板和PC端用)
private sideBarCtrl: SideBarContainerController = new SideBarContainerController();
aboutToAppear(): void {
// 根据权限过滤菜单
this.visibleMenus = PermissionEngine.getInstance().filterMenus(ALL_MENUS);
// 加载用户信息
this.loadUserInfo();
}
// 加载当前用户信息
private loadUserInfo(): void {
// 从本地数据库或缓存加载
this.userInfo = {
userId: 'u_001',
name: '张三',
deptName: '研发部',
avatarUrl: '',
role: '部门经理',
};
}
build() {
// 根据断点选择不同布局
GridRow({ columns: { sm: 4, md: 8, lg: 12 } }) {
GridCol({ span: { sm: 4, md: 8, lg: 12 } }) {
// 使用SideBarContainer实现多端适配
SideBarContainer(this.sideBarCtrl, SideBarContainerType.Embed) {
// 主内容区
this.MainContent()
}
.sideBarWidth(280)
.controlButton(this.currentBreakpoint === 'sm') // 手机端显示控制按钮
.showSideBar(this.currentBreakpoint !== 'sm') // 手机端默认隐藏侧边栏
.onChange((isShow: boolean) => {
console.info(`[OaApp] 侧边栏: ${isShow ? '展开' : '收起'}`);
})
// 侧边栏内容
.SideBarContent() {
this.SideBarPanel()
}
}
}
.onBreakpointChange((breakpoint: string) => {
this.currentBreakpoint = breakpoint;
console.info(`[OaApp] 断点变化: ${breakpoint}`);
})
}
// 侧边栏面板
@Builder
SideBarPanel() {
Column() {
// 用户信息区
Row() {
Image(this.userInfo.avatarUrl || $r('app.media.ic_avatar_default'))
.width(48)
.height(48)
.borderRadius(24)
.backgroundColor('#E0E0E0')
Column() {
Text(this.userInfo.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text(`${this.userInfo.deptName} · ${this.userInfo.role}`)
.fontSize(12)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
.margin({ left: 12 })
}
.width('100%')
.padding(16)
Divider().color('#F0F0F0')
// 菜单列表
List() {
ForEach(this.visibleMenus, (menu: MenuConfig, index: number) => {
ListItem() {
Row() {
Image(menu.icon)
.width(24)
.height(24)
.fillColor(this.selectedIndex === index ? '#1565C0' : '#666666')
Text(menu.title)
.fontSize(16)
.fontColor(this.selectedIndex === index ? '#1565C0' : '#333333')
.margin({ left: 16 })
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.borderRadius(8)
.backgroundColor(this.selectedIndex === index ? '#E3F2FD' : Color.Transparent)
.onClick(() => {
this.selectedIndex = index as number;
// 手机端点击后自动收起侧边栏
if (this.currentBreakpoint === 'sm') {
this.sideBarCtrl.changeSideBarStatus(false);
}
})
}
})
}
.layoutWeight(1)
.divider({ strokeWidth: 0.5, color: '#F0F0F0', startMargin: 56 })
}
.width('100%')
.height('100%')
.backgroundColor('#FAFAFA')
}
// 主内容区
@Builder
MainContent() {
Column() {
// 顶部导航栏
Row() {
if (this.currentBreakpoint === 'sm') {
// 手机端显示菜单按钮
Image($r('app.media.ic_menu'))
.width(24)
.height(24)
.fillColor('#333333')
.onClick(() => {
this.sideBarCtrl.changeSideBarStatus(true);
})
.margin({ right: 16 })
}
Text(this.visibleMenus[this.selectedIndex]?.title || '办公自动化')
.fontSize(20)
.fontWeight(FontWeight.Bold)
Blank()
// 消息通知
Badge({ count: 5, position: BadgePosition.RightTop }) {
Image($r('app.media.ic_notification'))
.width(24)
.height(24)
.fillColor('#666666')
}
.margin({ right: 16 })
// 搜索
Image($r('app.media.ic_search'))
.width(24)
.height(24)
.fillColor('#666666')
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
// 内容区域——根据选中菜单加载对应页面
// 这里简化为显示快捷入口网格
this.QuickEntryGrid()
}
.width('100%')
.height('100%')
.backgroundColor('#FFFFFF')
}
// 快捷入口网格
@Builder
QuickEntryGrid() {
Scroll() {
Column() {
// 待办事项概览
Row() {
this.TodoCard('待审批', 12, '#1565C0')
this.TodoCard('待阅读', 8, '#2E7D32')
this.TodoCard('待签到', 1, '#E65100')
}
.width('100%')
.justifyContent(FlexAlign.SpaceAround)
.margin({ top: 16, bottom: 24 })
// 公告区域
Text('最新公告')
.fontSize(18)
.fontWeight(FontWeight.Bold)
.width('100%')
.padding({ left: 16, bottom: 8 })
List() {
ForEach([1, 2, 3], (_item: number, index: number) => {
ListItem() {
Row() {
Column() {
Text(`关于${index === 0 ? '端午节放假' : index === 1 ? '系统升级维护' : '新员工培训'}的通知`)
.fontSize(15)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text('2026-06-25')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('未读')
.fontSize(12)
.fontColor('#1565C0')
.backgroundColor('#E3F2FD')
.padding({ left: 8, right: 8, top: 2, bottom: 2 })
.borderRadius(4)
}
.width('100%')
.padding(16)
}
})
}
.divider({ strokeWidth: 0.5, color: '#F0F0F0' })
}
.padding({ left: 16, right: 16 })
}
.layoutWeight(1)
}
// 待办卡片
@Builder
TodoCard(title: string, count: number, color: string) {
Column() {
Text(count.toString())
.fontSize(28)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text(title)
.fontSize(13)
.fontColor('#666666')
.margin({ top: 4 })
}
.width(100)
.height(80)
.justifyContent(FlexAlign.Center)
.borderRadius(12)
.backgroundColor(Color.White)
.shadow({ radius: 4, color: '#1A000000', offsetY: 2 })
}
}
// 用户信息
interface UserInfo {
userId: string;
name: string;
deptName: string;
avatarUrl: string;
role: string;
}
踩坑与注意事项
坑1:数据库加密别忘了密钥管理
RDB的encrypt: true只是开启了加密,密钥从哪来?硬编码在代码里?反编译一下就暴露了。密钥必须从服务端动态获取,或者用设备唯一标识派生。
更靠谱的方案:用@kit.CryptoArchitectureKit生成密钥,存在HUKS(通用密钥库)里,应用卸载后密钥自动销毁。
坑2:多端布局不是简单的if-else
很多人做多端适配就是if (isPhone) { ... } else { ... },这种写法维护成本极高。正确做法是用GridRow/GridCol的断点系统,配合@Builder拆分组件。布局逻辑交给框架,业务逻辑交给组件。
坑3:权限缓存要及时失效
权限引擎加了缓存是好事,但角色变更时缓存必须清掉。用户从"普通员工"升为"部门经理",如果缓存没清,他看到的还是普通员工的菜单——直到他杀掉App重进。
解决方案:服务端推送角色变更事件,客户端收到后清空缓存并刷新页面。
坑4:离线数据同步的冲突问题
OA应用断网时数据存在本地,连网后要同步到服务端。如果同一条数据在两端都改了,以谁的为准?
常见的冲突策略:
- 服务端优先:简单粗暴,客户端改的被服务端覆盖
- 时间戳优先:谁最后改的谁生效,但时钟不同步时不可靠
- 字段级合并:只合并修改的字段,最精确但实现最复杂
建议用"服务端优先 + 手动解决":自动以服务端为准,但冲突时弹窗让用户确认。
坑5:侧边栏在折叠屏上的适配
折叠屏展开时是平板布局,折叠时是手机布局。如果用固定的断点判断,折叠过程中布局会突变,体验很差。
解决方案:监听onBreakpointChange事件,加一个300ms的过渡动画,让布局变化不那么突兀。
坑6:安全合规不能只靠客户端
客户端做的所有安全措施(加密、防截屏、数据隔离)都是"防君子不防小人"。真正要过等保,服务端必须有对应的安全措施——接口鉴权、数据脱敏、操作审计、日志留存。客户端安全只是第一道防线,不是全部。
HarmonyOS 6适配说明
HarmonyOS 6对企业级应用做了多项增强:
- 企业设备管理(MDM)增强:新增设备策略引擎,可以远程配置应用黑名单、限制截屏、强制加密等策略。之前这些需要自研,现在系统原生支持。
// HarmonyOS 6 企业设备策略
import { enterpriseDeviceManagement } from '@kit.MDMKit';
// 检查截屏是否被策略禁止
async function checkScreenshotPolicy(): Promise<boolean> {
const admin = enterpriseDeviceManagement.getDeviceAdmin();
const policies = await admin.getPolicies();
return policies.disableScreenshot; // 返回true表示禁止截屏
}
-
安全沙箱增强:应用数据沙箱新增"企业容器"概念,企业数据和个人数据物理隔离。用户在"个人空间"和"工作空间"之间切换,数据互不可见。
-
分布式能力增强:OA应用可以在多个设备间无缝流转。你在手机上审批到一半,走到办公桌前,直接流转到PC上继续审批,状态完全保留。
-
窗口管理增强:PC端支持多窗口、自由缩放、分屏操作。OA应用需要适配
WindowStage的多窗口模式,不能只按单窗口设计。 -
网络代理支持:企业内网通常有代理服务器,HarmonyOS 6新增了全局代理配置能力,应用不需要自己处理代理逻辑。
总结
企业OA不是"做个App"那么简单。架构分层做不好,代码三个月就腐化;数据隔离做不好,安全事故随时来;权限引擎做不好,越权操作防不住;多端适配做不好,平板和PC就是摆设。
核心记住三点:
- 架构必须分层,展示层、业务层、能力层、数据层各司其职,互不干扰
- 权限必须贯穿始终,菜单权限、数据权限、操作权限三位一体,缺一不可
- 多端适配用框架能力,GridRow/GridCol断点系统比if-else靠谱一百倍
| 评估维度 | 说明 |
|---|---|
| 学习难度 | ⭐⭐⭐⭐ 架构设计需要经验,权限引擎需要业务理解 |
| 使用频率 | ⭐⭐⭐⭐⭐ 企业级应用的标配,几乎每个B端项目都需要 |
| 重要程度 | ⭐⭐⭐⭐⭐ 架构不对,后面全是补丁;架构对了,后面事半功倍 |
做OA,先想清楚架构,再动手写代码。否则写到最后你会发现——你不是在做OA,你是在给自己挖坑。
- 点赞
- 收藏
- 关注作者
评论(0)