前端如何实现一个简易的文档管理功能

1,466 阅读8分钟

最近在做文档管理的相关业务,发现挺有意思,刚开始想的很复杂,后来发现好像没有那么繁琐,在这里把使用的技术点以及遇到的问题做一个整体回顾。

技术选型:

前端:vue2+monaco-editor

服务端:Nest.js+Gitlab API+Mysql+CI

前端涉及编辑器的相关插件以及包如下:

  • markdown-it
  • markdown-it-anchor
  • markdown-it-link-attributes
  • markdown-it-toc-done-right
  • monaco-editor-webpack-plugin

前端部分后面会专门通过一篇文章来讲,这里主要围绕后端展开来说。

这里的文档管理功能相对来说比较简易,主要包括文档列表(增删改查)、文档版本列表(增删改查、构建、构建记录、封版)、文档编辑页面(左侧文档目录树、右侧文档内容(样式编辑、文档编辑、多语言))

数据库设计

数据库设计.png

文档列表

文档列表内容直接入库,主要包括新增、编辑、删除文档操作。创建一个文档,相当于创建一个git仓库(基于一个模版仓库进行fork操作),模版仓库文件目录结构如下所示:

├── zh-cn
|   ├── menu.json # 文档菜单
├── .gitlab-ci.yml # 构建CI
├── manifest.json # 文档基础信息
└── style.css # 文档样式

文档列表字段主要包括名称、描述、文档版本、是否支持多语言、创建人、修改者、创建时间、修改时间。

文档版本列表

新增一个版本即在git中对应仓库创建一个分支。文档版本列表字段主要包括名称、描述、语言版本、创建人、修改者、创建时间、修改时间。列表操作项中构建记录和Gitlab CI关联,点击构建按钮,会触发CI自动执行。

构建流程

技术选型:Gitlab CI+Node脚本

文档管理.png

获取构建记录时,会查询对应文档指定tag的CI执行状态,如果ci执行完成,更新数据库中状态以及构建包url

文档下载

在构建记录列表中可以对某个版本的文档进行下载,下载解压后获取的文档目录结构如下:

├── zh-cn
|   ├── html
|   |   ├── uuid.html # 单个文档
|   ├── menu.json # 文档菜单
├── manifest.json # 文档基础信息
└── style.css # 文档样式

文档编辑

技术选型:Gitlab API

文档编辑页面相关文档内容操作基本都是通过Gitlab API完成

文档编辑页面分为两部分:

  • 左侧:文档目录树(增删改查)
  • 右侧:文档编辑区(主要包括图片上传、word转换为md内容,编辑单个文档后点击保存实时推送git(没有则创建文件,有则更新文件))

最终git上文档目录结构如下所示:

├── zh-cn
|   ├── docs
|   |   ├── uuid.md # 单个文档
|   ├── menu.json # 文档菜单
├── .gitlab-ci.yml # 构建CI
├── manifest.json # 文档基础信息
└── style.css # 文档样式

图片上传

# controller
import {
  UseInterceptors,
  UploadedFiles,
  ...
} from '@nestjs/common';
import { FilesInterceptor } from '@nestjs/platform-express';

@Post('/doc/pic')
  @UseInterceptors(FilesInterceptor('files'))
  async uploadPic(
    @UploadedFiles() files: Array<Express.Multer.File>
  ) {
    return await this.xxx.uploadPic(files);
  }

# service
async uploadPic(files) {
  let result = [];   
  await Promise.all(
    (result = files.map((file) => {
      const index = file.mimetype.lastIndexOf('/');
      const fileSuffix = file.mimetype.substring(index + 1);
      const fileUuid = uuidv4();
      // 解决图片名称是中文时接口返回乱码问题
      file.originalname = Buffer.from(file.originalname, 'latin1').toString(
        'utf8',
      );
      return {
        name: file.originalname,
        uuid: fileUuid,
        fileSuffix,
      };
    })),
  );
  return result;
}

在做图片上传时遇到了图片名称如果是中文时,接口返回乱码(postman返回正常),解决方案是通过Buffer进行处理,具体操作如下

// 解决图片名称是中文时接口返回乱码问题
file.originalname = Buffer.from(file.originalname, 'latin1').toString(
  'utf8',
);

一般在遇到中文乱码问题时,首先查看response-headers中的Content-type是否有设置utf-8,如果没有则在response-headers设置Content-Type: application/json; charset=utf-8,然后再排查其他原因,不过这种方法用postman测试时返回的是乱码,原因是(postman发送请求的时候,会额外增加一个filename,位于Content-Disposition中),这里就不展开说了,感兴趣的同学可以查看这篇文章:cnodejs.org/topic/5e685…

image.png

图片下载

直接下载

# controller
@Get('/download')
async download(@Res() res: Response) { 
  return this.xxx.downloadFile(res, id);
}

# service
async downloadFile(res) {
  res.download(zip, (err) => {
    if (!err) {
      console.log('success', 'download', zip);
      return;
    }
    res.send({
      code: 400,
      error: err,
      detail: String(err),
      data: null,
    });
  });

}

流的形式下载

下载后的文件是二进制形式,需要前端一起配合

@Get('/download')
async download(@Res() res: Response) {
  const tarStream = new zip.Stream()
  await tarStream.addEntry(url)
  res.setHeader('Content-Type','application/octet-stream')
  res.setHeader('Content-Disposition','attachment;filename=xx')
  tarStream.pipe(res)
}

word转换为md内容

技术选型:turndown+mammoth

# controller
import { FileInterceptor } from '@nestjs/platform-express';
import {
  UploadedFile,
  UseInterceptors,
  ...
} from '@nestjs/common';

@Post('/word-to-doc')
@UseInterceptors(FileInterceptor('file'))
async wordToDoc(@UploadedFile() file) {
  return await this.xxx.wordToDoc(file);
}

# service
const TurndownService = require('turndown');
const turndownService = new TurndownService();

async wordToDoc(file) {
  const rootDir = process.cwd();
  // 创建docx临时目录
  child.execSync(`cd ${rootDir} && mkdir temp && cd temp && mkdir doc`);
  // 创建docx中图片临时目录
  child.execSync(`cd ${rootDir}/temp/doc && mkdir image && cd image`);
  const docPath = `${rootDir}/temp/doc/${uuidv4()}.docx`;
  // 写入temp
  fs.writeFileSync(docPath, file.buffer);
  const html = await docx2html(docPath); // docx to html
  const markdown = turndownService.turndown(html); // html to markdown
  // 删除temp临时文件
  child.execSync(`cd ${rootDir}/ && rm -rf temp`);
  return markdown;
}

# common.js
import { images, convertToHtml } from 'mammoth';
export const docx2html = (filepath) => {
  return new Promise((resolve, reject) => {
    convertToHtml({ path: filepath }, mammothOptions)
      .then((result) => {
        resolve(result.value);
      })
      .catch((err) => reject(err));
  });
};

// 图片格式
const MimeTypeObj = {
  'image/jpeg': '.jpg',
  'image/png': '.png',
  'image/gif': '.gif',
  'image/svg+xml': '.svg',
};

const mammothOptions = {
  convertImage: images.imgElement(function (image) {
    return image.read().then((buffer) => {
      const img = {
        buffer,
        suffix: MimeTypeObj[image.contentType],
        // originalname: '',
      };
      // TODO 处理图片,上传到图床获取url地址
      return {
        src: url,
      };
    });
  }),
};

问题

服务器上Clone with SSH时权限问题

image.png

解决方案:通过token处理(Clone with HTTPS)

具体实现如下:

/**
 * 功能:拉取git仓库指定分支到workspace
 * @param docName git仓库名称
 * @param branchName git仓库分支名称
 */
export const gitCloneRepository = (path, docName, versionName) => {
  const gitPath = `https://oauth2:${GITLAB_TOKEN}@gitxxx/${DOCS_GIT_WORKSPACE}/${docName}.git`;
  child.execSync(`cd ${path} && git clone -b ${versionName} ${gitPath}`);
  console.log(`${docName} 克隆成功`);
};

Nest.js服务启动后没有绿色日志信息

image.png

原因:因为中途添加了日志信息,里面没有log导致

async function bootstrap() {
  const app = await NestFactory.create(AppModule, {
    logger: ['error', 'warn', 'log'], // 即必须有log,否则服务启动后没有相关日志信息
  });
  ...
}

Nest.js图片上传部署服务器后报错

报错信息: image.png 通过错误可知报错来源于<Express.Multer.File>部分

解决方案: 代码中添加如下代码:

import { Express } from 'express' 导入这个类型

image.png

mysql报错

报错信息: image.png

解决方案:重启mysql容器

Gitlab API 使用常见问题

  1. git创建文件不支持并发执行,需要线性执行
  2. fork仓库时,参数path必须传,否则调用api会报错,path是git仓库名称
  3. 方法参数名称以及顺序有的和Gitlab API不是完全一样的

Linux问题

在使用脚本批量迁移历史文档信息时,发现linux命令对git以及对中英文不敏感,需要特殊处理。即git忽略大小写,命令操作时涉及大小写时需要单独处理。

树形结构增删改查

左侧文档目录展示形式是树形结构,主要操作包括增删改查

新增节点

/**
 * 功能:新增节点
 * @param docs
 * @param docsMap
 * @returns
 */
export const appendNodeInTree = (docs, docsMap) => {
  if (docsMap.pid) {
    docs.forEach((ele) => {
      if (ele.uuid === docsMap.pid) {
        // 文档目录插入子节点
        ele.doc_ids.push(docsMap.uuid);
        ele.children.push(docsMap);
      } else {
        if (ele.children) {
          appendNodeInTree(ele.children, docsMap);
        }
      }
    });
  } else {
    // 最外层目录插入
    docs.push(docsMap);
  }
  return docs;
};

// 使用
appendNodeInTree(docs, docContent);

修改节点

/**
 * 功能:修改tree节点
 * @param {*} list 需要更新的数组
 * @param {*} uuid 节点唯一标识
 * @param {*} pid 父级标识
 * @param {*} obj 修改内容
 */
export const updateNodeInTree = (list, uuid, pid, obj) => {
  if (!list || !list.length) {
    return;
  }
  for (let i = 0; i < list.length; i++) {
    const temp = Object.assign({}, list[i], {
      ...obj,
      last_modify_at: new Date(),
    });
    if (pid) {
      if (list[i].uuid == uuid && list[i].pid == pid) {
        list[i] = temp;
        break;
      }
    } else {
      if (list[i].uuid == uuid) {
        list[i] = temp;
        break;
      }
    }
    updateNodeInTree(list[i].children, uuid, pid, obj);
  }
};

// 使用
updateNodeInTree(docs, uuid, pid, modifyContent);

删除节点

/**
 * 功能:删除子节点
 * @param list 需要更新的数组
 * @param uuid 唯一标识
 * @param pid 父级唯一标识
 * @returns
 */
export const removeNodeInTree = (list, uuid, pid) => {
  if (!list || !list.length) {
    return;
  }
  // 有pid的话删除doc_ids
  for (let i = 0; i < list.length; i++) {
    if (pid && list[i].uuid === pid) {
      const index = list[i].doc_ids.findIndex((itm) => {
        itm === uuid;
      });
      list[i].doc_ids.splice(index, 1);
    }
    if (list[i].uuid === uuid) {
      list.splice(i, 1);
      break;
    }
    removeNodeInTree(list[i].children, uuid, pid);
  }
};

// 使用
removeNodeInTree(docs, uuid, pid);

查看节点

/**
 * 功能:查找节点
 * @param data
 * @param key
 * @param value
 * @param callback
 * @returns
 */
export const findNodeInTree = (data, key, value, callback) => {
  for (let i = 0; i < data.length; i++) {
    if (data[i][key] == value) {
      return callback(data[i], key, value, callback);
    }
    if (data[i].children) {
      findNodeInTree(data[i].children, key, value, callback);
    }
  }
};

// 使用
// 获取当前节点
let treeNode;
findNodeInTree(docs, 'uuid', uuid, (item) => {
  treeNode = item;
});

Gitlab API 使用以及常用方法

技术选型:@gitbeaker/node

@gitbeaker/node是GitLab API NodeJS库,完全支持所有Gitlab API服务

使用

// gitlab-repository.service
import { Gitlab } from '@gitbeaker/node';

@Injectable()
export class GitlabRepositoryService {
  private api = new Gitlab({
    host: process.env.GITLAB_HOST,
    token: process.env.GITLAB_TOKEN,
    version: 4,
    rejectUnauthorized: false,
  });

  async createRepository(docName: string) {
    await this.api.Projects.fork(31880, {
      name: docName,
      namespace_id: process.env.GIT_DOC_TEST_NAMESPACE,
      path: docName, // 必须传,否则会报错
    });
  }

  ...
}

// service中使用,比如我要在product.service.ts中使用
1. product.module.ts
import { GitlabRepositoryService } from './gitlab-repository.service';

const rs: FactoryProvider = {
  provide: GitlabRepositoryService,
  useFactory: () => new GitlabRepositoryService(),
};
@Module({
  ...
  providers: [rs],
  exports: [rs],
})
2. product.service.ts
import { GitlabRepositoryService } from './gitlab-repository.service';

export class ProductService {
  constructor(
    private readonly gitlabRepositoryService: GitlabRepositoryService,
  ) {}

  await this.gitlabRepositoryService.createRepository(
    xxx,
  );
}

常用方法

fork仓库

async createRepository(docName: string) {
  try {
    // fork
    const res = await this.api.Projects.fork(31880, {
      name: docName,
      namespace_id: process.env.NAMESPACE,
      path: docName, // 必须传,否则会报错
    });
    return res.id;
  } catch (err) {
    throw new InternalServerErrorException(
      `创建Git仓库(${docName})失败: ${get(
        err,
        'description',
        '未知错误',
      )}`,
    );
  }
}

创建仓库文件

async createRepositoryFile(
  gitlabId: number,
  filePath: string,
  branch: string,
  content: string,
  commitMessage: string,
) {
  try {
    return await this.api.RepositoryFiles.create(
      gitlabId,
      filePath,
      branch,
      content,
      commitMessage,
    );
  } catch (error) {
    // TODO 捕获异常
  }
}

// 使用
await this.gitlabRepositoryService.createRepositoryFile(
  product.gitlabId,
  `${locale}/docs/${uuid}.md`,
  version.name,
  content,
  commitMessage,
);

修改仓库名称

async updateRepository(gitlabId: number, docName: string) {
  try {
    await this.api.Projects.edit(gitlabId, { name: docName });
  } catch (error) {
    // TODO 捕获异常
  }
}

删除仓库

async deleteRepository(gitlabId: number) {
  try {
    await this.api.Projects.remove(gitlabId);
  } catch (error) {
    // TODO 捕获异常
  }
}

创建分支

// ref基于哪个分支创建
async createBranch(gitlabId: number, branchName: string, ref: string) {
  try {
    await this.api.Branches.create(gitlabId, branchName, ref);
  } catch (error) {
    // TODO 捕获异常
  }
}

删除远程仓库分支

async deleteBranch(gitlabId: number, branchName: string) {
  try {
    await this.api.Branches.remove(gitlabId, branchName);
  } catch (error) {
    // TODO 捕获异常
  }
}

获取git上文件内容

async getGitFile(filePath: string, gitlabId: number, ref: string) {
  try {
    const result = await this.api.RepositoryFiles.showRaw(
      gitlabId,
      filePath,
      {
        ref, // 分支、标记或提交的名称
      },
    );
    return result;
  } catch (error) {
    // TODO 捕获异常
  }
}

修改git上文件内容

async updateGitFile(
  gitlabId: number,
  commitMessage: string,
  branchName: string,
  filePath: string,
  content: string,
) {
  try {
    return await this.api.RepositoryFiles.edit(
      gitlabId,
      filePath,
      branchName,
      content,
      commitMessage,
    );
  } catch (error) {
    // TODO 捕获异常
  }
}

获取项目中存储库提交的列表

async getGitRepositoryCommits(
  gitlabId: number,
  branchName: string,
  path?: string,
) {
  try {
    return await this.api.Commits.all(gitlabId, {
      ref_name: branchName,
      perPage: 1,
      maxPages: 20,
      path,
    });
  } catch (error) {
    // TODO 捕获异常
  }
}

获取项目成员角色

async getUserGitInfo(gitlabId: number, userId: number) {
  try {
    return await this.api.ProjectMembers.show(gitlabId, userId, {
      includeInherited: true,
    });
  } catch (error) {
    const message = `${get(error, 'description')}`;
    if (message === '404 Not found') {
      // 没有权限的用户
      return {
        access_level: null,
      };
    } else {
      // TODO 捕获异常
    }
  }
}

列出组或项目的所有成员

async getGitGroupMember(
  gitlabId: number,
  query?: string,
  includeInherited?: boolean,
) {
  try {
    return await this.api.ProjectMembers.all(gitlabId, {
      includeInherited, // 是否继承
      query, // 查询者
    });
  } catch (error) {
    // TODO 捕获异常
  }
}

添加项目成员

async addProjectMember(
  projectId: number,
  userId: number,
  accessLevel,
  username: string,
) {
  try {
    return await this.api.ProjectMembers.add(
      projectId,
      userId,
      accessLevel,
    );
  } catch (e) {
    // TODO 捕获异常
  }
}

编辑项目的成员

async editProjectMember(
  projectId: number,
  userId: number,
  accessLevel,
  username: string,
) {
  try {
    return await this.api.ProjectMembers.edit(
      projectId,
      userId,
      accessLevel,
    );
  } catch (e) {
    // TODO 捕获异常
  }
}

删除项目成员

async deleteProjectMember(projectId: number, userId: number) {
  try {
    return await this.api.ProjectMembers.remove(projectId, userId);
  } catch (e) {
    // TODO 捕获异常
  }
}

获取用户信息

async getUserInfo(username: string) {
  try {
    const gitUsername = username.toLowerCase();
    const userInfos = await this.api.Users.search(gitUsername);
    return (userInfos as any[]).find((user) => user.username === gitUsername);
  } catch (e) {
    // TODO 捕获异常
  }
}

基于分支创建tag

async createGitLabTagByBranch(
  gitlabId: number,
  tagName: string,
  branchName: string,
  message?: string,
) {
  try {
    return await this.api.Tags.create(gitlabId, tagName, branchName, {
      message,
    });
  } catch (e) {
    // TODO 捕获异常
  }
}

获取项目tag列表

async getGitLabTags(gitlabId: number) {
  try {
    return await this.api.Tags.all(gitlabId);
  } catch (e) {
    // TODO 捕获异常
  }
}

获取项目pipeline

async getDocVersionPipline(
  gitlabId: number,
  branchName: string,
  ref: string,
) {
  try {
    return await this.api.Pipelines.all(gitlabId, {
      scope: 'tags',
      // page: 1,
      // perPage: 10,
      ref,
    });
  } catch (e) {
    // TODO 捕获异常
  }
}

相关链接

docx转html

html转md

node-md5

node-fs-extra

@gitbeaker/node

Gitlab API

js-base64