Files
disknext/docs/text-editor-api.md
于小丘 eac0766e79 feat: migrate ORM base to sqlmodel-ext, add file viewers and WOPI integration
- Migrate SQLModel base classes, mixins, and database management to
  external sqlmodel-ext package; remove sqlmodels/base/, sqlmodels/mixin/,
  and sqlmodels/database.py
- Add file viewer/editor system with WOPI protocol support for
  collaborative editing (OnlyOffice, Collabora)
- Add enterprise edition license verification module (ee/)
- Add Dockerfile multi-stage build with Cython compilation support
- Add new dependencies: sqlmodel-ext, cryptography, whatthepatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 14:23:17 +08:00

5.4 KiB
Raw Permalink Blame History

文本文件在线编辑 — 前端适配文档

概述

Monaco Editor 打开文本文件时,通过 GET 获取内容和哈希作为编辑基线;保存时用 jsdiff 计算 unified diff仅发送差异部分后端验证无并发冲突后应用 patch。

打开文件:  GET   /api/v1/file/content/{file_id}  →  { content, hash, size }
保存文件:  PATCH /api/v1/file/content/{file_id}  ←  { patch, base_hash }
                                                  →  { new_hash, new_size }

约定

项目 约定
编码 全程 UTF-8
换行符 后端 GET 时统一规范化为 \n,前端无需处理 \r\n
hash 算法 SHA-256hex 编码64 字符),基于 UTF-8 bytes 计算
diff 格式 jsdiff createPatch() 输出的标准 unified diff
空 diff 前端自行判断,内容未变时不发请求

GET /api/v1/file/content/{file_id}

获取文本文件内容。

请求

GET /api/v1/file/content/{file_id}
Authorization: Bearer <token>

响应 200

{
  "content": "line1\nline2\nline3\n",
  "hash": "a1b2c3d4...64字符 SHA-256 hex",
  "size": 18
}
字段 类型 说明
content string 文件文本内容,换行符已规范化为 \n
hash string 基于规范化内容 UTF-8 bytes 的 SHA-256 hex
size number 规范化后的字节大小

错误

状态码 说明
400 文件不是有效的 UTF-8 文本(二进制文件)
401 未认证
404 文件不存在

PATCH /api/v1/file/content/{file_id}

增量保存文本文件。

请求

PATCH /api/v1/file/content/{file_id}
Authorization: Bearer <token>
Content-Type: application/json
{
  "patch": "--- a\n+++ b\n@@ -1,3 +1,3 @@\n line1\n-line2\n+LINE2\n line3\n",
  "base_hash": "a1b2c3d4...GET 返回的 hash"
}
字段 类型 说明
patch string jsdiff createPatch() 生成的 unified diff
base_hash string 编辑前 GET 返回的 hash

响应 200

{
  "new_hash": "e5f6a7b8...64字符",
  "new_size": 18
}

保存成功后,前端应将 new_hash 作为新的 base_hash,用于下次保存。

错误

状态码 说明 前端处理
401 未认证
404 文件不存在
409 base_hash 不匹配(并发冲突) 提示用户刷新,重新加载内容
422 patch 格式无效或应用失败 回退到全量保存或提示用户

前端实现参考

依赖

npm install jsdiff

计算 hash

async function sha256(text: string): Promise<string> {
  const bytes = new TextEncoder().encode(text);
  const hashBuffer = await crypto.subtle.digest("SHA-256", bytes);
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  return hashArray.map(b => b.toString(16).padStart(2, "0")).join("");
}

打开文件

interface TextContent {
  content: string;
  hash: string;
  size: number;
}

async function openFile(fileId: string): Promise<TextContent> {
  const resp = await fetch(`/api/v1/file/content/${fileId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });

  if (!resp.ok) {
    if (resp.status === 400) throw new Error("该文件不是文本文件");
    throw new Error("获取文件内容失败");
  }

  return resp.json();
}

保存文件

import { createPatch } from "diff";

interface PatchResult {
  new_hash: string;
  new_size: number;
}

async function saveFile(
  fileId: string,
  originalContent: string,
  currentContent: string,
  baseHash: string,
): Promise<PatchResult | null> {
  // 内容未变,不发请求
  if (originalContent === currentContent) return null;

  const patch = createPatch("file", originalContent, currentContent);

  const resp = await fetch(`/api/v1/file/content/${fileId}`, {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${token}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ patch, base_hash: baseHash }),
  });

  if (resp.status === 409) {
    // 并发冲突,需要用户决策
    throw new Error("CONFLICT");
  }

  if (!resp.ok) throw new Error("保存失败");

  return resp.json();
}

完整编辑流程

// 1. 打开
const file = await openFile(fileId);
let baseContent = file.content;
let baseHash = file.hash;

// 2. 用户在 Monaco 中编辑...
editor.setValue(baseContent);

// 3. 保存Ctrl+S
const currentContent = editor.getValue();
const result = await saveFile(fileId, baseContent, currentContent, baseHash);

if (result) {
  // 更新基线
  baseContent = currentContent;
  baseHash = result.new_hash;
}

冲突处理建议

当 PATCH 返回 409 时,说明文件已被其他会话修改:

try {
  await saveFile(fileId, baseContent, currentContent, baseHash);
} catch (e) {
  if (e.message === "CONFLICT") {
    // 方案 A提示用户提供"覆盖"和"放弃"选项
    // 方案 B重新 GET 最新内容,展示 diff 让用户合并
    const latest = await openFile(fileId);
    // 展示合并 UI...
  }
}

hash 一致性验证

前端可以在 GET 后本地验证 hash确保传输无误

const file = await openFile(fileId);
const localHash = await sha256(file.content);
console.assert(localHash === file.hash, "hash 不一致,内容可能损坏");