HarmonyOS开发:文档管理——文档协作

举报
Jack20 发表于 2026/06/26 16:48:00 2026/06/26
【摘要】 HarmonyOS开发:文档管理——文档协作📌 核心要点:文档协作不是简单的"上传下载"——在线预览要跨格式、多人编辑要防冲突、版本管理要可追溯,三个难题一个比一个棘手。 背景与动机你打开OA系统,想看一份项目方案文档。点开——下载中…下载完成…打开失败,手机上没有对应的软件。换一种格式?PDF可以预览,但你想改几个字怎么办?你改完文档保存了,同事说他也在改同一份文档,他保存后你的修改全...

HarmonyOS开发:文档管理——文档协作

📌 核心要点:文档协作不是简单的"上传下载"——在线预览要跨格式、多人编辑要防冲突、版本管理要可追溯,三个难题一个比一个棘手。

背景与动机

你打开OA系统,想看一份项目方案文档。点开——下载中…下载完成…打开失败,手机上没有对应的软件。换一种格式?PDF可以预览,但你想改几个字怎么办?

你改完文档保存了,同事说他也在改同一份文档,他保存后你的修改全没了。你找IT,IT说"你们不能同时编辑同一个文档"。那多人协作怎么搞?

你记得上周改过一个关键数据,但现在文档里不是那个数了。谁改的?什么时候改的?改之前是什么?你翻遍了历史记录也找不到。

文档管理的三个核心难题:

  1. 在线预览:Word、Excel、PPT、PDF、图片,格式五花八门,手机上怎么预览?
  2. 多人协作:两个人同时编辑,怎么保证不冲突?冲突了怎么合并?
  3. 版本管理:改了什么、谁改的、什么时候改的,怎么追溯?

鸿蒙做文档管理有个天然优势——分布式能力。手机上预览,平板上编辑,PC上排版,文档在设备间无缝流转。但这个优势要发挥出来,协作和版本管理必须做好。

核心原理

文档协作的架构核心:云端存储 + 本地缓存 + 操作转换(OT) + 版本快照

flowchart TD
    A[用户A打开文档] --> B[加载云端最新版本]
    C[用户B打开文档] --> B
    
    B --> D[本地缓存副本]
    B --> E[本地缓存副本]
    
    D --> F[用户A编辑]
    E --> G[用户B编辑]
    
    F --> H[操作日志记录]
    G --> I[操作日志记录]
    
    H --> J[操作转换OT引擎]
    I --> J
    
    J --> K{冲突检测}
    K -->|无冲突| L[合并操作]
    K -->|有冲突| M[冲突解决策略]
    
    M --> N{自动合并?}
    N -->|可以| L
    N -->|不行| O[手动解决冲突]
    
    O --> P[用户选择保留版本]
    P --> L
    
    L --> Q[生成新版本快照]
    Q --> R[同步到云端]
    R --> S[通知其他协作者]
    S --> D
    S --> E
    
    classDef user fill:#1565C0,color:#fff,stroke:#0D47A1
    classDef local fill:#2E7D32,color:#fff,stroke:#1B5E20
    classDef engine fill:#E65100,color:#fff,stroke:#BF360C
    classDef conflict fill:#C62828,color:#fff,stroke:#B71C1C
    classDef sync fill:#6A1B9A,color:#fff,stroke:#4A148C
    
    class A,C,F,G user
    class D,E,H,I local
    class J,K,L engine
    class M,N,O,P conflict
    class Q,R,S sync

文档预览方案

鸿蒙端文档预览有三种方案:

方案 原理 优点 缺点
服务端转换 服务端把Word/Excel转成PDF或图片,客户端只渲染图片 兼容性最好 服务器压力大,转换慢
原生渲染 客户端直接解析文档格式并渲染 不依赖服务端 格式兼容性差,开发量大
WebView嵌入 用WebView加载在线文档编辑器 功能最全 体验差,离线不可用

推荐组合:服务端转换PDF预览 + 原生渲染图片/文本 + WebView嵌入编辑。预览走轻量方案,编辑走WebView。

操作转换(OT)算法

多人协作的核心算法——操作转换(Operational Transformation)。

假设用户A和用户B同时编辑同一段文字:

原文: "Hello World"

用户A: 在位置5插入" Dear""Hello Dear World"
用户B: 在位置5插入" Beautiful""Hello Beautiful World"

如果先执行A的操作再执行B的,B的插入位置应该变成5+5=10,结果是"Hello Dear Beautiful World"。

如果先执行B的操作再执行A的,A的插入位置应该变成5+10=15,结果是"Hello Beautiful Dear World"。

两种结果不一样!OT算法就是解决这个问题的——根据已执行的操作,转换后续操作的位置,保证最终结果一致。

版本管理策略

文档每次保存产生一个版本快照。但不可能每次保存都存完整文档——太浪费空间。

增量存储:只存与上一版本的差异(diff)。恢复时从基础版本开始,逐步应用差异。

版本快照策略:

  • 每次保存记录操作日志
  • 每10次保存生成一个完整快照
  • 恢复时找最近的快照,再应用差异

代码实战

基础用法:文档在线预览

先实现文档预览——支持PDF、图片、纯文本的本地预览。

// DocPreviewManager.ets - 文档预览管理
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';

// 文档类型
export enum DocType {
  PDF = 'pdf',
  IMAGE = 'image',
  TEXT = 'text',
  WORD = 'word',
  EXCEL = 'excel',
  PPT = 'ppt',
  UNKNOWN = 'unknown',
}

// 文档信息
export interface DocInfo {
  docId: string;          // 文档ID
  name: string;           // 文件名
  type: DocType;          // 文档类型
  size: number;           // 文件大小(字节)
  url: string;            // 下载URL
  localPath: string;      // 本地缓存路径
  version: number;        // 版本号
  lastModified: number;   // 最后修改时间
  thumbnailUrl?: string;  // 缩略图URL
}

export class DocPreviewManager {
  private static instance: DocPreviewManager;
  private context: common.UIAbilityContext | null = null;
  private cacheDir: string = '';

  private constructor() {}

  static getInstance(): DocPreviewManager {
    if (!DocPreviewManager.instance) {
      DocPreviewManager.instance = new DocPreviewManager();
    }
    return DocPreviewManager.instance;
  }

  init(context: common.UIAbilityContext): void {
    this.context = context;
    this.cacheDir = context.cacheDir + '/doc_preview';
    // 确保缓存目录存在
    if (!fs.accessSync(this.cacheDir)) {
      fs.mkdirSync(this.cacheDir);
    }
    console.info('[DocPreview] 初始化完成');
  }

  // 根据文件扩展名判断文档类型
  getDocType(fileName: string): DocType {
    const ext = fileName.split('.').pop()?.toLowerCase() || '';
    const typeMap: Record<string, DocType> = {
      'pdf': DocType.PDF,
      'jpg': DocType.IMAGE, 'jpeg': DocType.IMAGE, 'png': DocType.IMAGE,
      'gif': DocType.IMAGE, 'webp': DocType.IMAGE, 'bmp': DocType.IMAGE,
      'txt': DocType.TEXT, 'md': DocType.TEXT, 'json': DocType.TEXT,
      'doc': DocType.WORD, 'docx': DocType.WORD,
      'xls': DocType.EXCEL, 'xlsx': DocType.EXCEL,
      'ppt': DocType.PPT, 'pptx': DocType.PPT,
    };
    return typeMap[ext] || DocType.UNKNOWN;
  }

  // 检查文档是否已缓存到本地
  isCached(docId: string): boolean {
    const cachedPath = `${this.cacheDir}/${docId}`;
    return fs.accessSync(cachedPath);
  }

  // 获取本地缓存路径
  getCachedPath(docId: string, fileName: string): string {
    return `${this.cacheDir}/${docId}_${fileName}`;
  }

  // 下载文档到本地缓存
  async downloadDoc(docInfo: DocInfo, onProgress?: (percent: number) => void): Promise<string | null> {
    const localPath = this.getCachedPath(docInfo.docId, docInfo.name);

    // 已缓存则直接返回
    if (fs.accessSync(localPath)) {
      console.info(`[DocPreview] 使用缓存: ${localPath}`);
      return localPath;
    }

    try {
      // 使用HTTP下载文档
      const httpRequest = await import('@kit.NetworkKit').then(m => m.http.createHttp());
      const response = await httpRequest.requestInStream(docInfo.url, {
        method: 'GET',
        expectDataType: 'arraybuffer',
      });

      // 写入本地文件
      const file = fs.openSync(localPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      fs.writeSync(file.fd, response.result as ArrayBuffer);
      fs.closeSync(file);

      console.info(`[DocPreview] 下载完成: ${localPath}`);
      return localPath;
    } catch (error) {
      console.error(`[DocPreview] 下载失败: ${JSON.stringify(error)}`);
      return null;
    }
  }

  // 读取文本文件内容
  readTextContent(filePath: string): string {
    try {
      return fs.readTextSync(filePath, { encoding: 'utf-8' });
    } catch (error) {
      console.error(`[DocPreview] 读取文本失败: ${JSON.stringify(error)}`);
      return '';
    }
  }

  // 清理缓存
  clearCache(): void {
    try {
      if (fs.accessSync(this.cacheDir)) {
        // 删除缓存目录下的所有文件
        const files = fs.listFileSync(this.cacheDir);
        for (const file of files) {
          fs.unlinkSync(`${this.cacheDir}/${file}`);
        }
        console.info('[DocPreview] 缓存已清理');
      }
    } catch (error) {
      console.error(`[DocPreview] 清理缓存失败: ${JSON.stringify(error)}`);
    }
  }
}

进阶用法:多人协作与冲突处理

多人协作的核心——操作日志、冲突检测、自动合并。

// DocCollaboration.ets - 文档协作引擎
import { relationalStore } from '@kit.ArkData';
import { emitter } from '@kit.BasicServicesKit';

// 操作类型
export enum OperationType {
  INSERT = 'insert',   // 插入文本
  DELETE = 'delete',   // 删除文本
  REPLACE = 'replace', // 替换文本
  FORMAT = 'format',   // 格式变更(加粗、斜体等)
}

// 操作记录
export interface DocOperation {
  opId: string;             // 操作ID
  docId: string;            // 文档ID
  userId: string;           // 操作人ID
  opType: OperationType;    // 操作类型
  position: number;         // 操作位置(字符偏移量)
  content: string;          // 操作内容
  oldContent?: string;      // 被替换的旧内容(用于撤销)
  timestamp: number;        // 操作时间
  baseVersion: number;      // 基于哪个版本
}

// 冲突类型
export enum ConflictType {
  POSITION_CONFLICT = 'position',   // 位置冲突:两人修改了相邻区域
  CONTENT_CONFLICT = 'content',     // 内容冲突:两人修改了同一区域
  DELETE_CONFLICT = 'delete',       // 删除冲突:一人删除另一人正在编辑的区域
}

// 冲突记录
export interface ConflictRecord {
  conflictId: string;
  docId: string;
  localOp: DocOperation;     // 本地操作
  remoteOp: DocOperation;    // 远程操作
  conflictType: ConflictType;
  resolution?: 'local' | 'remote' | 'merge'; // 解决方式
}

// 版本快照
export interface DocVersion {
  versionId: string;
  docId: string;
  version: number;
  content: string;           // 完整文档内容
  checksum: string;          // 内容校验和
  creator: string;           // 创建者
  timestamp: number;         // 创建时间
  operations: DocOperation[]; // 从上一版本到本版本的操作列表
}

export class DocCollaboration {
  private static instance: DocCollaboration;
  // 操作日志缓冲区
  private operationBuffer: Map<string, DocOperation[]> = new Map();
  // 版本历史
  private versionHistory: Map<string, DocVersion[]> = new Map();
  // 当前文档内容
  private docContents: Map<string, string> = new Map();
  // 协作者列表
  private collaborators: Map<string, Set<string>> = new Map();

  private constructor() {}

  static getInstance(): DocCollaboration {
    if (!DocCollaboration.instance) {
      DocCollaboration.instance = new DocCollaboration();
    }
    return DocCollaboration.instance;
  }

  // 打开文档进行协作
  openDoc(docId: string, userId: string, initialContent: string): void {
    this.docContents.set(docId, initialContent);
    if (!this.collaborators.has(docId)) {
      this.collaborators.set(docId, new Set());
    }
    this.collaborators.get(docId)?.add(userId);
    console.info(`[DocCollab] 用户${userId}加入文档${docId}协作`);
  }

  // 离开文档协作
  leaveDoc(docId: string, userId: string): void {
    this.collaborators.get(docId)?.delete(userId);
    console.info(`[DocCollab] 用户${userId}离开文档${docId}协作`);
  }

  // 应用本地操作
  applyLocalOperation(op: DocOperation): void {
    const content = this.docContents.get(op.docId) || '';
    let newContent = '';

    switch (op.opType) {
      case OperationType.INSERT:
        // 在指定位置插入内容
        newContent = content.slice(0, op.position) + op.content + content.slice(op.position);
        break;

      case OperationType.DELETE:
        // 删除指定位置的内容
        const deleteLen = op.content.length;
        newContent = content.slice(0, op.position) + content.slice(op.position + deleteLen);
        break;

      case OperationType.REPLACE:
        // 替换指定位置的内容
        newContent = content.slice(0, op.position) + op.content + content.slice(op.position + (op.oldContent?.length || 0));
        break;

      default:
        newContent = content;
    }

    this.docContents.set(op.docId, newContent);

    // 记录操作到缓冲区
    if (!this.operationBuffer.has(op.docId)) {
      this.operationBuffer.set(op.docId, []);
    }
    this.operationBuffer.get(op.docId)?.push(op);

    // 通知其他协作者
    emitter.emit({ eventId: 2001 }, {
      data: { docId: op.docId, opId: op.opId, userId: op.userId }
    });
  }

  // 接收远程操作——需要做操作转换
  applyRemoteOperation(remoteOp: DocOperation): ConflictRecord | null {
    const localOps = this.operationBuffer.get(remoteOp.docId) || [];
    // 找出与远程操作并发的本地操作(基于同一版本的操作)
    const concurrentOps = localOps.filter(op =>
      op.baseVersion === remoteOp.baseVersion && op.userId !== remoteOp.userId
    );

    if (concurrentOps.length === 0) {
      // 没有并发操作,直接应用
      this.transformAndApply(remoteOp, []);
      return null;
    }

    // 有并发操作,检测冲突
    for (const localOp of concurrentOps) {
      const conflict = this.detectConflict(localOp, remoteOp);
      if (conflict) {
        console.warn(`[DocCollab] 检测到冲突: ${conflict.conflictType}`);
        return conflict;
      }
    }

    // 无冲突,转换后应用
    this.transformAndApply(remoteOp, concurrentOps);
    return null;
  }

  // 检测两个操作是否冲突
  private detectConflict(localOp: DocOperation, remoteOp: DocOperation): ConflictRecord | null {
    // 位置不重叠,不冲突
    const localEnd = localOp.position + (localOp.content?.length || 0);
    const remoteEnd = remoteOp.position + (remoteOp.content?.length || 0);

    if (localOp.position >= remoteEnd || remoteOp.position >= localEnd) {
      return null; // 操作区域不重叠
    }

    // 操作区域重叠,判断冲突类型
    let conflictType = ConflictType.CONTENT_CONFLICT;
    if (localOp.opType === OperationType.DELETE || remoteOp.opType === OperationType.DELETE) {
      conflictType = ConflictType.DELETE_CONFLICT;
    } else if (Math.abs(localOp.position - remoteOp.position) < 5) {
      conflictType = ConflictType.POSITION_CONFLICT;
    }

    return {
      conflictId: `conflict_${Date.now()}`,
      docId: localOp.docId,
      localOp,
      remoteOp,
      conflictType,
    };
  }

  // 操作转换并应用
  private transformAndApply(op: DocOperation, concurrentOps: DocOperation[]): void {
    let transformedOp = { ...op };

    // 根据已执行的并发操作,调整当前操作的位置
    for (const executedOp of concurrentOps) {
      if (executedOp.opType === OperationType.INSERT && executedOp.position <= transformedOp.position) {
        // 前面有插入,位置后移
        transformedOp.position += executedOp.content.length;
      } else if (executedOp.opType === OperationType.DELETE && executedOp.position < transformedOp.position) {
        // 前面有删除,位置前移
        transformedOp.position -= executedOp.content.length;
      }
    }

    // 应用转换后的操作
    this.applyLocalOperation(transformedOp);
  }

  // 解决冲突
  resolveConflict(conflict: ConflictRecord, resolution: 'local' | 'remote' | 'merge'): void {
    conflict.resolution = resolution;

    switch (resolution) {
      case 'local':
        // 保留本地版本,丢弃远程操作
        console.info('[DocCollab] 冲突解决:保留本地版本');
        break;
      case 'remote':
        // 使用远程版本,覆盖本地操作
        this.applyLocalOperation(conflict.remoteOp);
        console.info('[DocCollab] 冲突解决:使用远程版本');
        break;
      case 'merge':
        // 尝试合并——实际场景中需要更智能的合并策略
        console.info('[DocCollab] 冲突解决:尝试合并');
        break;
    }
  }

  // 保存版本快照
  saveVersion(docId: string, userId: string): DocVersion {
    const content = this.docContents.get(docId) || '';
    const versions = this.versionHistory.get(docId) || [];
    const lastVersion = versions.length > 0 ? versions[versions.length - 1] : null;
    const newVersionNum = (lastVersion?.version || 0) + 1;

    const version: DocVersion = {
      versionId: `v_${docId}_${newVersionNum}`,
      docId,
      version: newVersionNum,
      content,
      checksum: this.calculateChecksum(content),
      creator: userId,
      timestamp: Date.now(),
      operations: this.operationBuffer.get(docId) || [],
    };

    versions.push(version);
    this.versionHistory.set(docId, versions);
    // 清空操作缓冲区
    this.operationBuffer.set(docId, []);

    console.info(`[DocCollab] 版本${newVersionNum}已保存`);
    return version;
  }

  // 获取版本历史
  getVersionHistory(docId: string): DocVersion[] {
    return this.versionHistory.get(docId) || [];
  }

  // 回滚到指定版本
  rollbackToVersion(docId: string, targetVersion: number): boolean {
    const versions = this.versionHistory.get(docId) || [];
    const target = versions.find(v => v.version === targetVersion);
    if (!target) {
      console.error(`[DocCollab] 版本${targetVersion}不存在`);
      return false;
    }

    this.docContents.set(docId, target.content);
    console.info(`[DocCollab] 已回滚到版本${targetVersion}`);
    return true;
  }

  // 计算内容校验和(简化实现)
  private calculateChecksum(content: string): string {
    let hash = 0;
    for (let i = 0; i < content.length; i++) {
      const char = content.charCodeAt(i);
      hash = ((hash << 5) - hash) + char;
      hash = hash & hash; // 转为32位整数
    }
    return hash.toString(16);
  }

  // 获取当前文档内容
  getDocContent(docId: string): string {
    return this.docContents.get(docId) || '';
  }
}

完整示例:文档管理页面

把预览、协作、版本管理串起来,做一个完整的文档管理页面。

// DocManagementPage.ets - 文档管理页面
import { DocPreviewManager, DocInfo, DocType } from '../doc/DocPreviewManager';
import { DocCollaboration, DocVersion, OperationType } from '../doc/DocCollaboration';

@Entry
@Component
struct DocManagementPage {
  @State docList: DocInfo[] = [];
  @State selectedDoc: DocInfo | null = null;
  @State docContent: string = '';
  @State versionList: DocVersion[] = [];
  @State showVersionPanel: boolean = false;
  @State isEditing: boolean = false;
  @State searchText: string = '';
  @State viewMode: 'grid' | 'list' = 'list';

  private previewManager: DocPreviewManager = DocPreviewManager.getInstance();
  private collaboration: DocCollaboration = DocCollaboration.getInstance();

  aboutToAppear(): void {
    this.loadDocList();
  }

  // 加载文档列表
  private loadDocList(): void {
    // 模拟数据
    this.docList = [
      { docId: 'doc_001', name: '项目方案.docx', type: DocType.WORD, size: 1024000, url: '', localPath: '', version: 3, lastModified: Date.now() - 86400000, thumbnailUrl: '' },
      { docId: 'doc_002', name: '财务报表.xlsx', type: DocType.EXCEL, size: 512000, url: '', localPath: '', version: 1, lastModified: Date.now() - 172800000, thumbnailUrl: '' },
      { docId: 'doc_003', name: '会议纪要.txt', type: DocType.TEXT, size: 2048, url: '', localPath: '', version: 5, lastModified: Date.now() - 3600000, thumbnailUrl: '' },
      { docId: 'doc_004', name: '产品截图.png', type: DocType.IMAGE, size: 307200, url: '', localPath: '', version: 1, lastModified: Date.now() - 7200000, thumbnailUrl: '' },
      { docId: 'doc_005', name: '技术方案.pdf', type: DocType.PDF, size: 2048000, url: '', localPath: '', version: 2, lastModified: Date.now() - 43200000, thumbnailUrl: '' },
    ];
  }

  build() {
    Column() {
      // 顶部搜索栏
      this.SearchBar()

      // 文档列表
      if (this.selectedDoc) {
        // 文档详情/预览模式
        this.DocDetailView()
      } else {
        // 文档列表模式
        this.DocListView()
      }
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }

  // 搜索栏
  @Builder
  SearchBar() {
    Row() {
      Search({ placeholder: '搜索文档...' })
        .width('70%')
        .height(40)
        .onChange((value: string) => {
          this.searchText = value;
        })

      // 视图切换
      Row() {
        Image(this.viewMode === 'list' ? $r('app.media.ic_list_selected') : $r('app.media.ic_list'))
          .width(24).height(24)
          .onClick(() => { this.viewMode = 'list'; })
          .margin({ right: 8 })

        Image(this.viewMode === 'grid' ? $r('app.media.ic_grid_selected') : $r('app.media.ic_grid'))
          .width(24).height(24)
          .onClick(() => { this.viewMode = 'grid'; })
      }

      // 新建文档
      Image($r('app.media.ic_add'))
        .width(28).height(28)
        .fillColor('#1565C0')
        .margin({ left: 12 })
    }
    .width('100%')
    .padding({ left: 16, right: 16, top: 8, bottom: 8 })
    .backgroundColor(Color.White)
  }

  // 文档列表
  @Builder
  DocListView() {
    List({ space: 8 }) {
      ForEach(this.docList, (doc: DocInfo) => {
        ListItem() {
          this.DocListItem(doc)
        }
      })
    }
    .padding(16)
    .layoutWeight(1)
  }

  // 文档列表项
  @Builder
  DocListItem(doc: DocInfo) {
    Row() {
      // 文件图标
      Column() {
        Image(this.getDocIcon(doc.type))
          .width(40).height(40)
          .fillColor(this.getDocIconColor(doc.type))
      }
      .width(56).height(56)
      .justifyContent(FlexAlign.Center)
      .borderRadius(12)
      .backgroundColor(this.getDocBgColor(doc.type))

      // 文件信息
      Column() {
        Text(doc.name)
          .fontSize(15)
          .fontWeight(FontWeight.Medium)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Row() {
          Text(this.formatFileSize(doc.size))
            .fontSize(12)
            .fontColor('#999999')
          Text('·')
            .fontSize(12)
            .fontColor('#999999')
            .margin({ left: 4, right: 4 })
          Text(`v${doc.version}`)
            .fontSize(12)
            .fontColor('#999999')
          Text('·')
            .fontSize(12)
            .fontColor('#999999')
            .margin({ left: 4, right: 4 })
          Text(this.formatTime(doc.lastModified))
            .fontSize(12)
            .fontColor('#999999')
        }
        .margin({ top: 4 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })

      // 更多操作
      Image($r('app.media.ic_more'))
        .width(20).height(20)
        .fillColor('#999999')
    }
    .width('100%')
    .padding(12)
    .borderRadius(12)
    .backgroundColor(Color.White)
    .onClick(() => {
      this.openDoc(doc);
    })
  }

  // 文档详情视图
  @Builder
  DocDetailView() {
    Column() {
      // 返回按钮和操作栏
      Row() {
        Image($r('app.media.ic_back'))
          .width(24).height(24)
          .fillColor('#333333')
          .onClick(() => { this.selectedDoc = null; this.isEditing = false; })

        Text(this.selectedDoc!.name)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .margin({ left: 12 })
          .layoutWeight(1)

        // 版本历史
        Text('版本')
          .fontSize(14)
          .fontColor('#1565C0')
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .borderRadius(4)
          .backgroundColor('#E3F2FD')
          .onClick(() => {
            this.versionList = this.collaboration.getVersionHistory(this.selectedDoc!.docId);
            this.showVersionPanel = true;
          })

        // 编辑/预览切换
        Text(this.isEditing ? '预览' : '编辑')
          .fontSize(14)
          .fontColor('#1565C0')
          .padding({ left: 8, right: 8, top: 4, bottom: 4 })
          .borderRadius(4)
          .backgroundColor('#E3F2FD')
          .margin({ left: 8 })
          .onClick(() => { this.isEditing = !this.isEditing; })
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 })
      .backgroundColor(Color.White)

      // 文档内容区域
      if (this.isEditing) {
        TextArea({ text: this.docContent })
          .width('100%')
          .layoutWeight(1)
          .fontSize(15)
          .padding(16)
          .onChange((value: string) => {
            this.docContent = value;
          })
      } else {
        Scroll() {
          Text(this.docContent)
            .fontSize(15)
            .lineHeight(24)
            .padding(16)
            .width('100%')
        }
        .layoutWeight(1)
        .backgroundColor(Color.White)
      }

      // 保存按钮
      if (this.isEditing) {
        Button('保存')
          .width('90%')
          .height(44)
          .fontSize(16)
          .backgroundColor('#1565C0')
          .borderRadius(8)
          .margin({ top: 12, bottom: 16 })
          .onClick(() => this.saveDoc())
      }
    }
    .width('100%')
    .layoutWeight(1)
  }

  // 打开文档
  private openDoc(doc: DocInfo): void {
    this.selectedDoc = doc;

    if (doc.type === DocType.TEXT) {
      // 纯文本直接加载内容
      this.docContent = '这是文档的示例内容。\n\n第一段:项目背景\n本项目旨在...\n\n第二段:技术方案\n采用鸿蒙ArkTS框架...';
      this.collaboration.openDoc(doc.docId, 'current_user', this.docContent);
    } else {
      // 其他格式提示需要服务端转换
      this.docContent = `[${doc.name}] 预览模式\n\n该文档格式需要服务端转换后预览。\n当前版本: v${doc.version}`;
    }
  }

  // 保存文档
  private saveDoc(): void {
    if (!this.selectedDoc) return;

    this.collaboration.saveVersion(this.selectedDoc.docId, 'current_user');
    this.isEditing = false;
    console.info(`[DocManage] 文档已保存: ${this.selectedDoc.name}`);
  }

  // ========== 辅助方法 ==========

  private getDocIcon(type: DocType): Resource {
    const iconMap: Record<string, Resource> = {
      [DocType.PDF]: $r('app.media.ic_pdf'),
      [DocType.WORD]: $r('app.media.ic_word'),
      [DocType.EXCEL]: $r('app.media.ic_excel'),
      [DocType.PPT]: $r('app.media.ic_ppt'),
      [DocType.IMAGE]: $r('app.media.ic_image'),
      [DocType.TEXT]: $r('app.media.ic_text'),
    };
    return iconMap[type] || $r('app.media.ic_file');
  }

  private getDocIconColor(type: DocType): string {
    const colorMap: Record<string, string> = {
      [DocType.PDF]: '#C62828',
      [DocType.WORD]: '#1565C0',
      [DocType.EXCEL]: '#2E7D32',
      [DocType.PPT]: '#E65100',
      [DocType.IMAGE]: '#6A1B9A',
      [DocType.TEXT]: '#666666',
    };
    return colorMap[type] || '#999999';
  }

  private getDocBgColor(type: DocType): string {
    const colorMap: Record<string, string> = {
      [DocType.PDF]: '#FFEBEE',
      [DocType.WORD]: '#E3F2FD',
      [DocType.EXCEL]: '#E8F5E9',
      [DocType.PPT]: '#FFF3E0',
      [DocType.IMAGE]: '#F3E5F5',
      [DocType.TEXT]: '#F5F5F5',
    };
    return colorMap[type] || '#F5F5F5';
  }

  private formatFileSize(bytes: number): string {
    if (bytes < 1024) return `${bytes}B`;
    if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
    return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
  }

  private formatTime(timestamp: number): string {
    const diff = Date.now() - timestamp;
    if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`;
    if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`;
    return `${Math.floor(diff / 86400000)}天前`;
  }
}

踩坑与注意事项

坑1:大文件预览OOM

一个100MB的PDF,你直接加载到内存里试试?内存直接爆了。

解决方案:分页加载。PDF按页渲染,每次只加载当前页和前后各一页。图片用<Image>组件的autoResize属性,自动缩放到屏幕尺寸。

坑2:OT算法的边界情况

OT算法听起来很美,但实际场景远比教科书复杂。比如:

  • 一个人在另一个人的插入位置删除内容
  • 两个人同时替换同一段文字
  • 格式变更和内容变更同时发生

建议:不要自己实现OT引擎。用成熟的协作引擎(如Yjs、Automerge),它们已经帮你处理了各种边界情况。实在要自己实现,至少写100个测试用例覆盖各种场景。

坑3:版本快照的存储成本

每次保存都存完整快照?一个1MB的文档改100次就是100MB。10个文档就是1GB。

解决方案:增量存储。只存差异,每10个版本存一次完整快照。恢复时找最近的完整快照,再逐步应用差异。

坑4:离线编辑的同步问题

用户在地铁上编辑了文档,到公司后连上WiFi,怎么同步?

方案

  1. 离线时所有操作记录到本地
  2. 上线后先拉取最新版本
  3. 把本地操作与最新版本做diff
  4. 如果有冲突,弹窗让用户选择

坑5:文档权限与协作权限混淆

文档有"查看"和"编辑"权限,协作有"只读"和"可编辑"权限。这两个不是一回事——你可能对文档有编辑权限,但在当前协作会话中是只读的(因为有人在编辑)。

建议:文档权限和协作权限分开管理。文档权限是静态的(谁能看/编辑),协作权限是动态的(当前谁在编辑)。

坑6:WebView嵌入的性能问题

用WebView嵌入在线文档编辑器,功能是全了,但性能呢?加载慢、滚动卡、内存占用高,在低端设备上简直是灾难。

建议:WebView方案只用于"编辑"场景,"预览"走原生渲染。编辑时才加载WebView,预览时用轻量方案。

HarmonyOS 6适配说明

HarmonyOS 6对文档管理相关能力的增强:

  1. PDF原生渲染:新增@kit.PdfKit,支持PDF原生渲染,不再依赖WebView。分页加载、文字选择、批注标注全支持。
// HarmonyOS 6 PDF原生渲染
import { pdf } from '@kit.PdfKit';

// 加载PDF文档
async function loadPdf(filePath: string): Promise<pdf.PdfDocument> {
  const doc = await pdf.PdfDocument.open(filePath);
  const pageCount = doc.getPageCount();
  console.info(`[PDF] 总页数: ${pageCount}`);
  
  // 渲染指定页面
  const page = await doc.getPage(1);
  const bitmap = await page.render({ width: 1080, height: 1920 });
  return doc;
}
  1. 分布式文档流转:文档可以在设备间无缝流转。手机上预览,拖拽到平板上编辑,文档内容和光标位置完全保留。

  2. 文件管理增强@kit.CoreFileKit新增文件观察者能力,文档被其他协作者修改时实时收到通知,不需要轮询。

  3. 安全沙箱增强:企业文档和个人文档物理隔离,卸载App时企业文档自动清除,个人文档保留。

  4. AI辅助写作:新增AI写作辅助能力,文档编辑时自动补全、语法检查、格式优化。

总结

文档协作的三个核心难题:预览要跨格式、协作要防冲突、版本要可追溯。预览走服务端转换+原生渲染的组合方案,协作用OT算法处理并发,版本用增量存储控制成本。

核心记住三点:

  • 预览不要什么都用WebView,PDF有原生渲染,图片有Image组件,文本直接显示,WebView只用于编辑
  • OT算法不要自己造轮子,用成熟的协作引擎,边界情况太多了
  • 版本管理要用增量存储,每次存完整快照,存储成本会让你崩溃
评估维度 说明
学习难度 ⭐⭐⭐⭐ OT算法和冲突处理需要深入理解,预览方案相对简单
使用频率 ⭐⭐⭐⭐⭐ 企业OA必备功能,几乎每天都要用
重要程度 ⭐⭐⭐⭐⭐ 文档协作做不好,用户直接用WPS/飞书替代

文档协作是OA的"高频刚需"。做不好,用户就不会用你的OA——因为文档管理是他们每天花时间最多的地方。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。