feat: document version

This commit is contained in:
fantasticit 2022-03-29 22:23:41 +08:00
parent e7a70622d4
commit dbd257caa6
11 changed files with 282 additions and 13 deletions

View File

@ -56,6 +56,13 @@ mysql -u root -p;
CREATE DATABASE `think` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
#### 可选Redis
```
docker pull redis:latest
docker run --name think-redis -p 6379:6379 -d redis --appendonly yes --requirepass "root"
```
### 本地运行
首先clone 项目。

View File

@ -12,6 +12,7 @@ import { DocumentShare } from 'components/document/share';
import { DocumentStar } from 'components/document/star';
import { DocumentCollaboration } from 'components/document/collaboration';
import { DocumentStyle } from 'components/document/style';
import { DocumentVersion } from 'components/document/version';
import { useDocumentStyle } from 'hooks/use-document-style';
import { EventEmitter } from 'helpers/event-emitter';
import { Editor } from './editor';
@ -89,6 +90,7 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
<DocumentCollaboration key="collaboration" wikiId={document.wikiId} documentId={documentId} />
)}
<DocumentShare key="share" documentId={documentId} />
<DocumentVersion key="version" documentId={documentId} />
<DocumentStar key="star" documentId={documentId} />
<Popover key="style" zIndex={1061} position="bottomLeft" content={<DocumentStyle />}>
<Button icon={<IconArticle />} theme="borderless" type="tertiary" />

View File

@ -0,0 +1,82 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { Button, Modal, Input, Typography, Toast, Layout, Nav } from '@douyinfe/semi-ui';
import { useEditor, EditorContent } from '@tiptap/react';
import { IconLink } from '@douyinfe/semi-icons';
import { isPublicDocument } from '@think/domains';
import { DEFAULT_EXTENSION, DocumentWithTitle } from 'tiptap';
import { safeJSONParse } from 'helpers/json';
import { ShareIllustration } from 'illustrations/share';
import { DataRender } from 'components/data-render';
import { useToggle } from 'hooks/use-toggle';
import { useDocumentVersion } from 'data/document';
interface IProps {
documentId: string;
}
const { Text } = Typography;
const { Header, Footer, Sider, Content } = Layout;
export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
const [visible, toggleVisible] = useToggle(false);
const { data, loading, error, refresh } = useDocumentVersion(documentId);
const [selectedVersion, setSelectedVersion] = useState(null);
const editor = useEditor({
editable: false,
extensions: [...DEFAULT_EXTENSION, DocumentWithTitle],
content: {},
});
const select = useCallback(
(version) => {
setSelectedVersion(version);
editor.commands.setContent(safeJSONParse(version.data, { default: {} }).default);
},
[editor]
);
useEffect(() => {
if (visible) {
refresh();
}
}, [visible]);
return (
<>
<Button type="primary" theme="light" onClick={toggleVisible}>
</Button>
<Modal
title="历史记录"
fullScreen
visible={visible}
onOk={() => toggleVisible(false)}
onCancel={() => toggleVisible(false)}
>
<Layout style={{ height: 'calc(100vh - 72px)', overflow: 'hidden' }}>
<Sider>
<Nav
bodyStyle={{ height: 'calc(100vh - 96px)', overflow: 'auto' }}
defaultOpenKeys={['job']}
items={data.map(({ version, data }) => {
return { itemKey: version, text: version, onClick: () => select({ version, data }) };
})}
/>
</Sider>
<Content>
<Layout className="components-layout-demo">
<Header>Header</Header>
<Content>
<div className="container" style={{ paddingBottom: 48 }}>
<EditorContent editor={editor} />
</div>
</Content>
</Layout>
</Content>
</Layout>
</Modal>
</>
);
};

View File

@ -76,6 +76,20 @@ export const useDocumentDetail = (documentId, options = null) => {
return { data, loading, error, update, toggleStatus };
};
/**
*
* @param documentId
* @returns
*/
export const useDocumentVersion = (documentId) => {
const { data, error, mutate } = useSWR<Array<{ version: string; data: string }>>(
`/document/version/${documentId}`,
(url) => HttpClient.get(url)
);
const loading = !data && !error;
return { data: data || [], loading, error, refresh: mutate };
};
/**
* 10
* @returns

View File

@ -20,6 +20,11 @@ db:
charset: 'utf8mb4'
timezone: '+08:00'
synchronize: true
redis:
host: '127.0.0.1'
port: '6379'
db: 0
password: 'root'
# oss 文件存储服务
oss:

View File

@ -38,6 +38,7 @@
"express": "^4.17.2",
"express-rate-limit": "^6.2.0",
"helmet": "^5.0.2",
"ioredis": "^5.0.1",
"lodash": "^4.17.21",
"mysql2": "^2.3.3",
"nuid": "^1.1.6",

View File

@ -48,6 +48,14 @@ export class DocumentController {
return await this.documentService.updateDocument(req.user, documentId, dto);
}
@UseInterceptors(ClassSerializerInterceptor)
@Get('version/:id')
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async getDocumentVersion(@Request() req, @Param('id') documentId) {
return await this.documentService.getDocumentVersion(req.user, documentId);
}
@UseInterceptors(ClassSerializerInterceptor)
@Post('children')
@HttpCode(HttpStatus.OK)

View File

@ -8,12 +8,13 @@ import * as lodash from 'lodash';
import { OutUser, UserService } from '@services/user.service';
import { TemplateService } from '@services/template.service';
import { DocumentService } from '@services/document.service';
import { DocumentVersionService } from '@services/document-version.service';
@Injectable()
export class CollaborationService {
server: typeof Server;
debounceTime: 2000;
maxDebounceTime: 10000;
debounceTime = 1000;
maxDebounceTime = 10000;
timers: Map<
string,
{
@ -28,12 +29,14 @@ export class CollaborationService {
@Inject(forwardRef(() => DocumentService))
private readonly documentService: DocumentService,
@Inject(forwardRef(() => TemplateService))
private readonly templateService: TemplateService
private readonly templateService: TemplateService,
@Inject(forwardRef(() => DocumentVersionService))
private readonly documentVersionService: DocumentVersionService
) {
this.initServer();
}
debounce(id: string, func: () => void, immediately = false) {
debounce(id: string, func: () => void, debounceTime = this.debounceTime, immediately = false) {
const old = this.timers.get(id);
const start = old?.start || Date.now();
@ -56,7 +59,7 @@ export class CollaborationService {
this.timers.set(id, {
start,
timeout: setTimeout(run, this.debounceTime),
timeout: setTimeout(run, debounceTime),
});
}
@ -181,10 +184,19 @@ export class CollaborationService {
const targetId = requestParameters.get('targetId');
const docType = requestParameters.get('docType');
const updateDocument = async (user: OutUser, documentId: string, data) => {
await this.documentService.updateDocument(user, documentId, data);
this.debounce(
`onStoreDocumentVersion-${documentId}`,
() => {
this.documentVersionService.storeDocumentVersion(documentId, data.content);
},
this.debounceTime * 2
);
};
const updateHandler =
docType === 'document'
? this.documentService.updateDocument.bind(this.documentService)
: this.templateService.updateTemplate.bind(this.templateService);
docType === 'document' ? updateDocument : this.templateService.updateTemplate.bind(this.templateService);
this.debounce(`onStoreDocument-${targetId}`, () => {
this.onStoreDocument(updateHandler, data).catch((error) => {

View File

@ -0,0 +1,52 @@
import { Injectable, HttpException, HttpStatus, Inject, forwardRef } from '@nestjs/common';
import Redis from 'ioredis';
import { DocumentStatus, IDocument } from '@think/domains';
import { getConfig } from '@think/config';
import * as lodash from 'lodash';
@Injectable()
export class DocumentVersionService {
private redis: Redis;
constructor() {
this.init();
}
private versionDataToArray(data: Record<string, string>): Array<{ version: string; data: string }> {
return Object.keys(data)
.sort((a, b) => +b - +a)
.map((key) => ({ version: new Date(+key).toLocaleString(), data: data[key] }));
}
private async init() {
const config = getConfig();
const redisConfig = lodash.get(config, 'db.redis', {});
try {
const redis = new Redis(redisConfig);
this.redis = redis;
} catch (e) {
this.redis = null;
}
}
public async getDocumentVersions(documentId: IDocument['id']): Promise<Array<{ version: string; data: string }>> {
if (!this.redis) return [];
return new Promise((resolve, reject) => {
this.redis.hgetall(documentId, (err, ret) => {
if (err) {
reject(err);
} else {
resolve(ret ? this.versionDataToArray(ret) : []);
}
});
});
}
public async storeDocumentVersion(documentId: IDocument['id'], data: IDocument['content']) {
if (!this.redis) return;
const version = '' + Date.now();
this.redis.hsetnx(documentId, version, data);
}
}

View File

@ -9,6 +9,7 @@ import { OutUser, UserService } from '@services/user.service';
import { WikiService } from '@services/wiki.service';
import { MessageService } from '@services/message.service';
import { CollaborationService } from '@services/collaboration.service';
import { DocumentVersionService } from '@services/document-version.service';
import { TemplateService } from '@services/template.service';
import { ViewService } from '@services/view.service';
import { array2tree } from '@helpers/tree.helper';
@ -53,6 +54,7 @@ const DOCUMENT_PLACEHOLDERS = [
@Injectable()
export class DocumentService {
private collaborationService: CollaborationService;
private documentVersionService: DocumentVersionService;
constructor(
@InjectRepository(DocumentAuthorityEntity)
@ -70,7 +72,13 @@ export class DocumentService {
@Inject(forwardRef(() => ViewService))
private readonly viewService: ViewService
) {
this.collaborationService = new CollaborationService(this.userService, this, this.templateService);
this.documentVersionService = new DocumentVersionService();
this.collaborationService = new CollaborationService(
this.userService,
this,
this.templateService,
this.documentVersionService
);
}
/**
@ -494,6 +502,32 @@ export class DocumentService {
return { document: { ...doc, views, createUser }, authority };
}
/**
*
* @param user
* @param documentId
* @returns
*/
public async getDocumentVersion(user: OutUser, documentId: string) {
const document = await this.documentRepo.findOne(documentId);
if (!document) {
throw new HttpException('文档不存在', HttpStatus.NOT_FOUND);
}
const authority = await this.documentAuthorityRepo.findOne({
documentId,
userId: user.id,
});
if (!authority || !authority.readable) {
throw new HttpException('您无权查看此文档', HttpStatus.FORBIDDEN);
}
const data = await this.documentVersionService.getDocumentVersions(documentId);
return data;
}
/**
*
* @param id

View File

@ -243,6 +243,7 @@ importers:
express: ^4.17.2
express-rate-limit: ^6.2.0
helmet: ^5.0.2
ioredis: ^5.0.1
jest: ^27.2.5
lodash: ^4.17.21
mysql2: ^2.3.3
@ -286,6 +287,7 @@ importers:
express: 4.17.2
express-rate-limit: 6.2.0_express@4.17.2
helmet: 5.0.2
ioredis: 5.0.1
lodash: 4.17.21
mysql2: 2.3.3
nuid: 1.1.6
@ -295,7 +297,7 @@ importers:
reflect-metadata: 0.1.13
rimraf: 3.0.2
rxjs: 7.5.2
typeorm: 0.2.41_mysql2@2.3.3
typeorm: 0.2.41_ioredis@5.0.1+mysql2@2.3.3
ua-parser-js: 1.0.2
y-prosemirror: 1.0.14_8fd72c89aecefb95d86a797b5207d945
yjs: 13.5.24
@ -945,6 +947,10 @@ packages:
resolution: {integrity: sha512-YRsVFWjL8Gkkvlx3qnjeaxW4fnibSJ9791g8BA7Pv5ANByI64WmtR1vU7A2rXcrOn8XvyCEfY0ss1s8NhZP+MA==}
dev: false
/@ioredis/commands/1.1.1:
resolution: {integrity: sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg==}
dev: false
/@istanbuljs/load-nyc-config/1.1.0:
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
engines: {node: '>=8'}
@ -1367,7 +1373,7 @@ packages:
'@nestjs/core': 8.2.6_2d4ee36c4446df4873bb38fb1c4583da
reflect-metadata: 0.1.13
rxjs: 7.5.2
typeorm: 0.2.41_mysql2@2.3.3
typeorm: 0.2.41_ioredis@5.0.1+mysql2@2.3.3
uuid: 8.3.2
dev: false
@ -2997,6 +3003,11 @@ packages:
engines: {node: '>=6'}
dev: false
/cluster-key-slot/1.1.0:
resolution: {integrity: sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw==}
engines: {node: '>=0.10.0'}
dev: false
/co-defer/1.0.0:
resolution: {integrity: sha1-Pkp4eo7tawoh7ih8CU9+jeDTyBg=}
engines: {node: '>= 0.11.14'}
@ -3313,7 +3324,6 @@ packages:
optional: true
dependencies:
ms: 2.1.2
dev: true
/decamelize-keys/1.1.0:
resolution: {integrity: sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=}
@ -4445,6 +4455,23 @@ packages:
loose-envify: 1.4.0
dev: false
/ioredis/5.0.1:
resolution: {integrity: sha512-/5MF5vnUQf1o3+sy/pnkce5K8zdSMTcTgpRUW5oGGT+mEsgd1r7YLRPZ+zWGb9hbZXE3BJCgphc2eESm+2d9KA==}
engines: {node: '>=12.22.0'}
dependencies:
'@ioredis/commands': 1.1.1
cluster-key-slot: 1.1.0
debug: 4.3.4
denque: 2.0.1
lodash.defaults: 4.2.0
lodash.isarguments: 3.1.0
redis-errors: 1.2.0
redis-parser: 3.0.0
standard-as-callback: 2.1.0
transitivePeerDependencies:
- supports-color
dev: false
/ip/1.1.5:
resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=}
dev: false
@ -5453,10 +5480,18 @@ packages:
p-locate: 4.1.0
dev: true
/lodash.defaults/4.2.0:
resolution: {integrity: sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=}
dev: false
/lodash.includes/4.3.0:
resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=}
dev: false
/lodash.isarguments/3.1.0:
resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=}
dev: false
/lodash.isboolean/3.0.3:
resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=}
dev: false
@ -6780,6 +6815,18 @@ packages:
strip-indent: 3.0.0
dev: true
/redis-errors/1.2.0:
resolution: {integrity: sha1-62LSrbFeTq9GEMBK/hUpOEJQq60=}
engines: {node: '>=4'}
dev: false
/redis-parser/3.0.0:
resolution: {integrity: sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=}
engines: {node: '>=4'}
dependencies:
redis-errors: 1.2.0
dev: false
/reflect-metadata/0.1.13:
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
dev: false
@ -7213,6 +7260,10 @@ packages:
escape-string-regexp: 2.0.0
dev: true
/standard-as-callback/2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
dev: false
/statuses/1.5.0:
resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=}
engines: {node: '>= 0.6'}
@ -7905,7 +7956,7 @@ packages:
resolution: {integrity: sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=}
dev: false
/typeorm/0.2.41_mysql2@2.3.3:
/typeorm/0.2.41_ioredis@5.0.1+mysql2@2.3.3:
resolution: {integrity: sha512-/d8CLJJxKPgsnrZWiMyPI0rz2MFZnBQrnQ5XP3Vu3mswv2WPexb58QM6BEtmRmlTMYN5KFWUz8SKluze+wS9xw==}
hasBin: true
peerDependencies:
@ -7964,6 +8015,7 @@ packages:
debug: 4.3.3
dotenv: 8.6.0
glob: 7.2.0
ioredis: 5.0.1
js-yaml: 4.1.0
mkdirp: 1.0.4
mysql2: 2.3.3