mirror of https://github.com/fantasticit/think.git
chore: add user in document version
This commit is contained in:
parent
c05b98b58b
commit
a316f97bb9
|
@ -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;
|
||||||
flex-direction: column;
|
overflow: auto;
|
||||||
|
background-color: var(--semi-color-bg-0);
|
||||||
|
flex: 1;
|
||||||
|
|
||||||
.semi-navigation-header-list-outer {
|
&.isMobile {
|
||||||
flex: 1;
|
padding: 0;
|
||||||
height: calc(100% - 64px);
|
overflow: hidden;
|
||||||
}
|
|
||||||
|
|
||||||
.semi-navigation-footer {
|
> div {
|
||||||
display: block;
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
.semi-navigation-collapse-btn {
|
margin-top: 71px;
|
||||||
display: flex;
|
overflow: auto;
|
||||||
align-items: center;
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
aside {
|
||||||
color: var(--semi-color-primary);
|
width: 240px;
|
||||||
background-color: var(--semi-color-primary-light-default);
|
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;
|
||||||
|
|
||||||
|
&.isMobile {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--semi-color-text-1);
|
||||||
|
background-color: var(--semi-color-fill-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
color: var(--semi-color-primary);
|
||||||
|
background-color: var(--semi-color-primary-light-default);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 };
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
||||||
);
|
);
|
||||||
|
|
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue