mirror of
https://github.com/labring/FastGPT.git
synced 2025-08-03 13:38:00 +00:00
Optimize the project structure and introduce DDD design (#394)
This commit is contained in:
27
projects/app/src/web/common/api/bill.ts
Normal file
27
projects/app/src/web/common/api/bill.ts
Normal 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';
|
||||
});
|
115
projects/app/src/web/common/api/fetch.ts
Normal file
115
projects/app/src/web/common/api/fetch.ts
Normal 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, '请求异常'));
|
||||
}
|
||||
});
|
6
projects/app/src/web/common/api/plugin.ts
Normal file
6
projects/app/src/web/common/api/plugin.ts
Normal 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 });
|
140
projects/app/src/web/common/api/request.ts
Normal file
140
projects/app/src/web/common/api/request.ts
Normal 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');
|
||||
}
|
21
projects/app/src/web/common/api/system.ts
Normal file
21
projects/app/src/web/common/api/system.ts
Normal 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 });
|
80
projects/app/src/web/common/hooks/useConfirm.tsx
Normal file
80
projects/app/src/web/common/hooks/useConfirm.tsx
Normal 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]
|
||||
)
|
||||
};
|
||||
};
|
39
projects/app/src/web/common/hooks/useCopyData.tsx
Normal file
39
projects/app/src/web/common/hooks/useCopyData.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
77
projects/app/src/web/common/hooks/useEditTitle.tsx
Normal file
77
projects/app/src/web/common/hooks/useEditTitle.tsx
Normal 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
|
||||
};
|
||||
};
|
31
projects/app/src/web/common/hooks/useLoading.tsx
Normal file
31
projects/app/src/web/common/hooks/useLoading.tsx
Normal 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
|
||||
};
|
||||
};
|
15
projects/app/src/web/common/hooks/useMarkdown.ts
Normal file
15
projects/app/src/web/common/hooks/useMarkdown.ts
Normal 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
|
||||
};
|
||||
};
|
188
projects/app/src/web/common/hooks/usePagination.tsx
Normal file
188
projects/app/src/web/common/hooks/usePagination.tsx
Normal 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
|
||||
};
|
||||
}
|
36
projects/app/src/web/common/hooks/useRequest.tsx
Normal file
36
projects/app/src/web/common/hooks/useRequest.tsx
Normal 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;
|
||||
};
|
44
projects/app/src/web/common/hooks/useSelectFile.tsx
Normal file
44
projects/app/src/web/common/hooks/useSelectFile.tsx
Normal 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
|
||||
};
|
||||
};
|
13
projects/app/src/web/common/hooks/useToast.ts
Normal file
13
projects/app/src/web/common/hooks/useToast.ts
Normal 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
|
||||
};
|
||||
};
|
81
projects/app/src/web/common/store/global.ts
Normal file
81
projects/app/src/web/common/store/global.ts
Normal 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
|
||||
})
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
40
projects/app/src/web/common/store/static.ts
Normal file
40
projects/app/src/web/common/store/static.ts
Normal 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();
|
||||
}
|
||||
};
|
18
projects/app/src/web/common/utils/eventbus.ts
Normal file
18
projects/app/src/web/common/utils/eventbus.ts
Normal 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);
|
||||
}
|
||||
};
|
262
projects/app/src/web/common/utils/file.ts
Normal file
262
projects/app/src/web/common/utils/file.ts
Normal 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('压缩图片异常');
|
||||
};
|
||||
});
|
37
projects/app/src/web/common/utils/i18n.ts
Normal file
37
projects/app/src/web/common/utils/i18n.ts
Normal 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);
|
||||
};
|
129
projects/app/src/web/common/utils/voice.ts
Normal file
129
projects/app/src/web/common/utils/voice.ts
Normal 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
|
||||
};
|
||||
};
|
Reference in New Issue
Block a user