chore: add user in document version

This commit is contained in:
fantasticit 2022-05-26 12:29:50 +08:00
parent c05b98b58b
commit a316f97bb9
6 changed files with 159 additions and 76 deletions

View File

@ -16,34 +16,73 @@
} }
.contentWrap { .contentWrap {
position: relative;
display: flex; display: flex;
height: calc(100vh - 56px); height: calc(100vh - 56px);
margin: 0 -24px; margin: 0 -24px;
flex-wrap: nowrap; flex-wrap: nowrap;
:global { main {
.semi-navigation-inner { padding: 24px;
overflow: auto;
background-color: var(--semi-color-bg-0);
flex: 1;
&.isMobile {
padding: 0;
overflow: hidden;
> div {
height: 100%;
padding: 24px;
margin-top: 71px;
overflow: auto;
}
}
}
aside {
width: 240px;
border-left: 1px solid var(--semi-color-border);
&.isMobile {
position: absolute;
top: 0;
left: 0;
display: flex;
width: 100%;
height: 71px;
padding: 8px 24px;
overflow: auto;
border-left: 0;
border-bottom: 1px solid var(--semi-color-border);
flex-wrap: nowrap;
}
.item {
display: flex;
height: 54px;
padding: 8px 12px;
margin-top: 8px;
white-space: nowrap;
cursor: pointer;
border-radius: var(--semi-border-radius-small);
align-items: center;
flex-direction: column; flex-direction: column;
.semi-navigation-header-list-outer { &.isMobile {
flex: 1; margin: 0;
height: calc(100% - 64px);
} }
.semi-navigation-footer { &:hover {
display: block; color: var(--semi-color-text-1);
background-color: var(--semi-color-fill-1);
.semi-navigation-collapse-btn {
display: flex;
align-items: center;
justify-content: flex-start;
}
}
}
} }
.selected { &.selected {
color: var(--semi-color-primary); color: var(--semi-color-primary);
background-color: var(--semi-color-primary-light-default); background-color: var(--semi-color-primary-light-default);
} }
} }
}
}

View File

@ -1,11 +1,12 @@
import { IconChevronLeft } from '@douyinfe/semi-icons'; import { IconChevronLeft } from '@douyinfe/semi-icons';
import { Button, Layout, Modal, Nav, Typography } from '@douyinfe/semi-ui'; import { Button, Modal, Typography } from '@douyinfe/semi-ui';
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from '@tiptap/react';
import cls from 'classnames'; import cls from 'classnames';
import { DataRender } from 'components/data-render'; import { DataRender } from 'components/data-render';
import { LocaleTime } from 'components/locale-time'; import { LocaleTime } from 'components/locale-time';
import { useDocumentVersion } from 'data/document'; import { useDocumentVersion } from 'data/document';
import { safeJSONParse } from 'helpers/json'; import { safeJSONParse } from 'helpers/json';
import { IsOnMobile } from 'hooks/use-on-mobile';
import { useToggle } from 'hooks/use-toggle'; import { useToggle } from 'hooks/use-toggle';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { CollaborationKit } from 'tiptap/editor'; import { CollaborationKit } from 'tiptap/editor';
@ -18,12 +19,12 @@ interface IProps {
onSelect?: (data) => void; onSelect?: (data) => void;
} }
const { Sider, Content } = Layout; const { Title, Text } = Typography;
const { Title } = Typography;
export const DocumentVersion: React.FC<IProps> = ({ documentId, disabled = false, onSelect }) => { export const DocumentVersion: React.FC<IProps> = ({ documentId, disabled = false, onSelect }) => {
const { isMobile } = IsOnMobile.useHook();
const [visible, toggleVisible] = useToggle(false); const [visible, toggleVisible] = useToggle(false);
const { data, loading, error, refresh } = useDocumentVersion(documentId); const { data, loading, error, refresh } = useDocumentVersion(documentId, { enabled: visible });
const [selectedVersion, setSelectedVersion] = useState(null); const [selectedVersion, setSelectedVersion] = useState(null);
const editor = useEditor({ const editor = useEditor({
@ -83,6 +84,11 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId, disabled = false
</Title> </Title>
</div> </div>
<div>
<Button type="primary" theme="solid" disabled={!onSelect || !selectedVersion} onClick={restore}>
</Button>
</div>
<div> <div>
<Button <Button
theme="light" theme="light"
@ -90,15 +96,10 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId, disabled = false
style={{ marginRight: 8 }} style={{ marginRight: 8 }}
disabled={loading || !!error} disabled={loading || !!error}
loading={loading} loading={loading}
onClick={() => refresh()} onClick={refresh}
> >
</Button> </Button>
{onSelect && (
<Button type="primary" theme="solid" disabled={!selectedVersion} onClick={restore}>
</Button>
)}
</div> </div>
</div> </div>
} }
@ -109,40 +110,35 @@ export const DocumentVersion: React.FC<IProps> = ({ documentId, disabled = false
error={error} error={error}
empty={!loading && !data.length} empty={!loading && !data.length}
normalContent={() => ( normalContent={() => (
<Layout className={styles.contentWrap}> <div className={styles.contentWrap}>
<Sider style={{ backgroundColor: 'var(--semi-color-bg-1)' }}> <main className={cls(isMobile && styles.isMobile)}>
<Nav
style={{ maxWidth: 200, height: '100%' }}
bodyStyle={{ height: '100%' }}
selectedKeys={[selectedVersion]}
footer={{
collapseButton: true,
}}
>
{data.map(({ version, data }) => {
return (
<Nav.Item
key={version}
itemKey={version}
className={cls(selectedVersion && selectedVersion.version === version && styles.selected)}
text={<LocaleTime date={+version} />}
onClick={() => select({ version, data })}
/>
);
})}
</Nav>
</Sider>
<Content
style={{
padding: 16,
backgroundColor: 'var(--semi-color-bg-0)',
}}
>
<div className={'container'}> <div className={'container'}>
<EditorContent editor={editor} /> <EditorContent editor={editor} />
</div> </div>
</Content> </main>
</Layout> <aside className={cls(isMobile && styles.isMobile)}>
{data.map(({ version, data, createUser }) => {
return (
<div
key={version}
className={cls(
styles.item,
isMobile && styles.isMobile,
selectedVersion && selectedVersion.version === version && styles.selected
)}
onClick={() => select({ version, data })}
>
<p>
<LocaleTime date={+version} />
</p>
<p>
<Text>{createUser && createUser.name}</Text>
</p>
</div>
);
})}
</aside>
</div>
)} )}
/> />
</Modal> </Modal>

View File

@ -2,7 +2,7 @@ import { DocumentApiDefinition, IAuthority, IDocument, IUser, IWiki } from '@thi
import { triggerRefreshTocs } from 'event'; import { triggerRefreshTocs } from 'event';
import { useAsyncLoading } from 'hooks/use-async-loading'; import { useAsyncLoading } from 'hooks/use-async-loading';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useQuery } from 'react-query'; import { QueriesOptions, useQuery, UseQueryOptions } from 'react-query';
import { HttpClient } from 'services/http-client'; import { HttpClient } from 'services/http-client';
type IDocumentWithVisitedAt = IDocument & { visitedAt: string }; type IDocumentWithVisitedAt = IDocument & { visitedAt: string };
@ -186,7 +186,10 @@ export const useDocumentDetail = (documentId) => {
* @param documentId * @param documentId
* @returns * @returns
*/ */
export const getDocumentVersion = (documentId, cookie = null): Promise<Array<{ version: string; data: string }>> => { export const getDocumentVersion = (
documentId,
cookie = null
): Promise<Array<{ version: string; data: string; createUser: IUser }>> => {
return HttpClient.request({ return HttpClient.request({
method: DocumentApiDefinition.getVersionById.method, method: DocumentApiDefinition.getVersionById.method,
url: DocumentApiDefinition.getVersionById.client(documentId), url: DocumentApiDefinition.getVersionById.client(documentId),
@ -199,9 +202,14 @@ export const getDocumentVersion = (documentId, cookie = null): Promise<Array<{ v
* @param documentId * @param documentId
* @returns * @returns
*/ */
export const useDocumentVersion = (documentId) => { export const useDocumentVersion = (
const { data, error, isLoading, refetch } = useQuery(DocumentApiDefinition.getVersionById.client(documentId), () => documentId,
getDocumentVersion(documentId) options: UseQueryOptions<Array<{ version: string; data: string; createUser: IUser }>>
) => {
const { data, error, isLoading, refetch } = useQuery(
DocumentApiDefinition.getVersionById.client(documentId),
() => getDocumentVersion(documentId),
options
); );
return { data: data || [], loading: isLoading, error, refresh: refetch }; return { data: data || [], loading: isLoading, error, refresh: refetch };
}; };

View File

@ -176,13 +176,18 @@ export class CollaborationService {
const targetId = requestParameters.get('targetId'); const targetId = requestParameters.get('targetId');
const docType = requestParameters.get('docType'); const docType = requestParameters.get('docType');
const userId = requestParameters.get('userId');
const updateDocument = async (user: OutUser, documentId: string, data) => { const updateDocument = async (user: OutUser, documentId: string, data) => {
await this.documentService.updateDocument(user, documentId, data); await this.documentService.updateDocument(user, documentId, data);
this.debounce( this.debounce(
`onStoreDocumentVersion-${documentId}`, `onStoreDocumentVersion-${documentId}`,
() => { () => {
this.documentVersionService.storeDocumentVersion(documentId, data.content); this.documentVersionService.storeDocumentVersion({
documentId,
data: data.content,
userId,
});
}, },
this.debounceTime * 2 this.debounceTime * 2
); );

View File

@ -1,10 +1,12 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { getConfig } from '@think/config'; import { getConfig } from '@think/config';
import { IDocument } from '@think/domains'; import { IDocument, IUser } from '@think/domains';
import Redis from 'ioredis'; import Redis from 'ioredis';
import * as lodash from 'lodash'; import * as lodash from 'lodash';
type VerisonDataItem = { version: string; data: string }; import { UserService } from './user.service';
type VerisonDataItem = { version: string; data: string; userId: IUser['id']; createUser: IUser };
@Injectable() @Injectable()
export class DocumentVersionService { export class DocumentVersionService {
@ -12,14 +14,36 @@ export class DocumentVersionService {
private max = 0; private max = 0;
private error: string | null = '[think] 文档版本服务启动中'; private error: string | null = '[think] 文档版本服务启动中';
constructor() { constructor(
@Inject(forwardRef(() => UserService))
private readonly userService: UserService
) {
this.init(); this.init();
} }
private versionDataToArray(data: Record<string, string>): Array<VerisonDataItem> { private async withUser(data: Array<Omit<VerisonDataItem, 'createUser'>>): Promise<VerisonDataItem[]> {
return await Promise.all(
data.filter(Boolean).map(async (record) => {
const { userId } = record;
const createUser = await this.userService.findById(userId);
return { ...record, createUser };
})
);
}
private versionDataToArray(data: Record<string, string>): Array<Omit<VerisonDataItem, 'createUser'>> {
return Object.keys(data) return Object.keys(data)
.sort((a, b) => +b - +a) .sort((a, b) => +b - +a)
.map((key) => ({ version: key, data: data[key] })); .map((key) => {
const str = data[key];
try {
const json = JSON.parse(str);
return { version: key, ...json };
} catch (e) {
return null;
}
})
.filter(Boolean);
} }
private async init() { private async init() {
@ -66,7 +90,7 @@ export class DocumentVersionService {
if (this.max <= 0) return; if (this.max <= 0) return;
const res = await this.redis.hgetall(documentId); const res = await this.redis.hgetall(documentId);
if (!res) return; if (!res) return;
const data = this.versionDataToArray(res); const data = await this.versionDataToArray(res);
while (data.length > this.max) { while (data.length > this.max) {
const lastVersion = data.pop().version; const lastVersion = data.pop().version;
@ -80,10 +104,20 @@ export class DocumentVersionService {
* @param data * @param data
* @returns * @returns
*/ */
public async storeDocumentVersion(documentId: IDocument['id'], data: IDocument['content']) { public async storeDocumentVersion(arg: {
documentId: IDocument['id'];
data: IDocument['content'];
userId: IUser['id'];
}) {
if (!this.redis) return; if (!this.redis) return;
const { documentId, data, userId } = arg;
const storeData = JSON.stringify({
data,
userId,
});
const version = '' + Date.now(); const version = '' + Date.now();
await this.redis.hsetnx(documentId, version, data); await this.redis.hsetnx(documentId, version, storeData);
await this.checkCacheLength(documentId); await this.checkCacheLength(documentId);
} }
@ -97,6 +131,7 @@ export class DocumentVersionService {
throw new HttpException(this.error, HttpStatus.NOT_IMPLEMENTED); throw new HttpException(this.error, HttpStatus.NOT_IMPLEMENTED);
} }
const res = await this.redis.hgetall(documentId); const res = await this.redis.hgetall(documentId);
return res ? this.versionDataToArray(res) : []; if (!res) return [];
return await this.withUser(this.versionDataToArray(res));
} }
} }

View File

@ -50,7 +50,7 @@ export class DocumentService {
@Inject(forwardRef(() => ViewService)) @Inject(forwardRef(() => ViewService))
private readonly viewService: ViewService private readonly viewService: ViewService
) { ) {
this.documentVersionService = new DocumentVersionService(); this.documentVersionService = new DocumentVersionService(this.userService);
this.collaborationService = new CollaborationService( this.collaborationService = new CollaborationService(
this.userService, this.userService,
this, this,