Optimize the project structure and introduce DDD design (#394)

This commit is contained in:
Archer
2023-10-12 17:46:37 +08:00
committed by GitHub
parent 76ac5238b6
commit ad7a17bf40
193 changed files with 1169 additions and 1084 deletions

View File

@@ -0,0 +1,27 @@
import { GET, POST, PUT, DELETE } from '@/web/common/api/request';
import type { CreateTrainingBillType } from '@/global/common/api/billReq.d';
import type { PaySchema } from '@/types/mongoSchema';
import type { PagingData, RequestPaging } from '@/types';
import { UserBillType } from '@/types/user';
export const getUserBills = (data: RequestPaging) =>
POST<PagingData<UserBillType>>(`/user/getBill`, data);
export const postCreateTrainingBill = (data: CreateTrainingBillType) =>
POST<string>(`/common/bill/createTrainingBill`, data);
export const getPayOrders = () => GET<PaySchema[]>(`/user/getPayOrders`);
export const getPayCode = (amount: number) =>
GET<{
codeUrl: string;
payId: string;
}>(`/plusApi/user/pay/getPayCode`, { amount });
export const checkPayResult = (payId: string) =>
GET<number>(`/plusApi/user/pay/checkPayResult`, { payId }).then(() => {
try {
GET('/user/account/paySuccess');
} catch (error) {}
return 'success';
});

View File

@@ -0,0 +1,115 @@
import { sseResponseEventEnum, TaskResponseKeyEnum } from '@/constants/chat';
import { getErrText } from '@/utils/tools';
import { parseStreamChunk, SSEParseData } from '@/utils/sse';
import type { ChatHistoryItemResType } from '@/types/chat';
import { StartChatFnProps } from '@/components/ChatBox';
import { getToken } from '@/utils/user';
type StreamFetchProps = {
url?: string;
data: Record<string, any>;
onMessage: StartChatFnProps['generatingMessage'];
abortSignal: AbortController;
};
export const streamFetch = ({
url = '/api/v1/chat/completions',
data,
onMessage,
abortSignal
}: StreamFetchProps) =>
new Promise<{
responseText: string;
[TaskResponseKeyEnum.responseData]: ChatHistoryItemResType[];
}>(async (resolve, reject) => {
try {
const response = await window.fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
token: getToken()
},
signal: abortSignal.signal,
body: JSON.stringify({
...data,
detail: true,
stream: true
})
});
if (!response?.body) {
throw new Error('Request Error');
}
const reader = response.body?.getReader();
// response data
let responseText = '';
let errMsg = '';
let responseData: ChatHistoryItemResType[] = [];
const parseData = new SSEParseData();
const read = async () => {
try {
const { done, value } = await reader.read();
if (done) {
if (response.status === 200 && !errMsg) {
return resolve({
responseText,
responseData
});
} else {
return reject({
message: errMsg || '响应过程出现异常~',
responseText
});
}
}
const chunkResponse = parseStreamChunk(value);
chunkResponse.forEach((item) => {
// parse json data
const { eventName, data } = parseData.parse(item);
if (!eventName || !data) return;
if (eventName === sseResponseEventEnum.answer && data !== '[DONE]') {
const answer: string = data?.choices?.[0]?.delta?.content || '';
onMessage({ text: answer });
responseText += answer;
} else if (
eventName === sseResponseEventEnum.moduleStatus &&
data?.name &&
data?.status
) {
onMessage(data);
} else if (
eventName === sseResponseEventEnum.appStreamResponse &&
Array.isArray(data)
) {
responseData = data;
} else if (eventName === sseResponseEventEnum.error) {
errMsg = getErrText(data, '流响应错误');
}
});
read();
} catch (err: any) {
if (err?.message === 'The user aborted a request.') {
return resolve({
responseText,
responseData
});
}
reject({
responseText,
message: getErrText(err, '请求异常')
});
}
};
read();
} catch (err: any) {
console.log(err, 'fetch error');
reject(getErrText(err, '请求异常'));
}
});

View File

@@ -0,0 +1,6 @@
import { GET, POST, PUT, DELETE } from './request';
import type { FetchResultItem } from '@/global/common/api/pluginRes.d';
export const postFetchUrls = (urlList: string[]) =>
POST<FetchResultItem[]>(`/plugins/urlFetch`, { urlList });

View File

@@ -0,0 +1,140 @@
import axios, {
Method,
InternalAxiosRequestConfig,
AxiosResponse,
AxiosProgressEvent
} from 'axios';
import { clearToken, getToken } from '@/utils/user';
import { TOKEN_ERROR_CODE } from '@/service/errorCode';
interface ConfigType {
headers?: { [key: string]: string };
hold?: boolean;
timeout?: number;
onUploadProgress?: (progressEvent: AxiosProgressEvent) => void;
cancelToken?: AbortController;
}
interface ResponseDataType {
code: number;
message: string;
data: any;
}
/**
* 请求开始
*/
function requestStart(config: InternalAxiosRequestConfig): InternalAxiosRequestConfig {
if (config.headers) {
config.headers.token = getToken();
}
return config;
}
/**
* 请求成功,检查请求头
*/
function responseSuccess(response: AxiosResponse<ResponseDataType>) {
return response;
}
/**
* 响应数据检查
*/
function checkRes(data: ResponseDataType) {
if (data === undefined) {
console.log('error->', data, 'data is empty');
return Promise.reject('服务器异常');
} else if (data.code < 200 || data.code >= 400) {
return Promise.reject(data);
}
return data.data;
}
/**
* 响应错误
*/
function responseError(err: any) {
console.log('error->', '请求错误', err);
if (!err) {
return Promise.reject({ message: '未知错误' });
}
if (typeof err === 'string') {
return Promise.reject({ message: err });
}
// 有报错响应
if (err?.code in TOKEN_ERROR_CODE) {
clearToken();
window.location.replace(
`/login?lastRoute=${encodeURIComponent(location.pathname + location.search)}`
);
return Promise.reject({ message: 'token过期重新登录' });
}
if (err?.response?.data) {
return Promise.reject(err?.response?.data);
}
return Promise.reject(err);
}
/* 创建请求实例 */
const instance = axios.create({
timeout: 60000, // 超时时间
headers: {
'content-type': 'application/json'
}
});
/* 请求拦截 */
instance.interceptors.request.use(requestStart, (err) => Promise.reject(err));
/* 响应拦截 */
instance.interceptors.response.use(responseSuccess, (err) => Promise.reject(err));
function request(
url: string,
data: any,
{ cancelToken, ...config }: ConfigType,
method: Method
): any {
/* 去空 */
for (const key in data) {
if (data[key] === null || data[key] === undefined) {
delete data[key];
}
}
return instance
.request({
baseURL: '/api',
url,
method,
data: ['POST', 'PUT'].includes(method) ? data : null,
params: !['POST', 'PUT'].includes(method) ? data : null,
signal: cancelToken?.signal,
...config // 用户自定义配置,可以覆盖前面的配置
})
.then((res) => checkRes(res.data))
.catch((err) => responseError(err));
}
/**
* api请求方式
* @param {String} url
* @param {Any} params
* @param {Object} config
* @returns
*/
export function GET<T>(url: string, params = {}, config: ConfigType = {}): Promise<T> {
return request(url, params, config, 'GET');
}
export function POST<T>(url: string, data = {}, config: ConfigType = {}): Promise<T> {
return request(url, data, config, 'POST');
}
export function PUT<T>(url: string, data = {}, config: ConfigType = {}): Promise<T> {
return request(url, data, config, 'PUT');
}
export function DELETE<T>(url: string, data = {}, config: ConfigType = {}): Promise<T> {
return request(url, data, config, 'DELETE');
}

View File

@@ -0,0 +1,21 @@
import { GET, POST, PUT } from './request';
import type { InitDateResponse } from '@/global/common/api/systemRes';
import { AxiosProgressEvent } from 'axios';
export const getSystemInitData = () => GET<InitDateResponse>('/system/getInitData');
export const postUploadImg = (base64Img: string) =>
POST<string>('/system/file/uploadImage', { base64Img });
export const postUploadFiles = (
data: FormData,
onUploadProgress: (progressEvent: AxiosProgressEvent) => void
) =>
POST<string[]>('/system/file/upload', data, {
onUploadProgress,
headers: {
'Content-Type': 'multipart/form-data; charset=utf-8'
}
});
export const getFileViewUrl = (fileId: string) => GET<string>('/system/file/readUrl', { fileId });

View File

@@ -0,0 +1,80 @@
import { useCallback, useRef, useState } from 'react';
import {
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
useDisclosure,
Button
} from '@chakra-ui/react';
import { useTranslation } from 'next-i18next';
export const useConfirm = (props: { title?: string | null; content?: string | null }) => {
const { t } = useTranslation();
const { title = t('Warning'), content } = props;
const [customContent, setCustomContent] = useState(content);
const { isOpen, onOpen, onClose } = useDisclosure();
const cancelRef = useRef(null);
const confirmCb = useRef<any>();
const cancelCb = useRef<any>();
return {
openConfirm: useCallback(
(confirm?: any, cancel?: any, customContent?: string) => {
confirmCb.current = confirm;
cancelCb.current = cancel;
customContent && setCustomContent(customContent);
return onOpen;
},
[onOpen]
),
ConfirmModal: useCallback(
() => (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
autoFocus={false}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent maxW={'min(90vw,400px)'}>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{title}
</AlertDialogHeader>
<AlertDialogBody>{customContent}</AlertDialogBody>
<AlertDialogFooter>
<Button
variant={'base'}
onClick={() => {
onClose();
typeof cancelCb.current === 'function' && cancelCb.current();
}}
>
{t('Cancel')}
</Button>
<Button
ml={4}
onClick={() => {
onClose();
typeof confirmCb.current === 'function' && confirmCb.current();
}}
>
{t('Confirm')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
),
[customContent, isOpen, onClose, t, title]
)
};
};

View File

@@ -0,0 +1,39 @@
import { useTranslation } from 'react-i18next';
import { useToast } from './useToast';
/**
* copy text data
*/
export const useCopyData = () => {
const { t } = useTranslation();
const { toast } = useToast();
return {
copyData: async (
data: string,
title: string | null = t('common.Copy Successful'),
duration = 1000
) => {
try {
if (navigator.clipboard) {
await navigator.clipboard.writeText(data);
} else {
throw new Error('');
}
} catch (error) {
const textarea = document.createElement('textarea');
textarea.value = data;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
}
toast({
title,
status: 'success',
duration
});
}
};
};

View File

@@ -0,0 +1,77 @@
import React, { useCallback, useRef } from 'react';
import { ModalFooter, ModalBody, Input, useDisclosure, Button } from '@chakra-ui/react';
import MyModal from '@/components/MyModal';
export const useEditTitle = ({
title,
placeholder = ''
}: {
title: string;
placeholder?: string;
}) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const inputRef = useRef<HTMLInputElement | null>(null);
const onSuccessCb = useRef<(content: string) => void | Promise<void>>();
const onErrorCb = useRef<(err: any) => void>();
const defaultValue = useRef('');
const onOpenModal = useCallback(
({
defaultVal,
onSuccess,
onError
}: {
defaultVal: string;
onSuccess: (content: string) => any;
onError?: (err: any) => void;
}) => {
onOpen();
onSuccessCb.current = onSuccess;
onErrorCb.current = onError;
defaultValue.current = defaultVal;
},
[onOpen]
);
const onclickConfirm = useCallback(async () => {
if (!inputRef.current) return;
try {
const val = inputRef.current.value;
await onSuccessCb.current?.(val);
onClose();
} catch (err) {
onErrorCb.current?.(err);
}
}, [onClose]);
// eslint-disable-next-line react/display-name
const EditModal = useCallback(
() => (
<MyModal isOpen={isOpen} onClose={onClose} title={title}>
<ModalBody>
<Input
ref={inputRef}
defaultValue={defaultValue.current}
placeholder={placeholder}
autoFocus
maxLength={20}
/>
</ModalBody>
<ModalFooter>
<Button mr={3} variant={'base'} onClick={onClose}>
</Button>
<Button onClick={onclickConfirm}></Button>
</ModalFooter>
</MyModal>
),
[isOpen, onClose, onclickConfirm, placeholder, title]
);
return {
onOpenModal,
EditModal
};
};

View File

@@ -0,0 +1,31 @@
import { useState, useCallback } from 'react';
import LoadingComponent from '@/components/Loading';
export const useLoading = (props?: { defaultLoading: boolean }) => {
const [isLoading, setIsLoading] = useState(props?.defaultLoading || false);
const Loading = useCallback(
({
loading,
fixed = true,
text = '',
zIndex
}: {
loading?: boolean;
fixed?: boolean;
text?: string;
zIndex?: number;
}): JSX.Element | null => {
return isLoading || loading ? (
<LoadingComponent fixed={fixed} text={text} zIndex={zIndex} />
) : null;
},
[isLoading]
);
return {
isLoading,
setIsLoading,
Loading
};
};

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
export const getMd = async (url: string) => {
const response = await fetch(`/docs/${url}`);
const textContent = await response.text();
return textContent;
};
export const useMarkdown = ({ url }: { url: string }) => {
const { data = '' } = useQuery([url], () => getMd(url));
return {
data
};
};

View File

@@ -0,0 +1,188 @@
import { useRef, useState, useCallback, useMemo, useEffect } from 'react';
import type { PagingData } from '@/types/index.d';
import { IconButton, Flex, Box, Input } from '@chakra-ui/react';
import { ArrowBackIcon, ArrowForwardIcon } from '@chakra-ui/icons';
import { useMutation } from '@tanstack/react-query';
import { useToast } from './useToast';
import { throttle } from 'lodash';
const thresholdVal = 100;
export function usePagination<T = any>({
api,
pageSize = 10,
params = {},
defaultRequest = true,
type = 'button',
onChange
}: {
api: (data: any) => any;
pageSize?: number;
params?: Record<string, any>;
defaultRequest?: boolean;
type?: 'button' | 'scroll';
onChange?: (pageNum: number) => void;
}) {
const elementRef = useRef<HTMLDivElement>(null);
const { toast } = useToast();
const [pageNum, setPageNum] = useState(1);
const [total, setTotal] = useState(0);
const [data, setData] = useState<T[]>([]);
const maxPage = useMemo(() => Math.ceil(total / pageSize) || 1, [pageSize, total]);
const { mutate, isLoading } = useMutation({
mutationFn: async (num: number = pageNum) => {
try {
const res: PagingData<T> = await api({
pageNum: num,
pageSize,
...params
});
setPageNum(num);
res.total !== undefined && setTotal(res.total);
setData(res.data);
onChange && onChange(num);
} catch (error: any) {
toast({
title: error?.message || '获取数据异常',
status: 'error'
});
console.log(error);
}
return null;
}
});
const Pagination = useCallback(() => {
return (
<Flex alignItems={'center'} justifyContent={'end'}>
<IconButton
isDisabled={pageNum === 1}
icon={<ArrowBackIcon />}
aria-label={'left'}
size={'sm'}
w={'28px'}
h={'28px'}
isLoading={isLoading}
onClick={() => mutate(pageNum - 1)}
/>
<Flex mx={2} alignItems={'center'}>
<Input
defaultValue={pageNum}
w={'50px'}
size={'xs'}
type={'number'}
min={1}
max={maxPage}
onBlur={(e) => {
const val = +e.target.value;
if (val === pageNum) return;
if (val >= maxPage) {
mutate(maxPage);
} else if (val < 1) {
mutate(1);
} else {
mutate(+e.target.value);
}
}}
onKeyDown={(e) => {
// @ts-ignore
const val = +e.target.value;
if (val && e.keyCode === 13) {
if (val === pageNum) return;
if (val >= maxPage) {
mutate(maxPage);
} else if (val < 1) {
mutate(1);
} else {
mutate(val);
}
}
}}
/>
<Box mx={2}>/</Box>
{maxPage}
</Flex>
<IconButton
isDisabled={pageNum === maxPage}
icon={<ArrowForwardIcon />}
aria-label={'left'}
size={'sm'}
isLoading={isLoading}
w={'28px'}
h={'28px'}
onClick={() => mutate(pageNum + 1)}
/>
</Flex>
);
}, [isLoading, maxPage, mutate, pageNum]);
const ScrollData = useCallback(
({ children, ...props }: { children: React.ReactNode }) => {
const loadText = useMemo(() => {
if (isLoading) return '请求中……';
if (total <= data.length) return '已加载全部';
return '点击加载更多';
}, []);
return (
<Box {...props} ref={elementRef} overflow={'overlay'}>
{children}
<Box
mt={2}
fontSize={'xs'}
color={'blackAlpha.500'}
textAlign={'center'}
cursor={loadText === '点击加载更多' ? 'pointer' : 'default'}
onClick={() => {
if (loadText !== '点击加载更多') return;
mutate(pageNum + 1);
}}
>
{loadText}
</Box>
</Box>
);
},
[data.length, isLoading, mutate, pageNum, total]
);
useEffect(() => {
if (!elementRef.current || type !== 'scroll') return;
const scrolling = throttle((e: Event) => {
const element = e.target as HTMLDivElement;
if (!element) return;
// 当前滚动位置
const scrollTop = element.scrollTop;
// 可视高度
const clientHeight = element.clientHeight;
// 内容总高度
const scrollHeight = element.scrollHeight;
// 判断是否滚动到底部
if (scrollTop + clientHeight + thresholdVal >= scrollHeight) {
mutate(pageNum + 1);
}
}, 100);
elementRef.current.addEventListener('scroll', scrolling);
return () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
elementRef.current?.removeEventListener('scroll', scrolling);
};
}, [elementRef, mutate, pageNum, type]);
useEffect(() => {
defaultRequest && mutate(1);
}, []);
return {
pageNum,
pageSize,
total,
data,
isLoading,
Pagination,
ScrollData,
getData: mutate
};
}

View File

@@ -0,0 +1,36 @@
import { useToast } from '@/web/common/hooks/useToast';
import { useMutation } from '@tanstack/react-query';
import type { UseMutationOptions } from '@tanstack/react-query';
import { getErrText } from '@/utils/tools';
import { useTranslation } from 'react-i18next';
interface Props extends UseMutationOptions<any, any, any, any> {
successToast?: string | null;
errorToast?: string | null;
}
export const useRequest = ({ successToast, errorToast, onSuccess, onError, ...props }: Props) => {
const { toast } = useToast();
const { t } = useTranslation();
const mutation = useMutation<unknown, unknown, any, unknown>({
...props,
onSuccess(res, variables: void, context: unknown) {
onSuccess?.(res, variables, context);
successToast &&
toast({
title: successToast,
status: 'success'
});
},
onError(err: any, variables: void, context: unknown) {
onError?.(err, variables, context);
errorToast &&
toast({
title: t(getErrText(err, errorToast)),
status: 'error'
});
}
});
return mutation;
};

View File

@@ -0,0 +1,44 @@
import React, { useRef, useCallback } from 'react';
import { Box } from '@chakra-ui/react';
import { useToast } from './useToast';
import { useTranslation } from 'react-i18next';
export const useSelectFile = (props?: { fileType?: string; multiple?: boolean }) => {
const { t } = useTranslation();
const { fileType = '*', multiple = false } = props || {};
const { toast } = useToast();
const SelectFileDom = useRef<HTMLInputElement>(null);
const File = useCallback(
({ onSelect }: { onSelect: (e: File[]) => void }) => (
<Box position={'absolute'} w={0} h={0} overflow={'hidden'}>
<input
ref={SelectFileDom}
type="file"
accept={fileType}
multiple={multiple}
onChange={(e) => {
if (!e.target.files || e.target.files?.length === 0) return;
if (e.target.files.length > 10) {
return toast({
status: 'warning',
title: t('file.Select a maximum of 10 files')
});
}
onSelect(Array.from(e.target.files));
}}
/>
</Box>
),
[fileType, multiple, t, toast]
);
const onOpen = useCallback(() => {
SelectFileDom.current && SelectFileDom.current.click();
}, []);
return {
File,
onOpen
};
};

View File

@@ -0,0 +1,13 @@
import { useToast as uToast, UseToastOptions } from '@chakra-ui/react';
export const useToast = (props?: UseToastOptions) => {
const toast = uToast({
position: 'top',
duration: 2000,
...props
});
return {
toast
};
};

View File

@@ -0,0 +1,81 @@
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import axios from 'axios';
import { OAuthEnum } from '@/constants/user';
type LoginStoreType = { provider: `${OAuthEnum}`; lastRoute: string; state: string };
type State = {
lastRoute: string;
setLastRoute: (e: string) => void;
loginStore?: LoginStoreType;
setLoginStore: (e: LoginStoreType) => void;
loading: boolean;
setLoading: (val: boolean) => null;
screenWidth: number;
setScreenWidth: (val: number) => void;
isPc?: boolean;
initIsPc(val: boolean): void;
gitStar: number;
loadGitStar: () => Promise<void>;
};
export const useGlobalStore = create<State>()(
devtools(
persist(
immer((set, get) => ({
lastRoute: '/app/list',
setLastRoute(e) {
set((state) => {
state.lastRoute = e;
});
},
loginStore: undefined,
setLoginStore(e) {
set((state) => {
state.loginStore = e;
});
},
loading: false,
setLoading: (val: boolean) => {
set((state) => {
state.loading = val;
});
return null;
},
screenWidth: 600,
setScreenWidth(val: number) {
set((state) => {
state.screenWidth = val;
state.isPc = val < 900 ? false : true;
});
},
isPc: undefined,
initIsPc(val: boolean) {
if (get().isPc !== undefined) return;
set((state) => {
state.isPc = val;
});
},
gitStar: 3700,
async loadGitStar() {
try {
const { data: git } = await axios.get('https://api.github.com/repos/labring/FastGPT');
set((state) => {
state.gitStar = git.stargazers_count;
});
} catch (error) {}
}
})),
{
name: 'globalStore',
partialize: (state) => ({
loginStore: state.loginStore
})
}
)
)
);

View File

@@ -0,0 +1,40 @@
import {
type QAModelItemType,
type ChatModelItemType,
type VectorModelItemType
} from '@/types/model';
import type { InitDateResponse } from '@/global/common/api/systemRes';
import { getSystemInitData } from '@/web/common/api/system';
import { delay } from '@/utils/tools';
import { FeConfigsType } from '@/types';
export let systemVersion = '0.0.0';
export let chatModelList: ChatModelItemType[] = [];
export let qaModel: QAModelItemType = {
model: 'gpt-3.5-turbo-16k',
name: 'GPT35-16k',
maxToken: 16000,
price: 0
};
export let vectorModelList: VectorModelItemType[] = [];
export let feConfigs: FeConfigsType = {};
let retryTimes = 3;
export const clientInitData = async (): Promise<InitDateResponse> => {
try {
const res = await getSystemInitData();
chatModelList = res.chatModels;
qaModel = res.qaModel;
vectorModelList = res.vectorModels;
feConfigs = res.feConfigs;
systemVersion = res.systemVersion;
return res;
} catch (error) {
retryTimes--;
await delay(500);
return clientInitData();
}
};

View File

@@ -0,0 +1,18 @@
export enum EventNameEnum {
guideClick = 'guideClick'
}
type EventNameType = `${EventNameEnum}`;
export const eventBus = {
list: new Map<EventNameType, Function>(),
on: function (name: EventNameType, fn: Function) {
this.list.set(name, fn);
},
emit: function (name: EventNameType, data: Record<string, any> = {}) {
const fn = this.list.get(name);
fn && fn(data);
},
off: function (name: EventNameType) {
this.list.delete(name);
}
};

View File

@@ -0,0 +1,262 @@
import mammoth from 'mammoth';
import Papa from 'papaparse';
import { postUploadImg, postUploadFiles, getFileViewUrl } from '@/web/common/api/system';
/**
* upload file to mongo gridfs
*/
export const uploadFiles = (
files: File[],
metadata: Record<string, any> = {},
percentListen?: (percent: number) => void
) => {
const form = new FormData();
form.append('metadata', JSON.stringify(metadata));
files.forEach((file) => {
form.append('file', file, encodeURIComponent(file.name));
});
return postUploadFiles(form, (e) => {
if (!e.total) return;
const percent = Math.round((e.loaded / e.total) * 100);
percentListen && percentListen(percent);
});
};
/**
* 读取 txt 文件内容
*/
export const readTxtContent = (file: File) => {
return new Promise((resolve: (_: string) => void, reject) => {
try {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result as string);
};
reader.onerror = (err) => {
console.log('error txt read:', err);
reject('读取 txt 文件失败');
};
reader.readAsText(file);
} catch (error) {
reject('浏览器不支持文件内容读取');
}
});
};
/**
* 读取 pdf 内容
*/
export const readPdfContent = (file: File) =>
new Promise<string>((resolve, reject) => {
try {
const pdfjsLib = window['pdfjs-dist/build/pdf'];
pdfjsLib.workerSrc = '/js/pdf.worker.js';
const readPDFPage = async (doc: any, pageNo: number) => {
const page = await doc.getPage(pageNo);
const tokenizedText = await page.getTextContent();
const pageText = tokenizedText.items
.map((token: any) => token.str)
.filter((item: string) => item)
.join('');
return pageText;
};
let reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = async (event) => {
if (!event?.target?.result) return reject('解析 PDF 失败');
try {
const doc = await pdfjsLib.getDocument(event.target.result).promise;
const pageTextPromises = [];
for (let pageNo = 1; pageNo <= doc.numPages; pageNo++) {
pageTextPromises.push(readPDFPage(doc, pageNo));
}
const pageTexts = await Promise.all(pageTextPromises);
resolve(pageTexts.join('\n'));
} catch (err) {
console.log(err, 'pdf load error');
reject('解析 PDF 失败');
}
};
reader.onerror = (err) => {
console.log(err, 'pdf load error');
reject('解析 PDF 失败');
};
} catch (error) {
reject('浏览器不支持文件内容读取');
}
});
/**
* 读取doc
*/
export const readDocContent = (file: File) =>
new Promise<string>((resolve, reject) => {
try {
const reader = new FileReader();
reader.readAsArrayBuffer(file);
reader.onload = async ({ target }) => {
if (!target?.result) return reject('读取 doc 文件失败');
try {
const res = await mammoth.extractRawText({
arrayBuffer: target.result as ArrayBuffer
});
resolve(res?.value);
} catch (error) {
window.umami?.track('wordReadError', {
err: error?.toString()
});
console.log('error doc read:', error);
reject('读取 doc 文件失败, 请转换成 PDF');
}
};
reader.onerror = (err) => {
window.umami?.track('wordReadError', {
err: err?.toString()
});
console.log('error doc read:', err);
reject('读取 doc 文件失败');
};
} catch (error) {
reject('浏览器不支持文件内容读取');
}
});
/**
* 读取csv
*/
export const readCsvContent = async (file: File) => {
try {
const textArr = await readTxtContent(file);
const csvArr = Papa.parse(textArr).data as string[][];
if (csvArr.length === 0) {
throw new Error('csv 解析失败');
}
return {
header: csvArr.shift() as string[],
data: csvArr.map((item) => item)
};
} catch (error) {
return Promise.reject('解析 csv 文件失败');
}
};
/**
* file download
*/
export const fileDownload = ({
text,
type,
filename
}: {
text: string;
type: string;
filename: string;
}) => {
// 导出为文件
const blob = new Blob([`\uFEFF${text}`], { type: `${type};charset=utf-8;` });
// 创建下载链接
const downloadLink = document.createElement('a');
downloadLink.href = window.URL.createObjectURL(blob);
downloadLink.download = filename;
// 添加链接到页面并触发下载
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
};
export async function getFileAndOpen(fileId: string) {
const url = await getFileViewUrl(fileId);
const asPath = `${location.origin}${url}`;
window.open(asPath, '_blank');
}
export const fileToBase64 = (file: File) => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => resolve(reader.result);
reader.onerror = (error) => reject(error);
});
};
/**
* compress image. response base64
* @param maxSize The max size of the compressed image
*/
export const compressImg = ({
file,
maxW = 200,
maxH = 200,
maxSize = 1024 * 100
}: {
file: File;
maxW?: number;
maxH?: number;
maxSize?: number;
}) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = async () => {
const img = new Image();
// @ts-ignore
img.src = reader.result;
img.onload = async () => {
let width = img.width;
let height = img.height;
if (width > height) {
if (width > maxW) {
height *= maxW / width;
width = maxW;
}
} else {
if (height > maxH) {
width *= maxH / height;
height = maxH;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
return reject('压缩图片异常');
}
ctx.drawImage(img, 0, 0, width, height);
const compressedDataUrl = canvas.toDataURL(file.type, 0.8);
// 移除 canvas 元素
canvas.remove();
if (compressedDataUrl.length > maxSize) {
return reject('图片太大了');
}
const src = await (async () => {
try {
const src = await postUploadImg(compressedDataUrl);
return src;
} catch (error) {
return compressedDataUrl;
}
})();
resolve(src);
};
};
reader.onerror = (err) => {
console.log(err);
reject('压缩图片异常');
};
});

View File

@@ -0,0 +1,37 @@
import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
import Cookies from 'js-cookie';
export const LANG_KEY = 'NEXT_LOCALE_LANG';
export enum LangEnum {
'zh' = 'zh',
'en' = 'en'
}
export const langMap = {
[LangEnum.en]: {
label: 'English',
icon: 'language_en'
},
[LangEnum.zh]: {
label: '简体中文',
icon: 'language_zh'
}
};
export const setLangStore = (value: `${LangEnum}`) => {
return Cookies.set(LANG_KEY, value, { expires: 7, sameSite: 'None', secure: true });
};
export const getLangStore = () => {
return (Cookies.get(LANG_KEY) as `${LangEnum}`) || LangEnum.zh;
};
export const serviceSideProps = (content: any) => {
const acceptLanguage = (content.req.headers['accept-language'] as string) || '';
const acceptLanguageList = acceptLanguage.split(/,|;/g);
// @ts-ignore
const firstLang = acceptLanguageList.find((lang) => langMap[lang]);
const language = content.req.cookies[LANG_KEY] || firstLang || 'zh';
return serverSideTranslations(language, undefined, null, content.locales);
};

View File

@@ -0,0 +1,129 @@
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useToast } from '@/web/common/hooks/useToast';
import { getErrText } from '@/utils/tools';
export const useAudioPlay = (props?: { ttsUrl?: string }) => {
const { ttsUrl } = props || {};
const { toast } = useToast();
const [audio, setAudio] = useState<HTMLAudioElement>();
const [audioLoading, setAudioLoading] = useState(false);
const [audioPlaying, setAudioPlaying] = useState(false);
const hasAudio = useMemo(() => {
if (ttsUrl) return true;
const voices = window.speechSynthesis?.getVoices?.() || []; // 获取语言包
const voice = voices.find((item) => {
return item.lang === 'zh-CN';
});
return !!voice;
}, [ttsUrl]);
const playAudio = useCallback(
async (text: string) => {
text = text.replace(/\\n/g, '\n');
try {
if (audio && ttsUrl) {
setAudioLoading(true);
const response = await fetch(ttsUrl, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
text
})
}).then((res) => res.blob());
const audioUrl = URL.createObjectURL(response);
audio.src = audioUrl;
audio.play();
} else {
// window speech
window.speechSynthesis?.cancel();
const msg = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis?.getVoices?.() || []; // 获取语言包
const voice = voices.find((item) => {
return item.lang === 'zh-CN';
});
if (voice) {
msg.onstart = () => {
setAudioPlaying(true);
};
msg.onend = () => {
setAudioPlaying(false);
msg.onstart = null;
msg.onend = null;
};
msg.voice = voice;
window.speechSynthesis?.speak(msg);
}
}
} catch (error) {
toast({
status: 'error',
title: getErrText(error, '语音播报异常')
});
}
setAudioLoading(false);
},
[audio, toast, ttsUrl]
);
const cancelAudio = useCallback(() => {
if (audio) {
audio.pause();
audio.src = '';
}
window.speechSynthesis?.cancel();
setAudioPlaying(false);
}, [audio]);
useEffect(() => {
if (ttsUrl) {
setAudio(new Audio());
} else {
setAudio(undefined);
}
}, [ttsUrl]);
useEffect(() => {
if (audio) {
audio.onplay = () => {
setAudioPlaying(true);
};
audio.onended = () => {
setAudioPlaying(false);
};
audio.onerror = () => {
setAudioPlaying(false);
};
}
const listen = () => {
cancelAudio();
};
window.addEventListener('beforeunload', listen);
return () => {
if (audio) {
audio.onplay = null;
audio.onended = null;
audio.onerror = null;
}
cancelAudio();
window.removeEventListener('beforeunload', listen);
};
}, [audio, cancelAudio]);
useEffect(() => {
return () => {
setAudio(undefined);
};
}, []);
return {
audioPlaying,
audioLoading,
hasAudio,
playAudio,
cancelAudio
};
};