feat: document version support

pull/22/head
fantasticit 2022-03-30 12:25:08 +08:00
parent dbd257caa6
commit d84603c54f
9 changed files with 251 additions and 61 deletions

View File

@ -58,6 +58,8 @@ CREATE DATABASE `think` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_c
#### 可选Redis
如果需要文档版本服务,请在 `@think/config``yaml` 配置中进行 `db.redis` 的配置。
```
docker pull redis:latest
docker run --name think-redis -p 6379:6379 -d redis --appendonly yes --requirepass "root"

View File

@ -21,7 +21,7 @@ import { DataRender } from 'components/data-render';
import { joinUser } from 'components/document/collaboration';
import { Banner } from 'components/banner';
import { debounce } from 'helpers/debounce';
import { changeTitle } from './index';
import { em, changeTitle, USE_DATA_VERSION } from './index';
import styles from './index.module.scss';
interface IProps {
@ -64,7 +64,7 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
const title = transaction.doc.content.firstChild.content.firstChild.textContent;
changeTitle(title);
} catch (e) {}
}, 200),
}, 50),
});
const [loading, toggleLoading] = useToggle(true);
@ -89,6 +89,16 @@ export const Editor: React.FC<IProps> = ({ user, documentId, authority, classNam
};
}, []);
useEffect(() => {
if (!editor) return;
const handler = (data) => editor.commands.setContent(data);
em.on(USE_DATA_VERSION, handler);
return () => {
em.off(USE_DATA_VERSION, handler);
};
}, [editor]);
return (
<DataRender
loading={loading}

View File

@ -20,13 +20,18 @@ import styles from './index.module.scss';
const { Text } = Typography;
const em = new EventEmitter();
export const em = new EventEmitter();
const TITLE_CHANGE_EVENT = 'TITLE_CHANGE_EVENT';
export const USE_DATA_VERSION = 'USE_DATA_VERSION';
export const changeTitle = (title) => {
em.emit(TITLE_CHANGE_EVENT, title);
};
const useVersion = (data) => {
em.emit(USE_DATA_VERSION, data);
};
interface IProps {
documentId: string;
}
@ -90,7 +95,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} />
<DocumentVersion key="version" documentId={documentId} onSelect={useVersion} />
<DocumentStar key="star" documentId={documentId} />
<Popover key="style" zIndex={1061} position="bottomLeft" content={<DocumentStyle />}>
<Button icon={<IconArticle />} theme="borderless" type="tertiary" />

View File

@ -9,6 +9,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 { CommentEditor } from 'components/document/comments';
import { useDocumentStyle } from 'hooks/use-document-style';
import { useWindowSize } from 'hooks/use-window-size';
@ -16,7 +17,6 @@ import { useUser } from 'data/user';
import { useDocumentDetail } from 'data/document';
import { DocumentSkeleton } from 'tiptap';
import { Editor } from './editor';
import { CreateUser } from './user';
import styles from './index.module.scss';
const { Header } = Layout;
@ -80,6 +80,7 @@ export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
{authority && authority.readable && (
<>
<DocumentShare key="share" documentId={documentId} />
<DocumentVersion key="version" documentId={documentId} />
<DocumentStar key="star" documentId={documentId} />
</>
)}

View File

@ -0,0 +1,71 @@
.headerWrap {
margin: 0 -24px;
position: relative;
z-index: 1000;
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--semi-color-border);
> div {
display: flex;
flex-wrap: nowrap;
align-items: center;
}
}
.contentWrap {
margin: 0 -24px;
height: calc(100vh - 56px);
display: flex;
flex-wrap: nowrap;
> aside {
width: 240px;
height: 100%;
padding: 12px 0;
flex-shrink: 0;
border-right: 1px solid var(--semi-color-border);
overflow: auto;
> ul {
margin: 0;
padding: 0;
list-style: none;
> li {
width: 100%;
padding: 12px 16px;
color: var(--semi-color-text-0);
font-size: 14px;
border-radius: var(--semi-border-radius-small);
text-align: center;
cursor: pointer;
&:hover {
background-color: var(--semi-color-primary-light-default);
}
&.selected {
color: var(--semi-color-primary);
background-color: var(--semi-color-primary-light-default);
}
}
}
}
> main {
flex: 1;
background-color: var(--semi-color-nav-bg);
padding: 24px 0;
overflow: auto;
.editorWrap {
min-height: 100%;
padding: 12px 24px;
background-color: var(--semi-color-bg-2);
border: 1px solid var(--semi-color-border);
}
}
}

View File

@ -1,24 +1,23 @@
import React, { useMemo, useState, useEffect, useCallback } from 'react';
import { Button, Modal, Input, Typography, Toast, Layout, Nav } from '@douyinfe/semi-ui';
import React, { useState, useEffect, useCallback } from 'react';
import { Button, Modal, Typography } from '@douyinfe/semi-ui';
import { IconClose } from '@douyinfe/semi-icons';
import { useEditor, EditorContent } from '@tiptap/react';
import { IconLink } from '@douyinfe/semi-icons';
import { isPublicDocument } from '@think/domains';
import cls from 'classnames';
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';
import styles from './index.module.scss';
interface IProps {
documentId: string;
onSelect?: (data) => void;
}
const { Text } = Typography;
const { Header, Footer, Sider, Content } = Layout;
const { Title } = Typography;
export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
export const DocumentVersion: React.FC<IProps> = ({ documentId, onSelect }) => {
const [visible, toggleVisible] = useToggle(false);
const { data, loading, error, refresh } = useDocumentVersion(documentId);
const [selectedVersion, setSelectedVersion] = useState(null);
@ -29,6 +28,11 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
content: {},
});
const close = useCallback(() => {
toggleVisible(false);
setSelectedVersion(null);
}, []);
const select = useCallback(
(version) => {
setSelectedVersion(version);
@ -37,12 +41,25 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
[editor]
);
const restore = useCallback(() => {
if (!selectedVersion || !onSelect) return;
onSelect(safeJSONParse(selectedVersion.data, { default: {} }).default);
close();
}, [selectedVersion]);
useEffect(() => {
if (visible) {
refresh();
}
}, [visible]);
useEffect(() => {
if (!editor) return;
if (!data.length) return;
if (selectedVersion) return;
select(data[0]);
}, [editor, data, selectedVersion]);
return (
<>
<Button type="primary" theme="light" onClick={toggleVisible}>
@ -52,30 +69,64 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
title="历史记录"
fullScreen
visible={visible}
onOk={() => toggleVisible(false)}
onCancel={() => toggleVisible(false)}
style={{ padding: 0 }}
bodyStyle={{ padding: 0 }}
header={
<div className={styles.headerWrap}>
<div>
<Button icon={<IconClose />} onClick={close} />
<Title heading={5} style={{ marginLeft: 12 }}>
</Title>
</div>
<div>
<Button
theme="light"
type="primary"
style={{ marginRight: 8 }}
disabled={loading || error}
onClick={() => refresh()}
>
</Button>
{onSelect && (
<Button type="primary" theme="solid" disabled={!selectedVersion} onClick={restore}>
</Button>
)}
</div>
</div>
}
footer={null}
>
<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 }}>
<DataRender
loading={loading}
error={error}
normalContent={() => (
<div className={styles.contentWrap}>
<aside>
<ul>
{data.map(({ version, data }) => {
return (
<li
key={version}
className={cls(selectedVersion && selectedVersion.version === version && styles.selected)}
onClick={() => select({ version, data })}
>
{version}
</li>
);
})}
</ul>
</aside>
<main>
<div className={cls('container', styles.editorWrap)}>
<EditorContent editor={editor} />
</div>
</Content>
</Layout>
</Content>
</Layout>
</main>
</div>
)}
/>
</Modal>
</>
);

View File

@ -84,7 +84,8 @@ export const useDocumentDetail = (documentId, options = null) => {
export const useDocumentVersion = (documentId) => {
const { data, error, mutate } = useSWR<Array<{ version: string; data: string }>>(
`/document/version/${documentId}`,
(url) => HttpClient.get(url)
(url) => HttpClient.get(url),
{ errorRetryCount: 0 }
);
const loading = !data && !error;
return { data: data || [], loading, error, refresh: mutate };

View File

@ -3,6 +3,8 @@ server:
prefix: '/api'
port: 5001
collaborationPort: 5003
# 最大版本记录数
maxDocumentVersion: 20
client:
assetPrefix: '/'
@ -23,7 +25,6 @@ db:
redis:
host: '127.0.0.1'
port: '6379'
db: 0
password: 'root'
# oss 文件存储服务

View File

@ -1,52 +1,100 @@
import { Injectable, HttpException, HttpStatus, Inject, forwardRef } from '@nestjs/common';
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
import Redis from 'ioredis';
import { DocumentStatus, IDocument } from '@think/domains';
import { IDocument } from '@think/domains';
import { getConfig } from '@think/config';
import * as lodash from 'lodash';
@Injectable()
export class DocumentVersionService {
private redis: Redis;
private max: number = 0;
private error: string | null = '文档版本服务启动中';
constructor() {
this.init();
}
private versionDataToArray(data: Record<string, string>): Array<{ version: string; data: string }> {
private versionDataToArray(
data: Record<string, string>
): Array<{ originVerison: string; version: string; data: string }> {
return Object.keys(data)
.sort((a, b) => +b - +a)
.map((key) => ({ version: new Date(+key).toLocaleString(), data: data[key] }));
.map((key) => ({ originVerison: key, version: new Date(+key).toLocaleString(), data: data[key] }));
}
private async init() {
const config = getConfig();
const redisConfig = lodash.get(config, 'db.redis', {});
const redisConfig = lodash.get(config, 'db.redis', null);
if (!redisConfig) {
console.error('Redis 未配置,无法启动文档版本服务');
return;
}
this.max = lodash.get(config, 'server.maxDocumentVersion', 0);
try {
const redis = new Redis(redisConfig);
this.redis = redis;
} catch (e) {
this.redis = null;
const redis = new Redis({
...redisConfig,
db: 0,
showFriendlyErrorStack: true,
lazyConnect: true,
});
redis.on('ready', () => {
console.log('文档版本服务启动成功');
this.redis = redis;
this.error = null;
});
redis.on('error', (e) => {
console.error(`Redis 启动失败: "${e}"`);
});
redis.connect().catch((e) => {
this.redis = null;
this.error = 'Redis 启动失败:无法提供文档版本服务';
});
} catch (e) {}
}
/**
* max
* @param key
* @returns
*/
public async checkCacheLength(documentId) {
if (this.max <= 0) return;
const res = await this.redis.hgetall(documentId);
if (!res) return;
const data = this.versionDataToArray(res);
while (data.length > this.max) {
const lastVersion = data.pop().originVerison;
await this.redis.hdel(documentId, lastVersion);
}
}
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) : []);
}
});
});
}
/**
*
* @param documentId
* @param data
* @returns
*/
public async storeDocumentVersion(documentId: IDocument['id'], data: IDocument['content']) {
if (!this.redis) return;
const version = '' + Date.now();
this.redis.hsetnx(documentId, version, data);
await this.redis.hsetnx(documentId, version, data);
await this.checkCacheLength(documentId);
}
/**
*
* @param documentId
* @returns
*/
public async getDocumentVersions(documentId: IDocument['id']): Promise<Array<{ version: string; data: string }>> {
if (this.error || !this.redis) {
throw new HttpException(this.error, HttpStatus.NOT_IMPLEMENTED);
}
const res = await this.redis.hgetall(documentId);
return res ? this.versionDataToArray(res) : [];
}
}