feat: document version support

This commit is contained in:
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 #### 可选Redis
如果需要文档版本服务,请在 `@think/config``yaml` 配置中进行 `db.redis` 的配置。
``` ```
docker pull redis:latest docker pull redis:latest
docker run --name think-redis -p 6379:6379 -d redis --appendonly yes --requirepass "root" 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 { joinUser } from 'components/document/collaboration';
import { Banner } from 'components/banner'; import { Banner } from 'components/banner';
import { debounce } from 'helpers/debounce'; import { debounce } from 'helpers/debounce';
import { changeTitle } from './index'; import { em, changeTitle, USE_DATA_VERSION } from './index';
import styles from './index.module.scss'; import styles from './index.module.scss';
interface IProps { 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; const title = transaction.doc.content.firstChild.content.firstChild.textContent;
changeTitle(title); changeTitle(title);
} catch (e) {} } catch (e) {}
}, 200), }, 50),
}); });
const [loading, toggleLoading] = useToggle(true); 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 ( return (
<DataRender <DataRender
loading={loading} loading={loading}

View File

@ -20,13 +20,18 @@ import styles from './index.module.scss';
const { Text } = Typography; const { Text } = Typography;
const em = new EventEmitter(); export const em = new EventEmitter();
const TITLE_CHANGE_EVENT = 'TITLE_CHANGE_EVENT'; const TITLE_CHANGE_EVENT = 'TITLE_CHANGE_EVENT';
export const USE_DATA_VERSION = 'USE_DATA_VERSION';
export const changeTitle = (title) => { export const changeTitle = (title) => {
em.emit(TITLE_CHANGE_EVENT, title); em.emit(TITLE_CHANGE_EVENT, title);
}; };
const useVersion = (data) => {
em.emit(USE_DATA_VERSION, data);
};
interface IProps { interface IProps {
documentId: string; documentId: string;
} }
@ -90,7 +95,7 @@ export const DocumentEditor: React.FC<IProps> = ({ documentId }) => {
<DocumentCollaboration key="collaboration" wikiId={document.wikiId} documentId={documentId} /> <DocumentCollaboration key="collaboration" wikiId={document.wikiId} documentId={documentId} />
)} )}
<DocumentShare key="share" documentId={documentId} /> <DocumentShare key="share" documentId={documentId} />
<DocumentVersion key="version" documentId={documentId} /> <DocumentVersion key="version" documentId={documentId} onSelect={useVersion} />
<DocumentStar key="star" documentId={documentId} /> <DocumentStar key="star" documentId={documentId} />
<Popover key="style" zIndex={1061} position="bottomLeft" content={<DocumentStyle />}> <Popover key="style" zIndex={1061} position="bottomLeft" content={<DocumentStyle />}>
<Button icon={<IconArticle />} theme="borderless" type="tertiary" /> <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 { DocumentStar } from 'components/document/star';
import { DocumentCollaboration } from 'components/document/collaboration'; import { DocumentCollaboration } from 'components/document/collaboration';
import { DocumentStyle } from 'components/document/style'; import { DocumentStyle } from 'components/document/style';
import { DocumentVersion } from 'components/document/version';
import { CommentEditor } from 'components/document/comments'; import { CommentEditor } from 'components/document/comments';
import { useDocumentStyle } from 'hooks/use-document-style'; import { useDocumentStyle } from 'hooks/use-document-style';
import { useWindowSize } from 'hooks/use-window-size'; import { useWindowSize } from 'hooks/use-window-size';
@ -16,7 +17,6 @@ import { useUser } from 'data/user';
import { useDocumentDetail } from 'data/document'; import { useDocumentDetail } from 'data/document';
import { DocumentSkeleton } from 'tiptap'; import { DocumentSkeleton } from 'tiptap';
import { Editor } from './editor'; import { Editor } from './editor';
import { CreateUser } from './user';
import styles from './index.module.scss'; import styles from './index.module.scss';
const { Header } = Layout; const { Header } = Layout;
@ -80,6 +80,7 @@ export const DocumentReader: React.FC<IProps> = ({ documentId }) => {
{authority && authority.readable && ( {authority && authority.readable && (
<> <>
<DocumentShare key="share" documentId={documentId} /> <DocumentShare key="share" documentId={documentId} />
<DocumentVersion key="version" documentId={documentId} />
<DocumentStar key="star" 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 React, { useState, useEffect, useCallback } from 'react';
import { Button, Modal, Input, Typography, Toast, Layout, Nav } from '@douyinfe/semi-ui'; import { Button, Modal, Typography } from '@douyinfe/semi-ui';
import { IconClose } from '@douyinfe/semi-icons';
import { useEditor, EditorContent } from '@tiptap/react'; import { useEditor, EditorContent } from '@tiptap/react';
import cls from 'classnames';
import { IconLink } from '@douyinfe/semi-icons';
import { isPublicDocument } from '@think/domains';
import { DEFAULT_EXTENSION, DocumentWithTitle } from 'tiptap'; import { DEFAULT_EXTENSION, DocumentWithTitle } from 'tiptap';
import { safeJSONParse } from 'helpers/json'; import { safeJSONParse } from 'helpers/json';
import { ShareIllustration } from 'illustrations/share';
import { DataRender } from 'components/data-render'; import { DataRender } from 'components/data-render';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import { useDocumentVersion } from 'data/document'; import { useDocumentVersion } from 'data/document';
import styles from './index.module.scss';
interface IProps { interface IProps {
documentId: string; documentId: string;
onSelect?: (data) => void;
} }
const { Text } = Typography; const { Title } = Typography;
const { Header, Footer, Sider, Content } = Layout;
export const DocumentVersion: React.FC<IProps> = ({ documentId }) => { export const DocumentVersion: React.FC<IProps> = ({ documentId, onSelect }) => {
const [visible, toggleVisible] = useToggle(false); const [visible, toggleVisible] = useToggle(false);
const { data, loading, error, refresh } = useDocumentVersion(documentId); const { data, loading, error, refresh } = useDocumentVersion(documentId);
const [selectedVersion, setSelectedVersion] = useState(null); const [selectedVersion, setSelectedVersion] = useState(null);
@ -29,6 +28,11 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
content: {}, content: {},
}); });
const close = useCallback(() => {
toggleVisible(false);
setSelectedVersion(null);
}, []);
const select = useCallback( const select = useCallback(
(version) => { (version) => {
setSelectedVersion(version); setSelectedVersion(version);
@ -37,12 +41,25 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
[editor] [editor]
); );
const restore = useCallback(() => {
if (!selectedVersion || !onSelect) return;
onSelect(safeJSONParse(selectedVersion.data, { default: {} }).default);
close();
}, [selectedVersion]);
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
refresh(); refresh();
} }
}, [visible]); }, [visible]);
useEffect(() => {
if (!editor) return;
if (!data.length) return;
if (selectedVersion) return;
select(data[0]);
}, [editor, data, selectedVersion]);
return ( return (
<> <>
<Button type="primary" theme="light" onClick={toggleVisible}> <Button type="primary" theme="light" onClick={toggleVisible}>
@ -52,30 +69,64 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId }) => {
title="历史记录" title="历史记录"
fullScreen fullScreen
visible={visible} visible={visible}
onOk={() => toggleVisible(false)} style={{ padding: 0 }}
onCancel={() => toggleVisible(false)} 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' }}> <DataRender
<Sider> loading={loading}
<Nav error={error}
bodyStyle={{ height: 'calc(100vh - 96px)', overflow: 'auto' }} normalContent={() => (
defaultOpenKeys={['job']} <div className={styles.contentWrap}>
items={data.map(({ version, data }) => { <aside>
return { itemKey: version, text: version, onClick: () => select({ version, data }) }; <ul>
})} {data.map(({ version, data }) => {
/> return (
</Sider> <li
<Content> key={version}
<Layout className="components-layout-demo"> className={cls(selectedVersion && selectedVersion.version === version && styles.selected)}
<Header>Header</Header> onClick={() => select({ version, data })}
<Content> >
<div className="container" style={{ paddingBottom: 48 }}> {version}
</li>
);
})}
</ul>
</aside>
<main>
<div className={cls('container', styles.editorWrap)}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
</div> </div>
</Content> </main>
</Layout> </div>
</Content> )}
</Layout> />
</Modal> </Modal>
</> </>
); );

View File

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

View File

@ -3,6 +3,8 @@ server:
prefix: '/api' prefix: '/api'
port: 5001 port: 5001
collaborationPort: 5003 collaborationPort: 5003
# 最大版本记录数
maxDocumentVersion: 20
client: client:
assetPrefix: '/' assetPrefix: '/'
@ -23,7 +25,6 @@ db:
redis: redis:
host: '127.0.0.1' host: '127.0.0.1'
port: '6379' port: '6379'
db: 0
password: 'root' password: 'root'
# oss 文件存储服务 # 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 Redis from 'ioredis';
import { DocumentStatus, IDocument } from '@think/domains'; import { IDocument } from '@think/domains';
import { getConfig } from '@think/config'; import { getConfig } from '@think/config';
import * as lodash from 'lodash'; import * as lodash from 'lodash';
@Injectable() @Injectable()
export class DocumentVersionService { export class DocumentVersionService {
private redis: Redis; private redis: Redis;
private max: number = 0;
private error: string | null = '文档版本服务启动中';
constructor() { constructor() {
this.init(); 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) return Object.keys(data)
.sort((a, b) => +b - +a) .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() { private async init() {
const config = getConfig(); 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 { try {
const redis = new Redis(redisConfig); const redis = new Redis({
this.redis = redis; ...redisConfig,
} catch (e) { db: 0,
this.redis = null; 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 []; *
* @param documentId
return new Promise((resolve, reject) => { * @param data
this.redis.hgetall(documentId, (err, ret) => { * @returns
if (err) { */
reject(err);
} else {
resolve(ret ? this.versionDataToArray(ret) : []);
}
});
});
}
public async storeDocumentVersion(documentId: IDocument['id'], data: IDocument['content']) { public async storeDocumentVersion(documentId: IDocument['id'], data: IDocument['content']) {
if (!this.redis) return; if (!this.redis) return;
const version = '' + Date.now(); 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) : [];
} }
} }