mirror of https://github.com/fantasticit/think.git
feat: document version support
parent
dbd257caa6
commit
d84603c54f
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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} />
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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 文件存储服务
|
||||
|
|
|
@ -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) : [];
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue