mirror of https://github.com/fantasticit/think.git
Merge pull request #22 from fantasticit/feat/version
This commit is contained in:
commit
c1e21dac44
|
@ -56,6 +56,15 @@ mysql -u root -p;
|
||||||
CREATE DATABASE `think` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
CREATE DATABASE `think` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### 可选:Redis
|
||||||
|
|
||||||
|
如果需要文档版本服务,请在 `@think/config` 的 `yaml` 配置中进行 `db.redis` 的配置。
|
||||||
|
|
||||||
|
```
|
||||||
|
docker pull redis:latest
|
||||||
|
docker run --name think-redis -p 6379:6379 -d redis --appendonly yes --requirepass "root"
|
||||||
|
```
|
||||||
|
|
||||||
### 本地运行
|
### 本地运行
|
||||||
|
|
||||||
首先,clone 项目。
|
首先,clone 项目。
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -12,6 +12,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 { useDocumentStyle } from 'hooks/use-document-style';
|
import { useDocumentStyle } from 'hooks/use-document-style';
|
||||||
import { EventEmitter } from 'helpers/event-emitter';
|
import { EventEmitter } from 'helpers/event-emitter';
|
||||||
import { Editor } from './editor';
|
import { Editor } from './editor';
|
||||||
|
@ -19,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;
|
||||||
}
|
}
|
||||||
|
@ -89,6 +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} 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" />
|
||||||
|
|
|
@ -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} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,133 @@
|
||||||
|
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 cls from 'classnames';
|
||||||
|
import { DEFAULT_EXTENSION, DocumentWithTitle } from 'tiptap';
|
||||||
|
import { safeJSONParse } from 'helpers/json';
|
||||||
|
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 { Title } = Typography;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
const editor = useEditor({
|
||||||
|
editable: false,
|
||||||
|
extensions: [...DEFAULT_EXTENSION, DocumentWithTitle],
|
||||||
|
content: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
toggleVisible(false);
|
||||||
|
setSelectedVersion(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const select = useCallback(
|
||||||
|
(version) => {
|
||||||
|
setSelectedVersion(version);
|
||||||
|
editor.commands.setContent(safeJSONParse(version.data, { default: {} }).default);
|
||||||
|
},
|
||||||
|
[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}>
|
||||||
|
文档版本
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
title="历史记录"
|
||||||
|
fullScreen
|
||||||
|
visible={visible}
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
|
@ -76,6 +76,21 @@ export const useDocumentDetail = (documentId, options = null) => {
|
||||||
return { data, loading, error, update, toggleStatus };
|
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),
|
||||||
|
{ errorRetryCount: 0 }
|
||||||
|
);
|
||||||
|
const loading = !data && !error;
|
||||||
|
return { data: data || [], loading, error, refresh: mutate };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取知识库最近更新的10条文档
|
* 获取知识库最近更新的10条文档
|
||||||
* @returns
|
* @returns
|
||||||
|
|
|
@ -3,6 +3,8 @@ server:
|
||||||
prefix: '/api'
|
prefix: '/api'
|
||||||
port: 5001
|
port: 5001
|
||||||
collaborationPort: 5003
|
collaborationPort: 5003
|
||||||
|
# 最大版本记录数
|
||||||
|
maxDocumentVersion: 20
|
||||||
|
|
||||||
client:
|
client:
|
||||||
assetPrefix: '/'
|
assetPrefix: '/'
|
||||||
|
@ -20,6 +22,10 @@ db:
|
||||||
charset: 'utf8mb4'
|
charset: 'utf8mb4'
|
||||||
timezone: '+08:00'
|
timezone: '+08:00'
|
||||||
synchronize: true
|
synchronize: true
|
||||||
|
redis:
|
||||||
|
host: '127.0.0.1'
|
||||||
|
port: '6379'
|
||||||
|
password: 'root'
|
||||||
|
|
||||||
# oss 文件存储服务
|
# oss 文件存储服务
|
||||||
oss:
|
oss:
|
||||||
|
|
|
@ -38,6 +38,7 @@
|
||||||
"express": "^4.17.2",
|
"express": "^4.17.2",
|
||||||
"express-rate-limit": "^6.2.0",
|
"express-rate-limit": "^6.2.0",
|
||||||
"helmet": "^5.0.2",
|
"helmet": "^5.0.2",
|
||||||
|
"ioredis": "^5.0.1",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mysql2": "^2.3.3",
|
"mysql2": "^2.3.3",
|
||||||
"nuid": "^1.1.6",
|
"nuid": "^1.1.6",
|
||||||
|
|
|
@ -48,6 +48,14 @@ export class DocumentController {
|
||||||
return await this.documentService.updateDocument(req.user, documentId, dto);
|
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)
|
@UseInterceptors(ClassSerializerInterceptor)
|
||||||
@Post('children')
|
@Post('children')
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
|
|
|
@ -8,12 +8,13 @@ import * as lodash from 'lodash';
|
||||||
import { OutUser, UserService } from '@services/user.service';
|
import { OutUser, UserService } from '@services/user.service';
|
||||||
import { TemplateService } from '@services/template.service';
|
import { TemplateService } from '@services/template.service';
|
||||||
import { DocumentService } from '@services/document.service';
|
import { DocumentService } from '@services/document.service';
|
||||||
|
import { DocumentVersionService } from '@services/document-version.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CollaborationService {
|
export class CollaborationService {
|
||||||
server: typeof Server;
|
server: typeof Server;
|
||||||
debounceTime: 2000;
|
debounceTime = 1000;
|
||||||
maxDebounceTime: 10000;
|
maxDebounceTime = 10000;
|
||||||
timers: Map<
|
timers: Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
@ -28,12 +29,14 @@ export class CollaborationService {
|
||||||
@Inject(forwardRef(() => DocumentService))
|
@Inject(forwardRef(() => DocumentService))
|
||||||
private readonly documentService: DocumentService,
|
private readonly documentService: DocumentService,
|
||||||
@Inject(forwardRef(() => TemplateService))
|
@Inject(forwardRef(() => TemplateService))
|
||||||
private readonly templateService: TemplateService
|
private readonly templateService: TemplateService,
|
||||||
|
@Inject(forwardRef(() => DocumentVersionService))
|
||||||
|
private readonly documentVersionService: DocumentVersionService
|
||||||
) {
|
) {
|
||||||
this.initServer();
|
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 old = this.timers.get(id);
|
||||||
const start = old?.start || Date.now();
|
const start = old?.start || Date.now();
|
||||||
|
|
||||||
|
@ -56,7 +59,7 @@ export class CollaborationService {
|
||||||
|
|
||||||
this.timers.set(id, {
|
this.timers.set(id, {
|
||||||
start,
|
start,
|
||||||
timeout: setTimeout(run, this.debounceTime),
|
timeout: setTimeout(run, debounceTime),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -181,10 +184,19 @@ export class CollaborationService {
|
||||||
const targetId = requestParameters.get('targetId');
|
const targetId = requestParameters.get('targetId');
|
||||||
const docType = requestParameters.get('docType');
|
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 =
|
const updateHandler =
|
||||||
docType === 'document'
|
docType === 'document' ? updateDocument : this.templateService.updateTemplate.bind(this.templateService);
|
||||||
? this.documentService.updateDocument.bind(this.documentService)
|
|
||||||
: this.templateService.updateTemplate.bind(this.templateService);
|
|
||||||
|
|
||||||
this.debounce(`onStoreDocument-${targetId}`, () => {
|
this.debounce(`onStoreDocument-${targetId}`, () => {
|
||||||
this.onStoreDocument(updateHandler, data).catch((error) => {
|
this.onStoreDocument(updateHandler, data).catch((error) => {
|
||||||
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
import { Injectable, HttpException, HttpStatus } from '@nestjs/common';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
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<{ originVerison: string; version: string; data: string }> {
|
||||||
|
return Object.keys(data)
|
||||||
|
.sort((a, b) => +b - +a)
|
||||||
|
.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', null);
|
||||||
|
|
||||||
|
if (!redisConfig) {
|
||||||
|
console.error('Redis 未配置,无法启动文档版本服务');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.max = lodash.get(config, 'server.maxDocumentVersion', 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存文档版本数据
|
||||||
|
* @param documentId
|
||||||
|
* @param data
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
public async storeDocumentVersion(documentId: IDocument['id'], data: IDocument['content']) {
|
||||||
|
if (!this.redis) return;
|
||||||
|
const version = '' + Date.now();
|
||||||
|
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) : [];
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,7 @@ import { OutUser, UserService } from '@services/user.service';
|
||||||
import { WikiService } from '@services/wiki.service';
|
import { WikiService } from '@services/wiki.service';
|
||||||
import { MessageService } from '@services/message.service';
|
import { MessageService } from '@services/message.service';
|
||||||
import { CollaborationService } from '@services/collaboration.service';
|
import { CollaborationService } from '@services/collaboration.service';
|
||||||
|
import { DocumentVersionService } from '@services/document-version.service';
|
||||||
import { TemplateService } from '@services/template.service';
|
import { TemplateService } from '@services/template.service';
|
||||||
import { ViewService } from '@services/view.service';
|
import { ViewService } from '@services/view.service';
|
||||||
import { array2tree } from '@helpers/tree.helper';
|
import { array2tree } from '@helpers/tree.helper';
|
||||||
|
@ -53,6 +54,7 @@ const DOCUMENT_PLACEHOLDERS = [
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocumentService {
|
export class DocumentService {
|
||||||
private collaborationService: CollaborationService;
|
private collaborationService: CollaborationService;
|
||||||
|
private documentVersionService: DocumentVersionService;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(DocumentAuthorityEntity)
|
@InjectRepository(DocumentAuthorityEntity)
|
||||||
|
@ -70,7 +72,13 @@ export class DocumentService {
|
||||||
@Inject(forwardRef(() => ViewService))
|
@Inject(forwardRef(() => ViewService))
|
||||||
private readonly viewService: 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 };
|
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
|
* @param id
|
||||||
|
|
|
@ -243,6 +243,7 @@ importers:
|
||||||
express: ^4.17.2
|
express: ^4.17.2
|
||||||
express-rate-limit: ^6.2.0
|
express-rate-limit: ^6.2.0
|
||||||
helmet: ^5.0.2
|
helmet: ^5.0.2
|
||||||
|
ioredis: ^5.0.1
|
||||||
jest: ^27.2.5
|
jest: ^27.2.5
|
||||||
lodash: ^4.17.21
|
lodash: ^4.17.21
|
||||||
mysql2: ^2.3.3
|
mysql2: ^2.3.3
|
||||||
|
@ -286,6 +287,7 @@ importers:
|
||||||
express: 4.17.2
|
express: 4.17.2
|
||||||
express-rate-limit: 6.2.0_express@4.17.2
|
express-rate-limit: 6.2.0_express@4.17.2
|
||||||
helmet: 5.0.2
|
helmet: 5.0.2
|
||||||
|
ioredis: 5.0.1
|
||||||
lodash: 4.17.21
|
lodash: 4.17.21
|
||||||
mysql2: 2.3.3
|
mysql2: 2.3.3
|
||||||
nuid: 1.1.6
|
nuid: 1.1.6
|
||||||
|
@ -295,7 +297,7 @@ importers:
|
||||||
reflect-metadata: 0.1.13
|
reflect-metadata: 0.1.13
|
||||||
rimraf: 3.0.2
|
rimraf: 3.0.2
|
||||||
rxjs: 7.5.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
|
ua-parser-js: 1.0.2
|
||||||
y-prosemirror: 1.0.14_8fd72c89aecefb95d86a797b5207d945
|
y-prosemirror: 1.0.14_8fd72c89aecefb95d86a797b5207d945
|
||||||
yjs: 13.5.24
|
yjs: 13.5.24
|
||||||
|
@ -945,6 +947,10 @@ packages:
|
||||||
resolution: {integrity: sha512-YRsVFWjL8Gkkvlx3qnjeaxW4fnibSJ9791g8BA7Pv5ANByI64WmtR1vU7A2rXcrOn8XvyCEfY0ss1s8NhZP+MA==}
|
resolution: {integrity: sha512-YRsVFWjL8Gkkvlx3qnjeaxW4fnibSJ9791g8BA7Pv5ANByI64WmtR1vU7A2rXcrOn8XvyCEfY0ss1s8NhZP+MA==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@ioredis/commands/1.1.1:
|
||||||
|
resolution: {integrity: sha512-fsR4P/ROllzf/7lXYyElUJCheWdTJVJvOTps8v9IWKFATxR61ANOlnoPqhH099xYLrJGpc2ZQ28B3rMeUt5VQg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@istanbuljs/load-nyc-config/1.1.0:
|
/@istanbuljs/load-nyc-config/1.1.0:
|
||||||
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -1367,7 +1373,7 @@ packages:
|
||||||
'@nestjs/core': 8.2.6_2d4ee36c4446df4873bb38fb1c4583da
|
'@nestjs/core': 8.2.6_2d4ee36c4446df4873bb38fb1c4583da
|
||||||
reflect-metadata: 0.1.13
|
reflect-metadata: 0.1.13
|
||||||
rxjs: 7.5.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
|
||||||
uuid: 8.3.2
|
uuid: 8.3.2
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
@ -2997,6 +3003,11 @@ packages:
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: false
|
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:
|
/co-defer/1.0.0:
|
||||||
resolution: {integrity: sha1-Pkp4eo7tawoh7ih8CU9+jeDTyBg=}
|
resolution: {integrity: sha1-Pkp4eo7tawoh7ih8CU9+jeDTyBg=}
|
||||||
engines: {node: '>= 0.11.14'}
|
engines: {node: '>= 0.11.14'}
|
||||||
|
@ -3313,7 +3324,6 @@ packages:
|
||||||
optional: true
|
optional: true
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.2
|
ms: 2.1.2
|
||||||
dev: true
|
|
||||||
|
|
||||||
/decamelize-keys/1.1.0:
|
/decamelize-keys/1.1.0:
|
||||||
resolution: {integrity: sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=}
|
resolution: {integrity: sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=}
|
||||||
|
@ -4445,6 +4455,23 @@ packages:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
dev: false
|
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:
|
/ip/1.1.5:
|
||||||
resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=}
|
resolution: {integrity: sha1-vd7XARQpCCjAoDnnLvJfWq7ENUo=}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -5453,10 +5480,18 @@ packages:
|
||||||
p-locate: 4.1.0
|
p-locate: 4.1.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/lodash.defaults/4.2.0:
|
||||||
|
resolution: {integrity: sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw=}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/lodash.includes/4.3.0:
|
/lodash.includes/4.3.0:
|
||||||
resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=}
|
resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/lodash.isarguments/3.1.0:
|
||||||
|
resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/lodash.isboolean/3.0.3:
|
/lodash.isboolean/3.0.3:
|
||||||
resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=}
|
resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -6780,6 +6815,18 @@ packages:
|
||||||
strip-indent: 3.0.0
|
strip-indent: 3.0.0
|
||||||
dev: true
|
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:
|
/reflect-metadata/0.1.13:
|
||||||
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
|
resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -7213,6 +7260,10 @@ packages:
|
||||||
escape-string-regexp: 2.0.0
|
escape-string-regexp: 2.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/standard-as-callback/2.1.0:
|
||||||
|
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/statuses/1.5.0:
|
/statuses/1.5.0:
|
||||||
resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=}
|
resolution: {integrity: sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow=}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
@ -7905,7 +7956,7 @@ packages:
|
||||||
resolution: {integrity: sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=}
|
resolution: {integrity: sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=}
|
||||||
dev: false
|
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==}
|
resolution: {integrity: sha512-/d8CLJJxKPgsnrZWiMyPI0rz2MFZnBQrnQ5XP3Vu3mswv2WPexb58QM6BEtmRmlTMYN5KFWUz8SKluze+wS9xw==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -7964,6 +8015,7 @@ packages:
|
||||||
debug: 4.3.3
|
debug: 4.3.3
|
||||||
dotenv: 8.6.0
|
dotenv: 8.6.0
|
||||||
glob: 7.2.0
|
glob: 7.2.0
|
||||||
|
ioredis: 5.0.1
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
mkdirp: 1.0.4
|
mkdirp: 1.0.4
|
||||||
mysql2: 2.3.3
|
mysql2: 2.3.3
|
||||||
|
|
Loading…
Reference in New Issue