feat: add admin

This commit is contained in:
fantasticit 2022-06-28 17:11:26 +08:00
parent f9aaa13d0d
commit 43cfc2e339
50 changed files with 2083 additions and 406 deletions

View File

@ -23,6 +23,15 @@ server:
enableRateLimit: true # 是否限流
rateLimitWindowMs: 60000 # 限流时间
rateLimitMax: 1000 # 单位限流时间内单个 ip 最大访问数量
email: # 邮箱服务,参考 http://help.163.com/09/1223/14/5R7P6CJ600753VB8.html?servCode=6010376 获取 SMTP 配置
host: ''
port: 465
user: ''
password: ''
admin:
name: 'sytemadmin' # 注意修改
password: 'sytemadmin' # 注意修改
email: 'sytemadmin@think.com' # 注意修改为真实邮箱地址
# 数据库配置
db:

View File

@ -0,0 +1,29 @@
import { TabPane, Tabs } from '@douyinfe/semi-ui';
import React from 'react';
import { Mail } from './mail';
import { System } from './system';
interface IProps {
tab?: string;
onNavigate: (arg: string) => void;
}
const TitleMap = {
base: '系统管理',
mail: '邮箱服务',
};
export const SystemConfig: React.FC<IProps> = ({ tab, onNavigate }) => {
return (
<Tabs lazyRender type="line" activeKey={tab} onChange={onNavigate}>
<TabPane tab={TitleMap['base']} itemKey="base">
<System />
</TabPane>
<TabPane tab={TitleMap['mail']} itemKey="mail">
<Mail />
</TabPane>
</Tabs>
);
};

View File

@ -0,0 +1,88 @@
import { Banner, Button, Form, Toast } from '@douyinfe/semi-ui';
import { DataRender } from 'components/data-render';
import { useSystemConfig } from 'data/user';
import { useToggle } from 'hooks/use-toggle';
import React, { useCallback } from 'react';
export const Mail = () => {
const { data, loading, error, sendTestEmail, updateSystemConfig } = useSystemConfig();
const [changed, toggleChanged] = useToggle(false);
const onFormChange = useCallback(() => {
toggleChanged(true);
}, [toggleChanged]);
const onFinish = useCallback(
(values) => {
updateSystemConfig(values).then(() => {
Toast.success('操作成功');
});
},
[updateSystemConfig]
);
return (
<DataRender
loading={loading}
error={error}
normalContent={() => (
<div style={{ marginTop: 16 }}>
<Banner
type="warning"
description="配置邮箱服务后,请测试是否正确,否则可能导致无法注册用户,找回密码!"
closeIcon={null}
/>
<Form initValues={data} onChange={onFormChange} onSubmit={onFinish}>
<Form.Input
field="emailServiceHost"
label="邮件服务地址"
style={{ width: '100%' }}
placeholder="输入邮件服务地址"
rules={[{ required: true, message: '请输入邮件服务地址' }]}
/>
<Form.Input
field="emailServicePort"
label="邮件服务端口"
style={{ width: '100%' }}
placeholder="输入邮件服务端口"
rules={[{ required: true, message: '请输入邮件服务端口' }]}
/>
<Form.Input
field="emailServicePassword"
label="邮件服务密码"
style={{ width: '100%' }}
placeholder="输入邮件服务密码"
rules={[{ required: true, message: '请输入邮件服务密码' }]}
/>
<Form.Input
field="emailServiceUser"
label="邮件服务用户"
style={{ width: '100%' }}
placeholder="输入邮件服务密码"
rules={[{ required: true, message: '请输入邮件服务密码' }]}
/>
<Button
htmlType="submit"
type="primary"
theme="solid"
disabled={!changed}
loading={loading}
style={{ margin: '16px 0' }}
>
</Button>
<Button style={{ margin: '16px' }} onClick={sendTestEmail}>
</Button>
</Form>
</div>
)}
/>
);
};

View File

@ -0,0 +1,49 @@
import { Banner, Button, Form, Toast } from '@douyinfe/semi-ui';
import { DataRender } from 'components/data-render';
import { useSystemConfig } from 'data/user';
import { useToggle } from 'hooks/use-toggle';
import React, { useCallback } from 'react';
export const System = () => {
const { data, loading, error, updateSystemConfig } = useSystemConfig();
const [changed, toggleChanged] = useToggle(false);
const onFormChange = useCallback(() => {
toggleChanged(true);
}, [toggleChanged]);
const onFinish = useCallback(
(values) => {
updateSystemConfig(values).then(() => {
Toast.success('操作成功');
});
},
[updateSystemConfig]
);
return (
<DataRender
loading={loading}
error={error}
normalContent={() => (
<div style={{ marginTop: 16 }}>
<Banner type="warning" description="系统锁定后,除系统管理员外均不可登录,谨慎修改!" closeIcon={null} />
<Form labelPosition="left" initValues={data} onChange={onFormChange} onSubmit={onFinish}>
<Form.Switch field="isSystemLocked" label="系统锁定" />
<Button
htmlType="submit"
type="primary"
theme="solid"
disabled={!changed}
loading={loading}
style={{ margin: '16px 0' }}
>
</Button>
</Form>
</div>
)}
/>
);
};

View File

@ -16,7 +16,6 @@ import {
Tooltip,
Typography,
} from '@douyinfe/semi-ui';
import { IUser } from '@think/domains';
import { DataRender } from 'components/data-render';
import { DocumentLinkCopyer } from 'components/document/link';
import { useDoumentMembers } from 'data/document';
@ -52,6 +51,7 @@ const renderChecked = (onChange, authKey: 'readable' | 'editable') => (checked,
export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId, disabled = false }) => {
const { isMobile } = IsOnMobile.useHook();
const ref = useRef<HTMLInputElement>();
const toastedUsersRef = useRef([]);
const { user: currentUser } = useUser();
const [visible, toggleVisible] = useToggle(false);
const { users, loading, error, addUser, updateUser, deleteUser } = useDoumentMembers(documentId, {
@ -162,7 +162,10 @@ export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId, di
return joinUser.name !== currentUser.name;
})
.forEach((joinUser) => {
if (!toastedUsersRef.current.includes(joinUser.clientId)) {
Toast.info(`${joinUser.name}-${joinUser.clientId}加入文档`);
toastedUsersRef.current.push(joinUser.clientId);
}
});
setCollaborationUsers(joinUsers);
@ -171,6 +174,7 @@ export const DocumentCollaboration: React.FC<IProps> = ({ wikiId, documentId, di
event.on(JOIN_USER, handler);
return () => {
toastedUsersRef.current = [];
event.off(JOIN_USER, handler);
};
}, [currentUser]);

View File

@ -2,7 +2,8 @@ import { IconSpin } from '@douyinfe/semi-icons';
import { Avatar, Button, Dropdown, Typography } from '@douyinfe/semi-ui';
import { useUser } from 'data/user';
import { useToggle } from 'hooks/use-toggle';
import React from 'react';
import Router from 'next/router';
import React, { useCallback } from 'react';
import { UserSetting } from './setting';
@ -12,6 +13,10 @@ export const User: React.FC = () => {
const { user, loading, error, toLogin, logout } = useUser();
const [visible, toggleVisible] = useToggle(false);
const toAdmin = useCallback(() => {
Router.push('/admin');
}, []);
if (loading) return <Button icon={<IconSpin />} theme="borderless" type="tertiary" />;
if (error || !user) {
@ -32,6 +37,11 @@ export const User: React.FC = () => {
<Dropdown.Item onClick={() => toggleVisible(true)}>
<Text></Text>
</Dropdown.Item>
{user.isSystemAdmin ? (
<Dropdown.Item onClick={toAdmin}>
<Text></Text>
</Dropdown.Item>
) : null}
<Dropdown.Divider />
<Dropdown.Item onClick={logout}>
<Text>退</Text>

View File

@ -65,7 +65,13 @@ export const UserSetting: React.FC<IProps> = ({ visible, toggleVisible }) => {
disabled
placeholder="请输入账户名称"
></Form.Input>
<Form.Input label="邮箱" field="email" style={{ width: '100%' }} placeholder="请输入账户邮箱"></Form.Input>
<Form.Input
disabled
label="邮箱"
field="email"
style={{ width: '100%' }}
placeholder="请输入账户邮箱"
></Form.Input>
</Form>
</Modal>
);

View File

@ -42,7 +42,9 @@ export const WikiCard: React.FC<{ wiki: IWikiWithIsMember; shareMode?: boolean }
</header>
<main>
<div style={{ marginBottom: 12 }}>
<Text strong>{wiki.name}</Text>
<Paragraph ellipsis={{ rows: 1 }} strong>
{wiki.name}
</Paragraph>
<Paragraph ellipsis={{ rows: 1 }}>{wiki.description}</Paragraph>
</div>
<div>

View File

@ -7,7 +7,7 @@ import Link from 'next/link';
import styles from './index.module.scss';
const { Text } = Typography;
const { Text, Paragraph } = Typography;
export const WikiPinCard: React.FC<{ wiki: IWiki }> = ({ wiki }) => {
return (
@ -35,7 +35,9 @@ export const WikiPinCard: React.FC<{ wiki: IWiki }> = ({ wiki }) => {
</div>
</header>
<main>
<Text strong>{wiki.name}</Text>
<Paragraph ellipsis={{ rows: 1 }} strong>
{wiki.name}
</Paragraph>
</main>
<footer>
<Text type="tertiary" size="small">

View File

@ -9,7 +9,7 @@ import { useWikiDetail, useWikiTocs } from 'data/wiki';
import { triggerCreateDocument } from 'event';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import styles from './index.module.scss';
import { Tree } from './tree';
@ -29,7 +29,7 @@ export const WikiTocs: React.FC<IProps> = ({
docAsLink = '/wiki/[wikiId]/document/[documentId]',
getDocLink = (documentId) => `/wiki/${wikiId}/document/${documentId}`,
}) => {
const { pathname } = useRouter();
const { pathname, query } = useRouter();
const { data: wiki, loading: wikiLoading, error: wikiError } = useWikiDetail(wikiId);
const { data: tocs, loading: tocsLoading, error: tocsError } = useWikiTocs(wikiId);
const { data: starWikis } = useStarWikis();
@ -39,6 +39,7 @@ export const WikiTocs: React.FC<IProps> = ({
error: starDocumentsError,
} = useWikiStarDocuments(wikiId);
const [parentIds, setParentIds] = useState<Array<string>>([]);
const otherStarWikis = useMemo(() => (starWikis || []).filter((wiki) => wiki.id !== wikiId), [starWikis, wikiId]);
useEffect(() => {
if (!tocs || !tocs.length) return;
@ -73,15 +74,14 @@ export const WikiTocs: React.FC<IProps> = ({
</div>
}
error={wikiError}
normalContent={() => (
normalContent={() =>
otherStarWikis.length ? (
<Dropdown
trigger={'click'}
position="bottomRight"
render={
<Dropdown.Menu style={{ width: 180 }}>
{(starWikis || [])
.filter((wiki) => wiki.id !== wikiId)
.map((wiki) => {
{otherStarWikis.map((wiki) => {
return (
<Dropdown.Item key={wiki.id}>
<Link
@ -93,10 +93,10 @@ export const WikiTocs: React.FC<IProps> = ({
<a
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
}}
>
<span>
<Avatar
shape="square"
size="small"
@ -110,10 +110,9 @@ export const WikiTocs: React.FC<IProps> = ({
>
{wiki.name.charAt(0)}
</Avatar>
<Text strong ellipsis={{ rows: 1 }}>
<Text strong style={{ width: 120 }} ellipsis={{ showTooltip: true }}>
{wiki.name}
</Text>
</span>
</a>
</Link>
</Dropdown.Item>
@ -142,7 +141,27 @@ export const WikiTocs: React.FC<IProps> = ({
<IconSmallTriangleDown />
</div>
</Dropdown>
)}
) : (
<div className={styles.titleWrap}>
<span>
<Avatar
shape="square"
size="small"
src={wiki.avatar}
style={{
marginRight: 8,
width: 24,
height: 24,
borderRadius: 4,
}}
>
{wiki.name.charAt(0)}
</Avatar>
<Text strong>{wiki.name}</Text>
</span>
</div>
)
}
/>
<DataRender
@ -170,7 +189,12 @@ export const WikiTocs: React.FC<IProps> = ({
}
error={wikiError}
normalContent={() => (
<div className={cls(styles.linkWrap, pathname === '/wiki/[wikiId]' && styles.isActive)}>
<div
className={cls(
styles.linkWrap,
(pathname === '/wiki/[wikiId]' || query.documentId === wiki.homeDocumentId) && styles.isActive
)}
>
<Link
href={{
pathname: `/wiki/[wikiId]`,

View File

@ -1,5 +1,7 @@
import { ILoginUser, IUser, UserApiDefinition } from '@think/domains';
import { Toast } from '@douyinfe/semi-ui';
import { ILoginUser, ISystemConfig, IUser, UserApiDefinition } from '@think/domains';
import { getStorage, setStorage } from 'helpers/storage';
import { useAsyncLoading } from 'hooks/use-async-loading';
import Router, { useRouter } from 'next/router';
import { useCallback, useEffect } from 'react';
import { useQuery } from 'react-query';
@ -16,6 +18,63 @@ export const toLogin = () => {
}
};
/**
*
* @returns
*/
export const useVerifyCode = () => {
const [sendVerifyCode, loading] = useAsyncLoading((params: { email: string }) =>
HttpClient.request({
method: UserApiDefinition.sendVerifyCode.method,
url: UserApiDefinition.sendVerifyCode.client(),
params,
})
);
return {
sendVerifyCode,
loading,
};
};
/**
*
* @returns
*/
export const useRegister = () => {
const [registerWithLoading, loading] = useAsyncLoading((data) =>
HttpClient.request({
method: UserApiDefinition.register.method,
url: UserApiDefinition.register.client(),
data,
})
);
return {
register: registerWithLoading,
loading,
};
};
/**
*
* @returns
*/
export const useResetPassword = () => {
const [resetPasswordWithLoading, loading] = useAsyncLoading((data) =>
HttpClient.request({
method: UserApiDefinition.resetPassword.method,
url: UserApiDefinition.resetPassword.client(),
data,
})
);
return {
reset: resetPasswordWithLoading,
loading,
};
};
export const useUser = () => {
const router = useRouter();
const { data, error, refetch } = useQuery<ILoginUser>('user', () => {
@ -78,3 +137,40 @@ export const useUser = () => {
updateUser,
};
};
/**
*
* @returns
*/
export const useSystemConfig = () => {
const { data, error, isLoading, refetch } = useQuery(UserApiDefinition.getSystemConfig.client(), () =>
HttpClient.request<ISystemConfig>({
method: UserApiDefinition.getSystemConfig.method,
url: UserApiDefinition.getSystemConfig.client(),
})
);
const sendTestEmail = useCallback(async () => {
return await HttpClient.request<ISystemConfig>({
method: UserApiDefinition.sendTestEmail.method,
url: UserApiDefinition.sendTestEmail.client(),
}).then(() => {
Toast.success('测试邮件发送成功');
});
}, []);
const updateSystemConfig = useCallback(
async (data: Partial<ISystemConfig>) => {
const ret = await HttpClient.request<ISystemConfig>({
method: UserApiDefinition.updateSystemConfig.method,
url: UserApiDefinition.updateSystemConfig.client(),
data,
});
refetch();
return ret;
},
[refetch]
);
return { data, error, loading: isLoading, refresh: refetch, sendTestEmail, updateSystemConfig };
};

View File

@ -0,0 +1,34 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useToggle } from './use-toggle';
import { useIsomorphicLayoutEffect } from './user-isomorphic-layout-effect';
export const useInterval = (callback: () => void, delay: number) => {
const savedCallback = useRef(callback);
const timer = useRef(null);
const [canActive, toggleCanActive] = useToggle(false);
useIsomorphicLayoutEffect(() => {
savedCallback.current = callback;
}, [callback]);
useEffect(() => {
if (!canActive) return;
clearInterval(timer.current);
timer.current = setInterval(() => savedCallback.current(), delay);
return () => clearInterval(timer.current);
}, [canActive, delay]);
const start = useCallback(() => {
toggleCanActive(true);
}, [toggleCanActive]);
const stop = useCallback(() => {
clearInterval(timer.current);
toggleCanActive(false);
}, [toggleCanActive]);
return { start, stop };
};

View File

@ -0,0 +1,3 @@
import { useEffect, useLayoutEffect } from 'react';
export const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;

View File

@ -0,0 +1,225 @@
import React from 'react';
export const Forbidden = () => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"
data-name="Layer 1"
width="20em"
height="15em"
viewBox="0 0 807.45276 499.98424"
>
<path
id="ad903c08-5677-4dbe-a9c7-05a0eb46801f-129"
data-name="Path 461"
d="M252.30849,663.16553a22.728,22.728,0,0,0,21.947-3.866c7.687-6.452,10.1-17.081,12.058-26.924l5.8-29.112-12.143,8.362c-8.733,6.013-17.662,12.219-23.709,20.929s-8.686,20.6-3.828,30.024"
transform="translate(-196.27362 -200.00788)"
fill="#e6e6e6"
/>
<path
id="a94887ac-0642-4b28-b311-c351a0f7f12b-130"
data-name="Path 462"
d="M253.34651,698.41151c-1.229-8.953-2.493-18.02-1.631-27.069.766-8.036,3.217-15.885,8.209-22.321a37.13141,37.13141,0,0,1,9.527-8.633c.953-.6,1.829.909.881,1.507a35.29989,35.29989,0,0,0-13.963,16.847c-3.04,7.732-3.528,16.161-3,24.374.317,4.967.988,9.9,1.665,14.83a.9.9,0,0,1-.61,1.074.878.878,0,0,1-1.074-.61Z"
transform="translate(-196.27362 -200.00788)"
fill="#f2f2f2"
/>
<path
d="M496.87431,505.52556a6.9408,6.9408,0,0,1-2.85071.67077l-91.60708,2.51425a14.3796,14.3796,0,0,1-.62506-28.75241l91.60729-2.51381a7.00744,7.00744,0,0,1,7.15064,6.8456l.32069,14.75586a7.01658,7.01658,0,0,1-3.99577,6.47974Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M379.332,698.59808H364.57245a7.00786,7.00786,0,0,1-7-7V568.58392a7.00785,7.00785,0,0,1,7-7H379.332a7.00786,7.00786,0,0,1,7,7V691.59808A7.00787,7.00787,0,0,1,379.332,698.59808Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M418.52435,698.59808H403.76459a7.00786,7.00786,0,0,1-7-7V568.58392a7.00785,7.00785,0,0,1,7-7h14.75976a7.00786,7.00786,0,0,1,7,7V691.59808A7.00787,7.00787,0,0,1,418.52435,698.59808Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<circle cx="196.71571" cy="182.69717" r="51" fill="#6c63ff" />
<path
d="M410.30072,605.205H373.61127a43.27708,43.27708,0,0,1-37.56043-65.05664l51.30933-88.87012a6.5,6.5,0,0,1,11.2583,0l50.27612,87.08057A44.56442,44.56442,0,0,1,410.30072,605.205Z"
transform="translate(-196.27362 -200.00788)"
fill="#2f2e41"
/>
<path
d="M405.02686,404.114c3.30591-.0918,7.42029-.20655,10.59-2.522a8.13274,8.13274,0,0,0,3.20007-6.07275,5.47084,5.47084,0,0,0-1.86035-4.49315c-1.65552-1.39894-4.073-1.72706-6.67823-.96144l2.69922-19.72558-1.98144-.27149-3.17322,23.18994,1.65466-.75928c1.91834-.87988,4.55164-1.32763,6.188.05518a3.51514,3.51514,0,0,1,1.15271,2.89551,6.14686,6.14686,0,0,1-2.38122,4.52783c-2.46668,1.80176-5.74622,2.03418-9.46582,2.13818Z"
transform="translate(-196.27362 -200.00788)"
fill="#2f2e41"
/>
<rect x="226.50312" y="172.03238" width="10.77161" height="2" fill="#2f2e41" />
<rect x="192.50312" y="172.03238" width="10.77161" height="2" fill="#2f2e41" />
<path
d="M380.99359,593.79839a6.94088,6.94088,0,0,1-.67077-2.85072l-2.51425-91.60708a14.3796,14.3796,0,0,1,28.75241-.62506l2.51381,91.60729a7.00744,7.00744,0,0,1-6.8456,7.15064l-14.75586.32069a7.01655,7.01655,0,0,1-6.47974-3.99576Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M388.25747,345.00549c6.19637,8.10336,16.033,13.53931,26.42938,12.25223,9.90031-1.22567,18.06785-8.12619,20.117-18.0055a29.66978,29.66978,0,0,0-7.79665-26.1905c-7.00748-7.37032-17.03634-11.335-26.96311-12.69456-18.80446-2.57537-38.1172,4.04852-52.33518,16.4023a64.1102,64.1102,0,0,0-16.69251,22.37513,62.72346,62.72346,0,0,0-5.175,27.07767c.54633,18.375,8.595,36.71479,22.48271,48.90083a63.37666,63.37666,0,0,0,5.40808,4.23578c1.58387,1.11112,3.08464-1.48868,1.51415-2.59042-14.222-9.977-23.29362-26.21093-25.78338-43.26844a59.92391,59.92391,0,0,1,14.05278-48.33971c11.48411-13.058,28.32271-21.54529,45.7628-22.30575,17.54894-.76521,39.47915,7.06943,42.7631,26.60435,1.47191,8.7558-1.801,17.95926-9.82454,22.3428-8.59053,4.69326-19.12416,2.76181-26.50661-3.29945a30.448,30.448,0,0,1-4.86258-5.01092c-1.157-1.51313-3.76387-.02044-2.59041,1.51416Z"
transform="translate(-196.27362 -200.00788)"
fill="#2f2e41"
/>
<rect
id="fc777aff-63b1-4720-84dc-e3a9c20790b9"
data-name="ab2e16f2-9798-47da-b25d-769524f3c86f"
x="484.20919"
y="242.03206"
width="437.1948"
height="207.45652"
transform="translate(-238.48792 -95.97299) rotate(-8.21995)"
fill="#f1f1f1"
/>
<rect
id="ecffa418-b240-4504-be04-512edea7ccda"
data-name="bf81c03f-68cf-4889-8697-1102f95f97bb"
x="496.79745"
y="259.81556"
width="412.19197"
height="173.08746"
transform="translate(-238.57266 -95.95442) rotate(-8.21995)"
fill="#fff"
/>
<rect
id="b49ce3f1-9d75-4481-986b-3b6beb000c79"
data-name="f065dccc-d150-492a-a09f-a7f3f89523f0"
x="468.80837"
y="231.16611"
width="437.19481"
height="18.57334"
transform="translate(-223.58995 -99.25677) rotate(-8.21995)"
fill="#e5e5e5"
/>
<circle
id="a4219562-805a-49cd-8b89-b1f92f7a9e75"
data-name="bdbbf39c-df25-4682-8b85-5a6af4a1bd14"
cx="288.67474"
cy="71.34324"
r="3.4425"
fill="#fff"
/>
<circle
id="b0f6399c-6944-4f74-a888-473f61f9730c"
data-name="abcd4292-0b1f-4102-9b5e-e8bbd87baabc"
cx="301.6071"
cy="69.47507"
r="3.4425"
fill="#fff"
/>
<circle
id="b03f93dc-2c99-4323-9b17-02f51b8830c0"
data-name="a3fb731e-8b3d-41ca-96f2-91600dc0b434"
cx="314.54005"
cy="67.6068"
r="3.4425"
fill="#fff"
/>
<rect
id="a6067cfc-0392-4d68-afe4-e34d11a8f0ac"
data-name="ab2e16f2-9798-47da-b25d-769524f3c86f"
x="370.25796"
y="100.18309"
width="437.1948"
height="207.45652"
fill="#e6e6e6"
/>
<rect
id="ecd65817-7467-4dbd-a435-c0f1d9841c98"
data-name="bf81c03f-68cf-4889-8697-1102f95f97bb"
x="382.75969"
y="117.97286"
width="412.19197"
height="173.08746"
fill="#fff"
/>
<rect
id="eea6c39d-8a45-4eb1-bab9-6120f465de14"
data-name="f065dccc-d150-492a-a09f-a7f3f89523f0"
x="370.07154"
y="88.19711"
width="437.19481"
height="18.57334"
fill="#cbcbcb"
/>
<circle
id="ab9e51f9-7431-4d30-8193-f9435a6bd5c3"
data-name="bdbbf39c-df25-4682-8b85-5a6af4a1bd14"
cx="383.87383"
cy="99.11864"
r="3.4425"
fill="#fff"
/>
<circle
id="a54ed687-3b0d-413b-b405-af8897a5c032"
data-name="abcd4292-0b1f-4102-9b5e-e8bbd87baabc"
cx="396.94043"
cy="99.11864"
r="3.4425"
fill="#fff"
/>
<circle
id="fd1d2195-7e97-488f-8f4b-7061a06deb9a"
data-name="a3fb731e-8b3d-41ca-96f2-91600dc0b434"
cx="410.00762"
cy="99.11864"
r="3.4425"
fill="#fff"
/>
<rect x="620.27691" y="144.28855" width="58.05212" height="4.36334" fill="#e6e6e6" />
<rect x="620.27691" y="157.09784" width="89.64514" height="4.36332" fill="#e6e6e6" />
<rect x="621.20899" y="169.29697" width="73.05881" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="182.68222" width="42.65054" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="195.75677" width="64.37073" height="4.36332" fill="#e6e6e6" />
<rect x="593.81776" y="142.916" width="7.10843" height="7.10842" fill="#e6e6e6" />
<rect x="593.81776" y="155.72527" width="7.10843" height="7.10841" fill="#e6e6e6" />
<rect x="593.81776" y="167.92442" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="181.30967" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="194.38423" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="620.27691" y="208.91306" width="58.05212" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="221.72236" width="89.64514" height="4.36332" fill="#e6e6e6" />
<rect x="621.20899" y="233.92149" width="73.05881" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="247.30674" width="42.65054" height="4.36332" fill="#e6e6e6" />
<rect x="620.27691" y="260.38129" width="64.37073" height="4.36332" fill="#e6e6e6" />
<rect x="593.81776" y="207.54051" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="220.34979" width="7.10843" height="7.10841" fill="#e6e6e6" />
<rect x="593.81776" y="232.54894" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="245.93419" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="593.81776" y="259.00875" width="7.10843" height="7.10843" fill="#e6e6e6" />
<rect x="436.63003" y="243.13905" width="58.05213" height="4.36333" fill="#e6e6e6" />
<rect x="428.86266" y="254.4769" width="73.05881" height="4.36332" fill="#e6e6e6" />
<path
d="M699.66075,388.1056a37.91872,37.91872,0,0,1-55.87819,33.382l-.00736-.00737a37.907,37.907,0,1,1,55.88555-33.37461Z"
transform="translate(-196.27362 -200.00788)"
fill="#e6e6e6"
/>
<circle cx="465.67554" cy="175.95347" r="10.30421" fill="#fff" />
<path
d="M679.54362,407.55657a53.11056,53.11056,0,0,1-35.56788-.13775l-.00738-.0051,7.6766-15.15329h20.60841Z"
transform="translate(-196.27362 -200.00788)"
fill="#fff"
/>
<path
d="M547.86351,482.19293c-17.96014,0-32.5719-15.52155-32.5719-34.60067,0-19.07858,14.61176-34.60014,32.5719-34.60014s32.5719,15.52156,32.5719,34.60014C580.43541,466.67138,565.82365,482.19293,547.86351,482.19293Zm0-60.4582c-13.13954,0-23.82929,11.59955-23.82929,25.85753s10.68975,25.85806,23.82929,25.85806,23.82928-11.60008,23.82928-25.85806S561.00305,421.73473,547.86351,421.73473Z"
transform="translate(-196.27362 -200.00788)"
fill="#6c63ff"
/>
<path
d="M578.70786,542.49212h-61.6887a20.54138,20.54138,0,0,1-20.51852-20.51826V461.46391a14.06356,14.06356,0,0,1,14.04747-14.04774h74.6308a14.06356,14.06356,0,0,1,14.04747,14.04774v60.50995A20.54138,20.54138,0,0,1,578.70786,542.49212Z"
transform="translate(-196.27362 -200.00788)"
fill="#3f3d56"
/>
<path
d="M559.88461,481.84022a12.0211,12.0211,0,1,0-17.48524,10.69829v18.808h10.92827v-18.808A12.01088,12.01088,0,0,0,559.88461,481.84022Z"
transform="translate(-196.27362 -200.00788)"
fill="#fff"
/>
<path
d="M578.27362,699.99212h-381a1,1,0,0,1,0-2h381a1,1,0,0,1,0,2Z"
transform="translate(-196.27362 -200.00788)"
fill="#3f3d56"
/>
</svg>
);
};

View File

@ -38,7 +38,7 @@ const menus = [
},
{
itemKey: '/star',
text: '收藏',
text: '星标',
onClick: () => {
Router.push({
pathname: `/star`,

View File

@ -0,0 +1,12 @@
.wikiItemWrap {
padding: 12px 16px !important;
margin: 8px 2px;
cursor: pointer;
background-color: var(--semi-color-bg-2);
border: 1px solid var(--semi-color-border) !important;
}
.titleWrap {
display: flex;
justify-content: space-between;
}

View File

@ -0,0 +1,55 @@
import { Typography } from '@douyinfe/semi-ui';
import { SystemConfig } from 'components/admin/system-config';
import { Seo } from 'components/seo';
import { useUser } from 'data/user';
import { Forbidden } from 'illustrations/forbidden';
import { SingleColumnLayout } from 'layouts/single-column';
import type { NextPage } from 'next';
import Router, { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import styles from './index.module.scss';
const { Title, Text } = Typography;
const Page: NextPage = () => {
const { user } = useUser();
const { query = {} } = useRouter();
const { tab = 'base' } = query as {
tab?: string;
};
const navigate = useCallback((tab = 'base') => {
Router.push({
pathname: `/admin`,
query: { tab },
});
}, []);
return (
<SingleColumnLayout>
<Seo title="管理后台" />
<div className="container">
{user && user.isSystemAdmin ? (
<>
<div className={styles.titleWrap}>
<Title heading={3} style={{ margin: '8px 0' }}>
</Title>
</div>
<SystemConfig tab={tab} onNavigate={navigate} />
</>
) : (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' }}>
<Forbidden />
<Text strong type="danger">
</Text>
</div>
)}
</div>
</SingleColumnLayout>
);
};
export default Page;

View File

@ -0,0 +1,35 @@
.wrap {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
background-color: var(--semi-color-bg-0);
.content {
position: relative;
z-index: 10;
display: flex;
padding: 10vh 24px;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
.form {
width: 100%;
max-width: 400px;
padding: 32px 24px;
margin: 0 auto;
border: 1px solid var(--semi-color-border);
border-radius: var(--border-radius);
box-shadow: var(--box-shadow);
footer {
padding-top: 16px;
border-top: 1px solid var(--semi-color-border);
margin-top: 12px;
text-align: center;
}
}
}
}

View File

@ -0,0 +1,170 @@
import { Button, Col, Form, Layout, Modal, Row, Space, Toast, Typography } from '@douyinfe/semi-ui';
import { Author } from 'components/author';
import { LogoImage, LogoText } from 'components/logo';
import { Seo } from 'components/seo';
import { useResetPassword, useVerifyCode } from 'data/user';
import { useInterval } from 'hooks/use-interval';
import { useRouterQuery } from 'hooks/use-router-query';
import { useToggle } from 'hooks/use-toggle';
import Link from 'next/link';
import Router from 'next/router';
import React, { useCallback, useState } from 'react';
import styles from './index.module.scss';
const { Content, Footer } = Layout;
const { Title, Text } = Typography;
const Page = () => {
const query = useRouterQuery();
const [email, setEmail] = useState('');
const [hasSendVerifyCode, toggleHasSendVerifyCode] = useToggle(false);
const [countDown, setCountDown] = useState(0);
const { reset, loading } = useResetPassword();
const { sendVerifyCode, loading: sendVerifyCodeLoading } = useVerifyCode();
const onFormChange = useCallback((formState) => {
setEmail(formState.values.email);
}, []);
const { start, stop } = useInterval(() => {
setCountDown((v) => {
if (v - 1 <= 0) {
stop();
toggleHasSendVerifyCode(false);
return 0;
}
return v - 1;
});
}, 1000);
const onFinish = useCallback(
(values) => {
reset(values).then((res) => {
Modal.confirm({
title: <Title heading={5}></Title>,
content: <Text>?</Text>,
okText: '确认',
cancelText: '取消',
onOk() {
Router.push('/login', { query });
},
});
});
},
[reset, query]
);
const getVerifyCode = useCallback(() => {
stop();
sendVerifyCode({ email })
.then(() => {
Toast.success('请前往邮箱查收验证码');
setCountDown(60);
start();
toggleHasSendVerifyCode(true);
})
.catch(() => {
toggleHasSendVerifyCode(false);
});
}, [email, toggleHasSendVerifyCode, sendVerifyCode, start, stop]);
return (
<Layout className={styles.wrap}>
<Seo title="重置密码" />
<Content className={styles.content}>
<Title heading={4} style={{ marginBottom: 16, textAlign: 'center' }}>
<Space>
<LogoImage></LogoImage>
<LogoText></LogoText>
</Space>
</Title>
<Form
className={styles.form}
initValues={{ name: '', password: '' }}
onChange={onFormChange}
onSubmit={onFinish}
>
<Title type="tertiary" heading={5} style={{ marginBottom: 16, textAlign: 'center' }}>
</Title>
<Form.Input
noLabel
field="email"
placeholder={'请输入邮箱'}
rules={[
{
type: 'email',
message: '请输入正确的邮箱地址!',
},
{
required: true,
message: '请输入邮箱地址!',
},
]}
/>
<Row gutter={8} style={{ paddingTop: 12 }}>
<Col span={16}>
<Form.Input
noLabel
fieldStyle={{ paddingTop: 0 }}
placeholder={'请输入验证码'}
field="verifyCode"
rules={[{ required: true, message: '请输入邮箱收到的验证码!' }]}
/>
</Col>
<Col span={8}>
<Button disabled={!email || countDown > 0} loading={sendVerifyCodeLoading} onClick={getVerifyCode} block>
{hasSendVerifyCode ? countDown : '获取验证码'}
</Button>
</Col>
</Row>
<Form.Input
noLabel
mode="password"
field="password"
label="密码"
style={{ width: '100%' }}
placeholder="输入用户密码"
rules={[{ required: true, message: '请输入新密码' }]}
/>
<Form.Input
noLabel
mode="password"
field="confirmPassword"
label="密码"
style={{ width: '100%' }}
placeholder="确认用户密码"
rules={[{ required: true, message: '请再次输入密码' }]}
/>
<Button htmlType="submit" type="primary" theme="solid" block loading={loading} style={{ margin: '16px 0' }}>
</Button>
<footer>
<Link
href={{
pathname: '/login',
query,
}}
>
<Text link style={{ textAlign: 'center' }}>
</Text>
</Link>
</footer>
</Form>
</Content>
<Footer>
<Author></Author>
</Footer>
</Layout>
);
};
export default Page;

View File

@ -51,9 +51,10 @@ const Page = () => {
field="name"
label="账户"
style={{ width: '100%' }}
placeholder="输入账户名称"
rules={[{ required: true, message: '请输入账户' }]}
placeholder="输入账户名称或邮箱"
rules={[{ required: true, message: '请输入账户或邮箱' }]}
></Form.Input>
<Form.Input
noLabel
mode="password"
@ -67,6 +68,7 @@ const Page = () => {
</Button>
<footer>
<Space>
<Link
href={{
pathname: '/register',
@ -77,6 +79,18 @@ const Page = () => {
</Text>
</Link>
<Link
href={{
pathname: '/forgetPassword',
query,
}}
>
<a>
<Text type="tertiary"></Text>
</a>
</Link>
</Space>
</footer>
</Form>
</Content>

View File

@ -1,13 +1,14 @@
import { Button, Form, Layout, Modal, Space, Typography } from '@douyinfe/semi-ui';
import { Button, Col, Form, Layout, Modal, Row, Space, Toast, Typography } from '@douyinfe/semi-ui';
import { Author } from 'components/author';
import { LogoImage, LogoText } from 'components/logo';
import { Seo } from 'components/seo';
import { useAsyncLoading } from 'hooks/use-async-loading';
import { useRegister, useVerifyCode } from 'data/user';
import { useInterval } from 'hooks/use-interval';
import { useRouterQuery } from 'hooks/use-router-query';
import { useToggle } from 'hooks/use-toggle';
import Link from 'next/link';
import Router from 'next/router';
import React from 'react';
import { register as registerApi } from 'services/user';
import React, { useCallback, useState } from 'react';
import styles from './index.module.scss';
@ -16,21 +17,58 @@ const { Title, Text } = Typography;
const Page = () => {
const query = useRouterQuery();
const [registerWithLoading, loading] = useAsyncLoading(registerApi);
const onFinish = (values) => {
registerWithLoading(values).then((res) => {
const [email, setEmail] = useState('');
const [hasSendVerifyCode, toggleHasSendVerifyCode] = useToggle(false);
const [countDown, setCountDown] = useState(0);
const { register, loading } = useRegister();
const { sendVerifyCode, loading: sendVerifyCodeLoading } = useVerifyCode();
const onFormChange = useCallback((formState) => {
setEmail(formState.values.email);
}, []);
const { start, stop } = useInterval(() => {
setCountDown((v) => {
if (v - 1 <= 0) {
stop();
toggleHasSendVerifyCode(false);
return 0;
}
return v - 1;
});
}, 1000);
const onFinish = useCallback(
(values) => {
register(values).then((res) => {
Modal.confirm({
title: <Title heading={5}></Title>,
content: <Text>?</Text>,
okText: '确认',
cancelText: '取消',
onOk() {
Router.push('/login');
Router.push('/login', { query });
},
});
});
};
},
[register, query]
);
const getVerifyCode = useCallback(() => {
stop();
sendVerifyCode({ email })
.then(() => {
Toast.success('请前往邮箱查收验证码');
setCountDown(60);
start();
toggleHasSendVerifyCode(true);
})
.catch(() => {
toggleHasSendVerifyCode(false);
});
}, [email, toggleHasSendVerifyCode, sendVerifyCode, start, stop]);
return (
<Layout className={styles.wrap}>
@ -42,7 +80,12 @@ const Page = () => {
<LogoText></LogoText>
</Space>
</Title>
<Form className={styles.form} initValues={{ name: '', password: '' }} onSubmit={onFinish}>
<Form
className={styles.form}
initValues={{ name: '', password: '' }}
onChange={onFormChange}
onSubmit={onFinish}
>
<Title type="tertiary" heading={5} style={{ marginBottom: 16, textAlign: 'center' }}>
</Title>
@ -72,6 +115,40 @@ const Page = () => {
placeholder="确认用户密码"
rules={[{ required: true, message: '请再次输入密码' }]}
></Form.Input>
<Form.Input
noLabel
field="email"
placeholder={'请输入邮箱'}
rules={[
{
type: 'email',
message: '请输入正确的邮箱地址!',
},
{
required: true,
message: '请输入邮箱地址!',
},
]}
/>
<Row gutter={8} style={{ paddingTop: 12 }}>
<Col span={16}>
<Form.Input
noLabel
fieldStyle={{ paddingTop: 0 }}
placeholder={'请输入验证码'}
field="verifyCode"
rules={[{ required: true, message: '请输入邮箱收到的验证码!' }]}
/>
</Col>
<Col span={8}>
<Button disabled={!email || countDown > 0} loading={sendVerifyCodeLoading} onClick={getVerifyCode} block>
{hasSendVerifyCode ? countDown : '获取验证码'}
</Button>
</Col>
</Row>
<Button htmlType="submit" type="primary" theme="solid" block loading={loading} style={{ margin: '16px 0' }}>
</Button>

View File

@ -7,6 +7,14 @@ export declare const UserApiDefinition: {
server: "/";
client: () => string;
};
/**
*
*/
sendVerifyCode: {
method: "get";
server: "sendVerifyCode";
client: () => string;
};
/**
*
*/
@ -15,6 +23,14 @@ export declare const UserApiDefinition: {
server: "register";
client: () => string;
};
/**
*
*/
resetPassword: {
method: "post";
server: "resetPassword";
client: () => string;
};
/**
*
*/
@ -39,4 +55,36 @@ export declare const UserApiDefinition: {
server: "update";
client: () => string;
};
/**
*
*/
toggleLockUser: {
method: "post";
server: "lock/user";
client: () => string;
};
/**
*
*/
getSystemConfig: {
method: "get";
server: "config/system";
client: () => string;
};
/**
*
*/
sendTestEmail: {
method: "get";
server: "config/system/sendTestEmail";
client: () => string;
};
/**
*
*/
updateSystemConfig: {
method: "post";
server: "config/system/updateSystemConfig";
client: () => string;
};
};

View File

@ -10,6 +10,14 @@ exports.UserApiDefinition = {
server: '/',
client: function () { return '/user'; }
},
/**
*
*/
sendVerifyCode: {
method: 'get',
server: 'sendVerifyCode',
client: function () { return '/verify/sendVerifyCode'; }
},
/**
*
*/
@ -18,6 +26,14 @@ exports.UserApiDefinition = {
server: 'register',
client: function () { return '/user/register'; }
},
/**
*
*/
resetPassword: {
method: 'post',
server: 'resetPassword',
client: function () { return '/user/resetPassword'; }
},
/**
*
*/
@ -41,5 +57,37 @@ exports.UserApiDefinition = {
method: 'patch',
server: 'update',
client: function () { return "/user/update"; }
},
/**
*
*/
toggleLockUser: {
method: 'post',
server: 'lock/user',
client: function () { return "/user/lock/user"; }
},
/**
*
*/
getSystemConfig: {
method: 'get',
server: 'config/system',
client: function () { return "/user/config/system"; }
},
/**
*
*/
sendTestEmail: {
method: 'get',
server: 'config/system/sendTestEmail',
client: function () { return "/user/config/system/sendTestEmail"; }
},
/**
*
*/
updateSystemConfig: {
method: 'post',
server: 'config/system/updateSystemConfig',
client: function () { return "/user/config/system/updateSystemConfig"; }
}
};

View File

@ -5,3 +5,4 @@ export * from './message';
export * from './template';
export * from './comment';
export * from './pagination';
export * from './system';

View File

@ -17,3 +17,4 @@ __exportStar(require("./message"), exports);
__exportStar(require("./template"), exports);
__exportStar(require("./comment"), exports);
__exportStar(require("./pagination"), exports);
__exportStar(require("./system"), exports);

View File

@ -0,0 +1,7 @@
export interface ISystemConfig {
isSystemLocked: boolean;
emailServiceHost: string;
emailServicePassword: string;
emailServicePort: string;
emailServiceUser: string;
}

View File

@ -0,0 +1,2 @@
"use strict";
exports.__esModule = true;

View File

@ -24,6 +24,7 @@ export interface IUser {
email?: string;
role: UserRole;
status: UserStatus;
isSystemAdmin?: boolean;
}
/**
*

View File

@ -10,6 +10,15 @@ export const UserApiDefinition = {
client: () => '/user',
},
/**
*
*/
sendVerifyCode: {
method: 'get' as const,
server: 'sendVerifyCode' as const,
client: () => '/verify/sendVerifyCode',
},
/**
*
*/
@ -19,6 +28,15 @@ export const UserApiDefinition = {
client: () => '/user/register',
},
/**
*
*/
resetPassword: {
method: 'post' as const,
server: 'resetPassword' as const,
client: () => '/user/resetPassword',
},
/**
*
*/
@ -45,4 +63,40 @@ export const UserApiDefinition = {
server: 'update' as const,
client: () => `/user/update`,
},
/**
*
*/
toggleLockUser: {
method: 'post' as const,
server: 'lock/user' as const,
client: () => `/user/lock/user`,
},
/**
*
*/
getSystemConfig: {
method: 'get' as const,
server: 'config/system' as const,
client: () => `/user/config/system`,
},
/**
*
*/
sendTestEmail: {
method: 'get' as const,
server: 'config/system/sendTestEmail' as const,
client: () => `/user/config/system/sendTestEmail`,
},
/**
*
*/
updateSystemConfig: {
method: 'post' as const,
server: 'config/system/updateSystemConfig' as const,
client: () => `/user/config/system/updateSystemConfig`,
},
};

View File

@ -5,3 +5,4 @@ export * from './message';
export * from './template';
export * from './comment';
export * from './pagination';
export * from './system';

View File

@ -0,0 +1,7 @@
export interface ISystemConfig {
isSystemLocked: boolean;
emailServiceHost: string;
emailServicePassword: string;
emailServicePort: string;
emailServiceUser: string;
}

View File

@ -26,6 +26,7 @@ export interface IUser {
email?: string;
role: UserRole;
status: UserStatus;
isSystemAdmin?: boolean;
}
/**

View File

@ -49,6 +49,7 @@
"lodash": "^4.17.21",
"mysql2": "^2.3.3",
"nestjs-pino": "^2.5.2",
"nodemailer": "^6.7.5",
"nuid": "^1.1.6",
"passport": "^0.5.2",
"passport-jwt": "^4.0.0",
@ -61,6 +62,7 @@
"rxjs": "^7.2.0",
"typeorm": "^0.2.41",
"ua-parser-js": "^1.0.2",
"validator": "^13.7.0",
"y-prosemirror": "^1.0.14",
"yjs": "^13.5.24"
},

View File

@ -3,8 +3,10 @@ import { DocumentEntity } from '@entities/document.entity';
import { DocumentAuthorityEntity } from '@entities/document-authority.entity';
import { MessageEntity } from '@entities/message.entity';
import { StarEntity } from '@entities/star.entity';
import { SystemEntity } from '@entities/system.entity';
import { TemplateEntity } from '@entities/template.entity';
import { UserEntity } from '@entities/user.entity';
import { VerifyEntity } from '@entities/verify.entity';
import { ViewEntity } from '@entities/view.entity';
import { WikiEntity } from '@entities/wiki.entity';
import { WikiUserEntity } from '@entities/wiki-user.entity';
@ -15,8 +17,10 @@ import { DocumentModule } from '@modules/document.module';
import { FileModule } from '@modules/file.module';
import { MessageModule } from '@modules/message.module';
import { StarModule } from '@modules/star.module';
import { SystemModule } from '@modules/system.module';
import { TemplateModule } from '@modules/template.module';
import { UserModule } from '@modules/user.module';
import { VerifyModule } from '@modules/verify.module';
import { ViewModule } from '@modules/view.module';
import { WikiModule } from '@modules/wiki.module';
import { forwardRef, Inject, Module } from '@nestjs/common';
@ -40,6 +44,8 @@ const ENTITIES = [
MessageEntity,
TemplateEntity,
ViewEntity,
VerifyEntity,
SystemEntity,
];
const MODULES = [
@ -52,6 +58,8 @@ const MODULES = [
MessageModule,
TemplateModule,
ViewModule,
VerifyModule,
SystemModule,
];
@Module({

View File

@ -0,0 +1,7 @@
import { Controller } from '@nestjs/common';
import { SystemService } from '@services/system.service';
@Controller('system')
export class SystemController {
constructor(private readonly systemService: SystemService) {}
}

View File

@ -1,4 +1,4 @@
import { CreateUserDto } from '@dtos/create-user.dto';
import { RegisterUserDto, ResetPasswordDto } from '@dtos/create-user.dto';
import { LoginUserDto } from '@dtos/login-user.dto';
import { UpdateUserDto } from '@dtos/update-user.dto';
import { JwtGuard } from '@guard/jwt.guard';
@ -11,6 +11,7 @@ import {
HttpStatus,
Patch,
Post,
Query,
Request,
Res,
UseGuards,
@ -41,7 +42,7 @@ export class UserController {
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.register.server)
@HttpCode(HttpStatus.CREATED)
async register(@Body() user: CreateUserDto) {
async register(@Body() user: RegisterUserDto) {
return await this.userService.createUser(user);
}
@ -62,6 +63,16 @@ export class UserController {
return { ...data, token };
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.resetPassword.server)
@HttpCode(HttpStatus.OK)
async resetPassword(@Body() user: ResetPasswordDto) {
return await this.userService.resetPassword(user);
}
/**
*
*/
@ -88,4 +99,48 @@ export class UserController {
async updateUser(@Request() req, @Body() dto: UpdateUserDto) {
return await this.userService.updateUser(req.user, dto);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Get(UserApiDefinition.getSystemConfig.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async getSystemConfig(@Request() req) {
return await this.userService.getSystemConfig(req.user);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Get(UserApiDefinition.sendTestEmail.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async sendTestEmail(@Request() req) {
return await this.userService.sendTestEmail(req.user);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.updateSystemConfig.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async toggleLockSystem(@Request() req, @Body() systemConfig) {
return await this.userService.updateSystemConfig(req.user, systemConfig);
}
/**
*
*/
@UseInterceptors(ClassSerializerInterceptor)
@Post(UserApiDefinition.toggleLockUser.server)
@HttpCode(HttpStatus.OK)
@UseGuards(JwtGuard)
async toggleLockUser(@Request() req, @Query('targetUserId') targetUserId) {
return await this.userService.toggleLockUser(req.user, targetUserId);
}
}

View File

@ -0,0 +1,23 @@
import {
ClassSerializerInterceptor,
Controller,
Get,
HttpCode,
HttpStatus,
Query,
UseInterceptors,
} from '@nestjs/common';
import { VerifyService } from '@services/verify.service';
import { UserApiDefinition } from '@think/domains';
@Controller('verify')
export class VerifyController {
constructor(private readonly verifyService: VerifyService) {}
@UseInterceptors(ClassSerializerInterceptor)
@Get(UserApiDefinition.sendVerifyCode.server)
@HttpCode(HttpStatus.CREATED)
async sendVerifyCode(@Query('email') email) {
return await this.verifyService.sendVerifyCode(email);
}
}

View File

@ -1,27 +1,57 @@
import { IsEmail, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateUserDto {
/**
*
*/
export class RegisterUserDto {
@MaxLength(20, { message: '用户账号最多20个字符' })
@MinLength(5, { message: '用户账号至少5个字符' })
@IsString({ message: '用户名称类型错误正确类型为String' })
@IsNotEmpty({ message: '用户账号不能为空' })
@MinLength(5, { message: '用户账号至少5个字符' })
@MaxLength(20, { message: '用户账号最多20个字符' })
readonly name: string;
name: string;
@MinLength(5, { message: '用户密码至少5个字符' })
@IsString({ message: '用户密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户密码不能为空' })
@MinLength(5, { message: '用户密码至少5个字符' })
password: string;
@IsString({ message: ' 用户确认密码类型错误正确类型为String' })
@MinLength(5, { message: '用户密码至少5个字符' })
readonly confirmPassword: string;
@IsString({ message: '用户头像类型错误正确类型为String' })
@IsOptional()
readonly avatar?: string;
@MinLength(5, { message: '用户二次确认密码至少5个字符' })
@IsString({ message: '用户二次确认密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户二次确认密码不能为空' })
confirmPassword: string;
@IsEmail({ message: '请输入正确的邮箱地址' })
@IsString({ message: '用户邮箱类型错误正确类型为String' })
@IsEmail()
@IsOptional()
readonly email?: string;
@IsNotEmpty({ message: '用户邮箱不能为空' })
email: string;
@MinLength(5, { message: '邮箱验证码至少5个字符' })
@IsString({ message: '邮箱验证码错误正确类型为String' })
@IsNotEmpty({ message: '邮箱验证码不能为空' })
verifyCode: string;
}
/**
*
*/
export class ResetPasswordDto {
@MinLength(5, { message: '用户密码至少5个字符' })
@IsString({ message: '用户密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户密码不能为空' })
password: string;
@MinLength(5, { message: '用户二次确认密码至少5个字符' })
@IsString({ message: '用户二次确认密码类型错误正确类型为String' })
@IsNotEmpty({ message: '用户二次确认密码不能为空' })
confirmPassword: string;
@IsEmail({ message: '请输入正确的邮箱地址' })
@IsString({ message: '用户邮箱类型错误正确类型为String' })
@IsNotEmpty({ message: '用户邮箱不能为空' })
email: string;
@MinLength(5, { message: '邮箱验证码至少5个字符' })
@IsString({ message: '邮箱验证码错误正确类型为String' })
@IsNotEmpty({ message: '邮箱验证码不能为空' })
verifyCode: string;
}

View File

@ -1,4 +1,4 @@
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
import { IsEmail, IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
export class LoginUserDto {
@IsString({ message: '用户名称类型错误正确类型为String' })

View File

@ -0,0 +1,56 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('system')
export class SystemEntity {
@PrimaryGeneratedColumn('uuid')
public id: string;
/**
*
*/
@Column({ type: 'boolean', default: false, comment: '是否锁定系统' })
isSystemLocked: boolean;
/**
*
*/
@Column({ type: 'text', default: null })
emailServiceHost: string;
/**
*
*/
@Column({ type: 'text', default: null })
emailServicePort: string;
/**
*
*/
@Column({ type: 'text', default: null })
emailServiceUser: string;
/**
*
*/
@Column({ type: 'text', default: null })
emailServicePassword: string;
@CreateDateColumn({
type: 'timestamp',
name: 'createdAt',
comment: '创建时间',
})
createdAt: Date;
@UpdateDateColumn({
type: 'timestamp',
name: 'updatedAt',
comment: '更新时间',
})
updatedAt: Date;
}

View File

@ -28,12 +28,15 @@ export class UserEntity {
@Column({ type: 'varchar', length: 200, comment: '用户加密密码' })
public password: string;
@Column({ type: 'varchar', comment: '头像地址', default: '' })
@Column({ type: 'varchar', length: 500, comment: '头像地址', default: '' })
public avatar: string;
@Column({ type: 'varchar', comment: '邮箱地址', default: '' })
@Column({ type: 'varchar', comment: '邮箱地址' })
public email: string;
@Column({ type: 'boolean', default: false, comment: '是否为系统管理员' })
isSystemAdmin: boolean;
@Column({
type: 'enum',
enum: UserRole,

View File

@ -0,0 +1,27 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('verify')
export class VerifyEntity {
@PrimaryGeneratedColumn('uuid')
public id: string;
@Column({ type: 'varchar', comment: '邮箱地址' })
public email: string;
@Column({ type: 'varchar', comment: '验证码' })
public verifyCode: string;
@CreateDateColumn({
type: 'timestamp',
name: 'createdAt',
comment: '创建时间',
})
createdAt: Date;
@UpdateDateColumn({
type: 'timestamp',
name: 'updatedAt',
comment: '更新时间',
})
updatedAt: Date;
}

View File

@ -0,0 +1,13 @@
import { SystemController } from '@controllers/system.controller';
import { SystemEntity } from '@entities/system.entity';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SystemService } from '@services/system.service';
@Module({
imports: [TypeOrmModule.forFeature([SystemEntity])],
providers: [SystemService],
exports: [SystemService],
controllers: [SystemController],
})
export class SystemModule {}

View File

@ -13,6 +13,9 @@ import { getConfig } from '@think/config';
import { Request as RequestType } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { SystemModule } from './system.module';
import { VerifyModule } from './verify.module';
const config = getConfig();
const jwtConfig = config.jwt as {
secretkey: string;
@ -61,6 +64,8 @@ const jwtModule = JwtModule.register({
forwardRef(() => WikiModule),
forwardRef(() => MessageModule),
forwardRef(() => StarModule),
forwardRef(() => VerifyModule),
forwardRef(() => SystemModule),
passModule,
jwtModule,
],

View File

@ -0,0 +1,14 @@
import { VerifyController } from '@controllers/verify.controller';
import { VerifyEntity } from '@entities/verify.entity';
import { SystemModule } from '@modules/system.module';
import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { VerifyService } from '@services/verify.service';
@Module({
imports: [TypeOrmModule.forFeature([VerifyEntity]), forwardRef(() => SystemModule)],
providers: [VerifyService],
exports: [VerifyService],
controllers: [VerifyController],
})
export class VerifyModule {}

View File

@ -0,0 +1,123 @@
import { SystemEntity } from '@entities/system.entity';
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import * as nodemailer from 'nodemailer';
import { Repository } from 'typeorm';
@Injectable()
export class SystemService {
constructor(
@InjectRepository(SystemEntity)
private readonly systemRepo: Repository<SystemEntity>,
private readonly confifgService: ConfigService
) {
this.loadFromConfigFile();
}
/**
*
* @returns
*/
public async getConfigFromDatabase() {
const data = await this.systemRepo.find();
return (data && data[0]) || null;
}
/**
*
* @param patch
* @returns
*/
public async updateConfigInDatabase(patch: Partial<SystemEntity>) {
const current = await this.getConfigFromDatabase();
return await this.systemRepo.save(await this.systemRepo.merge(current, patch));
}
/**
*
*/
private async loadFromConfigFile() {
const currentConfig = await this.getConfigFromDatabase();
const emailConfigKeys = ['emailServiceHost', 'emailServicePort', 'emailServiceUser', 'emailServicePassword'];
if (currentConfig && emailConfigKeys.every((configKey) => Boolean(currentConfig[configKey]))) {
return;
}
// 同步邮件服务配置
const emailConfigFromConfigFile = await this.confifgService.get('server.email');
let emailConfig = {};
if (emailConfigFromConfigFile && typeof emailConfigFromConfigFile === 'object') {
emailConfig = {
emailServiceHost: emailConfigFromConfigFile.host,
emailServicePort: emailConfigFromConfigFile.port,
emailServiceUser: emailConfigFromConfigFile.user,
emailServicePassword: emailConfigFromConfigFile.password,
};
}
const newConfig = currentConfig
? await this.systemRepo.merge(currentConfig, emailConfig)
: await this.systemRepo.create(emailConfig);
await this.systemRepo.save(newConfig);
console.log('[think] 已载入文件配置:', newConfig);
}
/**
*
* @param content
*/
public async sendEmail(mail: { to: string; subject: string; text?: string; html?: string }) {
const config = await this.getConfigFromDatabase();
if (!config) {
throw new HttpException('系统未配置邮箱服务,请联系系统管理员', HttpStatus.SERVICE_UNAVAILABLE);
}
const emailConfig = {
host: config.emailServiceHost,
port: +config.emailServicePort,
user: config.emailServiceUser,
pass: config.emailServicePassword,
};
if (Object.keys(emailConfig).some((key) => !emailConfig[key])) {
throw new HttpException('系统邮箱服务配置不完善,请联系系统管理员', HttpStatus.SERVICE_UNAVAILABLE);
}
const transporter = nodemailer.createTransport({
host: emailConfig.host,
port: emailConfig.port,
secure: emailConfig.port === 465,
auth: {
user: emailConfig.user,
pass: emailConfig.pass,
},
});
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(new Error(`发送邮件失败`));
}, 10 * 1000);
transporter.sendMail(
{
from: emailConfig.user,
...mail,
},
(err, info) => {
console.log('fas', err, info);
if (err) {
reject(err);
} else {
resolve(info);
}
}
);
});
}
}

View File

@ -1,6 +1,7 @@
import { CreateUserDto } from '@dtos/create-user.dto';
import { RegisterUserDto, ResetPasswordDto } from '@dtos/create-user.dto';
import { LoginUserDto } from '@dtos/login-user.dto';
import { UpdateUserDto } from '@dtos/update-user.dto';
import { SystemEntity } from '@entities/system.entity';
import { UserEntity } from '@entities/user.entity';
import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@ -8,11 +9,14 @@ import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import { MessageService } from '@services/message.service';
import { StarService } from '@services/star.service';
import { VerifyService } from '@services/verify.service';
import { WikiService } from '@services/wiki.service';
import { UserStatus } from '@think/domains';
import { instanceToPlain } from 'class-transformer';
import { Repository } from 'typeorm';
import { SystemService } from './system.service';
export type OutUser = Omit<UserEntity, 'comparePassword' | 'encryptPassword' | 'encrypt' | 'password'>;
@Injectable()
@ -33,8 +37,47 @@ export class UserService {
private readonly starService: StarService,
@Inject(forwardRef(() => WikiService))
private readonly wikiService: WikiService
) {}
private readonly wikiService: WikiService,
@Inject(forwardRef(() => VerifyService))
private readonly verifyService: VerifyService,
@Inject(forwardRef(() => SystemService))
private readonly systemService: SystemService
) {
this.createDefaultSystemAdminFromConfigFile();
}
/**
*
*/
private async createDefaultSystemAdminFromConfigFile() {
if (await this.userRepo.findOne({ isSystemAdmin: true })) {
return;
}
const config = await this.confifgService.get('server.admin');
if (!config.name || !config.password || !config.email) {
throw new Error(`请指定名称、密码和邮箱`);
}
if (await this.userRepo.findOne({ name: config.name })) {
return;
}
try {
await this.userRepo.save(
await this.userRepo.create({
...config,
isSystemAdmin: true,
})
);
console.log('[think] 已创建默认系统管理员,请尽快登录系统修改密码');
} catch (e) {
console.error(`[think] 创建默认系统管理员失败:`, e.message);
}
}
/**
* id
@ -71,7 +114,7 @@ export class UserService {
* @param user CreateUserDto
* @returns
*/
async createUser(user: CreateUserDto): Promise<OutUser> {
async createUser(user: RegisterUserDto): Promise<OutUser> {
if (await this.userRepo.findOne({ name: user.name })) {
throw new HttpException('该账户已被注册', HttpStatus.BAD_REQUEST);
}
@ -88,6 +131,10 @@ export class UserService {
throw new HttpException('该邮箱已被注册', HttpStatus.BAD_REQUEST);
}
if (!(await this.verifyService.checkVerifyCode(user.email, user.verifyCode))) {
throw new HttpException('验证码不正确,请检查', HttpStatus.BAD_REQUEST);
}
const res = await this.userRepo.create(user);
const createdUser = await this.userRepo.save(res);
const wiki = await this.wikiService.createWiki(createdUser, {
@ -105,14 +152,52 @@ export class UserService {
return instanceToPlain(createdUser) as OutUser;
}
/**
*
* @param registerUser
*/
public async resetPassword(resetPasswordDto: ResetPasswordDto) {
const { email, password, confirmPassword, verifyCode } = resetPasswordDto;
const inDatabaseUser = await this.userRepo.findOne({ email });
if (!inDatabaseUser) {
throw new HttpException('该邮箱尚未注册', HttpStatus.BAD_REQUEST);
}
if (password !== confirmPassword) {
throw new HttpException('两次密码不一致,请重试', HttpStatus.BAD_REQUEST);
}
if (!(await this.verifyService.checkVerifyCode(email, verifyCode))) {
throw new HttpException('验证码不正确,请检查', HttpStatus.BAD_REQUEST);
}
const user = await this.userRepo.save(
await this.userRepo.merge(inDatabaseUser, { password: UserEntity.encryptPassword(password) })
);
return instanceToPlain(user);
}
/**
*
* @param user
* @returns
*/
async login(user: LoginUserDto): Promise<{ user: OutUser; token: string; domain: string; expiresIn: number }> {
const currentSystemConfig = await this.systemService.getConfigFromDatabase();
const { name, password } = user;
const existUser = await this.userRepo.findOne({ where: { name } });
let existUser = await this.userRepo.findOne({ where: { name } });
if (!existUser) {
existUser = await this.userRepo.findOne({ where: { email: name } });
}
if (!existUser.isSystemAdmin && currentSystemConfig.isSystemLocked) {
throw new HttpException('系统维护中,暂不可登录', HttpStatus.FORBIDDEN);
}
if (!existUser || !(await UserEntity.comparePassword(password, existUser.password))) {
throw new HttpException('用户名或密码错误', HttpStatus.BAD_REQUEST);
@ -168,4 +253,80 @@ export class UserService {
const [data] = await query.getManyAndCount();
return data;
}
/**
*
* @param user
* @param targetUserId
*/
async toggleLockUser(user: UserEntity, targetUserId) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
const targetUser = await this.userRepo.findOne(targetUserId);
if (!targetUser) {
throw new HttpException('目标用户不存在', HttpStatus.NOT_FOUND);
}
const nextStatus = targetUser.status === UserStatus.normal ? UserStatus.locked : UserStatus.normal;
return await this.userRepo.save(await this.userRepo.merge(targetUser, { status: nextStatus }));
}
/**
*
* @param user
* @returns
*/
async getSystemConfig(user: UserEntity) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
return await this.systemService.getConfigFromDatabase();
}
/**
*
* @param user
*/
async sendTestEmail(user: UserEntity) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
const currentConfig = await this.systemService.getConfigFromDatabase();
try {
await this.systemService.sendEmail({
to: currentConfig.emailServiceUser,
subject: '测试邮件',
html: `<p>测试邮件</p>`,
});
return '测试邮件发送成功';
} catch (err) {
throw new HttpException('测试邮件发送失败!', HttpStatus.BAD_REQUEST);
}
}
/**
*
* @param user
* @param targetUserId
*/
async updateSystemConfig(user: UserEntity, systemConfig: Partial<SystemEntity>) {
const currentUser = await this.userRepo.findOne(user.id);
if (!currentUser.isSystemAdmin) {
throw new HttpException('您无权操作', HttpStatus.FORBIDDEN);
}
return await this.systemService.updateConfigInDatabase(systemConfig);
}
}

View File

@ -0,0 +1,69 @@
import { VerifyEntity } from '@entities/verify.entity';
import { forwardRef, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { SystemService } from '@services/system.service';
import { randomInt } from 'node:crypto';
import { Repository } from 'typeorm';
import { isEmail } from 'validator';
@Injectable()
export class VerifyService {
constructor(
@InjectRepository(VerifyEntity)
private readonly verifyRepo: Repository<VerifyEntity>,
@Inject(forwardRef(() => SystemService))
private readonly systemService: SystemService
) {}
/**
*
* @param record
*/
private async deleteVerifyCode(id) {
await this.verifyRepo.remove(await this.verifyRepo.find(id));
}
/**
*
* @param email
*/
public async sendVerifyCode(email: string) {
if (!email || !isEmail(email)) {
throw new HttpException('请检查邮箱地址', HttpStatus.BAD_REQUEST);
}
const verifyCode = randomInt(1000000).toString().padStart(6, '0');
const record = await this.verifyRepo.save(await this.verifyRepo.create({ email, verifyCode }));
await this.systemService.sendEmail({
to: email,
subject: '验证码',
html: `<p>您的验证码为 ${verifyCode}</p>`,
});
const timer = setTimeout(() => {
this.deleteVerifyCode(record.id);
clearTimeout(timer);
}, 5 * 60 * 1000);
}
/**
*
* @param email
* @param verifyCode
* @returns
*/
public async checkVerifyCode(email: string, verifyCode: string) {
if (!email || !isEmail(email)) {
throw new HttpException('请检查邮箱地址', HttpStatus.BAD_REQUEST);
}
const ret = await this.verifyRepo.findOne({ email, verifyCode });
if (!ret) {
throw new HttpException('验证码错误', HttpStatus.BAD_REQUEST);
}
return Boolean(ret);
}
}

View File

@ -326,6 +326,10 @@ export class WikiService {
const withHomeDocumentIdWiki = await this.wikiRepo.merge(wiki, { homeDocumentId });
await this.wikiRepo.save(withHomeDocumentIdWiki);
await this.starService.toggleStar(user, {
wikiId: wiki.id,
});
return withHomeDocumentIdWiki;
}

File diff suppressed because it is too large Load Diff