chenos 479f64f197
feat: support i18n (#99)
* feat: i18next

* multi language data

* feat(client): locale support

* en-US for collections

* us-EN for routes

* en-US for routes

* en-US for attachments

* feat: partial translations

* translation

* add AntdConfigProvider

* translation

* translation

* feat: translation

* feat: add translation

* fix: improve translation

* feat: improve translation

* fix: SyntaxError: Unexpected token ) in JSON at position

* typo

* feat: improve translation

* feat: improve translation

* feat: language settings can be saved on the server

* feat: lang option of init cli

* demo translation

* typo

* change address field to textarea

* address data

* feat: chart translation

* feat: chart translation

* improve translation

* signin,signup,signout...

Co-authored-by: Zhou <zhou.working@gmail.com>
2021-11-08 19:32:59 +08:00

602 lines
19 KiB
TypeScript

import React, { useEffect } from 'react';
import {
connect,
mapProps,
mapReadPretty,
observer,
useField,
} from '@formily/react';
import { Button, Progress, Space, Upload as AntdUpload } from 'antd';
import {
UploadChangeParam,
UploadProps as AntdUploadProps,
DraggerProps as AntdDraggerProps,
} from 'antd/lib/upload';
import { reaction } from '@formily/reactive';
import { UploadFile } from 'antd/lib/upload/interface';
import { isArr, toArr as toArray, isValid } from '@formily/shared';
import { UPLOAD_PLACEHOLDER } from './placeholder';
import { usePrefixCls } from '@formily/antd/esm/__builtins__';
import { useState } from 'react';
import Lightbox from 'react-image-lightbox';
import 'react-image-lightbox/style.css'; // This only needs to be imported once in your app
import { useMap } from 'ahooks';
import DeleteOutlined from '@ant-design/icons/DeleteOutlined';
import UploadOutlined from '@ant-design/icons/UploadOutlined';
import PlusOutlined from '@ant-design/icons/PlusOutlined';
import DownloadOutlined from '@ant-design/icons/DownloadOutlined';
import './style.less';
import cls from 'classnames';
import { saveAs } from 'file-saver';
import { useTranslation } from 'react-i18next';
const toArr = (value) => {
if (!isValid(value)) {
return [];
}
if (Object.keys(value).length === 0) {
return [];
}
return toArray(value);
};
type UploadProps = Omit<AntdUploadProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
serviceErrorMessage?: string;
value?: any;
};
type DraggerProps = Omit<AntdDraggerProps, 'onChange'> & {
onChange?: (fileList: UploadFile[]) => void;
serviceErrorMessage?: string;
};
type ComposedUpload = React.FC<UploadProps> & {
Dragger?: React.FC<DraggerProps>;
File?: React.FC<UploadProps>;
Attachment?: React.FC<UploadProps>;
};
type IUploadProps = {
serviceErrorMessage?: string;
onChange?: (...args: any) => void;
};
const testOpts = (
ext: RegExp,
options: { exclude?: string[]; include?: string[] },
) => {
if (options && isArr(options.include)) {
return options.include.some((url) => ext.test(url));
}
if (options && isArr(options.exclude)) {
return !options.exclude.some((url) => ext.test(url));
}
return true;
};
const getImageByUrl = (url: string, options: any) => {
for (let i = 0; i < UPLOAD_PLACEHOLDER.length; i++) {
if (
UPLOAD_PLACEHOLDER[i].ext.test(url) &&
testOpts(UPLOAD_PLACEHOLDER[i].ext, options)
) {
return UPLOAD_PLACEHOLDER[i].icon || url;
}
}
return url;
};
const getURL = (target: any) => {
return target?.['url'] || target?.['downloadURL'] || target?.['imgURL'];
};
const getThumbURL = (target: any) => {
return (
target?.['thumbUrl'] ||
target?.['url'] ||
target?.['downloadURL'] ||
target?.['imgURL']
);
};
const getErrorMessage = (target: any) => {
return target?.errorMessage ||
target?.errMsg ||
target?.errorMsg ||
target?.message ||
typeof target?.error === 'string'
? target.error
: '';
};
const getState = (target: any) => {
if (target?.success === false) return 'error';
if (target?.failed === true) return 'error';
if (target?.error) return 'error';
return target?.state || target?.status;
};
const normalizeFileList = (fileList: UploadFile[]) => {
if (fileList && fileList.length) {
return fileList.map((file, index) => {
return {
...file,
uid: file.uid || `${index}`,
status: getState(file.response) || getState(file),
url: getURL(file) || getURL(file?.response),
thumbUrl: getImageByUrl(
getThumbURL(file) || getThumbURL(file?.response),
{
exclude: ['.png', '.jpg', '.jpeg', '.gif'],
},
),
};
});
}
return [];
};
const useValidator = (validator: (value: any) => string) => {
const field = useField<Formily.Core.Models.Field>();
useEffect(() => {
const dispose = reaction(
() => field.value,
(value) => {
const message = validator(value);
field.setFeedback({
type: 'error',
code: 'UploadError',
messages: message ? [message] : [],
});
},
);
return () => {
dispose();
};
}, []);
};
const useUploadValidator = (serviceErrorMessage = 'Upload Service Error') => {
useValidator((value) => {
const list = toArr(value);
for (let i = 0; i < list.length; i++) {
if (list[i]?.status === 'error') {
return (
getErrorMessage(list[i]?.response) ||
getErrorMessage(list[i]) ||
serviceErrorMessage
);
}
}
});
};
function useUploadProps<T extends IUploadProps = UploadProps>({
serviceErrorMessage,
...props
}: T) {
useUploadValidator(serviceErrorMessage);
const onChange = (param: UploadChangeParam<UploadFile>) => {
props.onChange?.(normalizeFileList([...param.fileList]));
};
return {
...props,
onChange,
};
}
export const Upload: ComposedUpload = connect(
(props: UploadProps) => {
return <AntdUpload {...useUploadProps(props)} />;
},
mapProps({
value: 'fileList',
}),
mapReadPretty((props) => {
const field = useField<Formily.Core.Models.Field>();
console.log('field.value', field.value);
return (field.value || []).map((item) => (
<div>
{item.url ? (
<a target={'_blank'} href={item.url}>
{item.name}
</a>
) : (
<span>{item.name}</span>
)}
</div>
));
}),
);
const Dragger = connect(
(props: DraggerProps) => {
return (
<div className={usePrefixCls('upload-dragger')}>
<AntdUpload.Dragger {...useUploadProps(props)} />
</div>
);
},
mapProps({
value: 'fileList',
}),
);
function toItem(file) {
if (file?.response?.data) {
file = file.response.data;
}
return {
...file,
id: file.id || file.uid,
title: file.title || file.name,
imageUrl: getImageByUrl(file.url, {
exclude: ['.png', '.jpg', '.jpeg', '.gif'],
}),
};
}
function toFileList(fileList: any) {
return toArr(fileList).map(toItem);
}
function toValue(fileList: any) {
return toArr(fileList)
.filter((file) => !file.response || file.status === 'done')
.map((file) => file?.response?.data || file);
}
function toMap(fileList: any) {
if (!fileList) {
return [];
}
if (typeof fileList !== 'object') {
return [];
}
let list = fileList;
if (!Array.isArray(fileList)) {
if (Object.keys({ ...fileList }).length === 0) {
return [];
}
list = [fileList];
}
console.log({ list, fileList });
return list.map((item) => {
return [item.id || item.uid, toItem(item)];
});
}
const toImages = (fileList) => {
if (!fileList) {
return [];
}
if (typeof fileList !== 'object') {
return [];
}
if (Object.keys(fileList).length === 0) {
return [];
}
let list = fileList;
if (!Array.isArray(fileList) && typeof fileList === 'object') {
list = [fileList];
}
return list.map((item) => {
return {
...item,
title: item.title || item.name,
imageUrl: getImageByUrl(item.url, {
exclude: ['.png', '.jpg', '.jpeg', '.gif'],
}),
};
});
};
Upload.Attachment = connect(
(props: UploadProps) => {
const { disabled, multiple, value, onChange } = props;
const [fileList, setFileList] = useState([]);
const [sync, setSync] = useState(true);
const images = fileList;
const [photoIndex, setPhotoIndex] = useState(0);
const [visible, setVisible] = useState(false);
const { t } = useTranslation();
useEffect(() => {
if (sync) {
setFileList(toFileList(value));
}
}, [value, sync]);
return (
<div>
<div className={cls('ant-upload-picture-card-wrapper nb-upload')}>
<div className={'ant-upload-list ant-upload-list-picture-card'}>
{fileList.map((file) => {
const handleClick = (e) => {
e.preventDefault();
const index = fileList.indexOf(file);
setVisible(true);
setPhotoIndex(index);
};
return (
<div className={'ant-upload-list-picture-card-container'}>
<div className="ant-upload-list-item ant-upload-list-item-done ant-upload-list-item-list-type-picture-card">
<div className={'ant-upload-list-item-info'}>
<span className="ant-upload-span">
<a
className="ant-upload-list-item-thumbnail"
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
>
{file.imageUrl && (
<img
src={file.imageUrl}
alt={file.title}
className="ant-upload-list-item-image"
/>
)}
</a>
<a
target="_blank"
rel="noopener noreferrer"
className="ant-upload-list-item-name"
title={file.title}
href={file.url}
onClick={handleClick}
>
{file.status === 'uploading'
? t('Uploading')
: file.title}
</a>
</span>
</div>
<span className={'ant-upload-list-item-actions'}>
<Space size={3}>
<Button
size={'small'}
type={'text'}
icon={<DownloadOutlined />}
onClick={() => {
saveAs(file.url, `${file.title}${file.extname}`);
}}
/>
{!disabled && (
<Button
size={'small'}
type={'text'}
icon={<DeleteOutlined />}
onClick={() => {
setSync(false);
setFileList((prevFileList) => {
if (!multiple) {
onChange(null);
return [];
}
const index = prevFileList.indexOf(file);
prevFileList.splice(index, 1);
onChange(toValue([...prevFileList]));
return [...prevFileList];
});
}}
/>
)}
</Space>
</span>
{file.status === 'uploading' && (
<div className={'ant-upload-list-item-progress'}>
<Progress
strokeWidth={2}
type={'line'}
showInfo={false}
percent={file.percent}
/>
</div>
)}
</div>
</div>
);
})}
<div className={'ant-upload-list-picture-card-container'}>
<AntdUpload
{...useUploadProps({ ...props })}
disabled={disabled}
multiple={multiple}
listType={'picture-card'}
fileList={fileList}
action={`${process.env.API_URL}attachments:upload`}
onChange={(info) => {
setSync(false);
if (multiple) {
if (info.file.status === 'done') {
onChange(toValue(info.fileList));
}
setFileList(info.fileList.map(toItem));
} else {
if (info.file.status === 'done') {
console.log('field.value', info.file?.response?.data);
// TODO(BUG): object 的联动有问题,不响应,折中的办法先置空再赋值
onChange(null);
onChange(info.file?.response?.data);
}
setFileList([toItem(info.file)]);
}
}}
showUploadList={false}
>
{!disabled && (multiple || toArr(value).length < 1) && (
<span>
<PlusOutlined />
<br /> {t('Upload')}
</span>
)}
</AntdUpload>
</div>
</div>
</div>
{visible && (
<Lightbox
// discourageDownloads={true}
mainSrc={images[photoIndex]?.imageUrl}
nextSrc={images[(photoIndex + 1) % images.length]?.imageUrl}
prevSrc={
images[(photoIndex + images.length - 1) % images.length]?.imageUrl
}
onCloseRequest={() => setVisible(false)}
onMovePrevRequest={() =>
setPhotoIndex((photoIndex + images.length - 1) % images.length)
}
onMoveNextRequest={() =>
setPhotoIndex((photoIndex + 1) % images.length)
}
imageTitle={images[photoIndex]?.title}
toolbarButtons={[
<button
style={{ fontSize: 22, background: 'none', lineHeight: 1 }}
type="button"
aria-label="Zoom in"
title="Zoom in"
className="ril-zoom-in ril__toolbarItemChild ril__builtinButton"
onClick={(e) => {
e.preventDefault();
const file = images[photoIndex];
saveAs(file.url, `${file.title}${file.extname}`);
}}
>
<DownloadOutlined />
</button>,
]}
/>
)}
</div>
);
},
mapReadPretty((props) => {
const field = useField<Formily.Core.Models.Field>();
const images = toImages(toArr(field.value));
const [photoIndex, setPhotoIndex] = useState(0);
const [visible, setVisible] = useState(false);
const { size } = props;
console.log('field.value', field.value, images);
return (
<div>
<div
className={cls(
'ant-upload-picture-card-wrapper nb-upload',
size ? `nb-upload-${size}` : null,
)}
>
<div className={'ant-upload-list ant-upload-list-picture-card'}>
{images.map((file) => {
const handleClick = (e) => {
e.preventDefault();
e.stopPropagation();
const index = images.indexOf(file);
setVisible(true);
setPhotoIndex(index);
};
return (
<div className={'ant-upload-list-picture-card-container'}>
<div className="ant-upload-list-item ant-upload-list-item-done ant-upload-list-item-list-type-picture-card">
<div className={'ant-upload-list-item-info'}>
<span className="ant-upload-span">
<a
className="ant-upload-list-item-thumbnail"
href={file.url}
target="_blank"
rel="noopener noreferrer"
onClick={handleClick}
>
{file.imageUrl && (
<img
src={`${file.imageUrl}?x-oss-process=style/thumbnail`}
alt={file.title}
className="ant-upload-list-item-image"
/>
)}
</a>
<a
target="_blank"
rel="noopener noreferrer"
className="ant-upload-list-item-name"
title={file.title}
href={file.url}
onClick={handleClick}
>
{file.title}
</a>
</span>
</div>
{size !== 'small' && (
<span className={'ant-upload-list-item-actions'}>
<Space size={3}>
<Button
size={'small'}
type={'text'}
icon={<DownloadOutlined />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
saveAs(file.url, `${file.title}${file.extname}`);
}}
/>
</Space>
</span>
)}
</div>
</div>
);
})}
</div>
</div>
{visible && (
<Lightbox
// discourageDownloads={true}
mainSrc={images[photoIndex]?.imageUrl}
nextSrc={images[(photoIndex + 1) % images.length]?.imageUrl}
prevSrc={
images[(photoIndex + images.length - 1) % images.length]?.imageUrl
}
// @ts-ignore
onCloseRequest={(e) => {
e.preventDefault();
e.stopPropagation();
setVisible(false);
}}
onMovePrevRequest={() =>
setPhotoIndex((photoIndex + images.length - 1) % images.length)
}
onMoveNextRequest={() =>
setPhotoIndex((photoIndex + 1) % images.length)
}
imageTitle={images[photoIndex]?.title}
toolbarButtons={[
<button
style={{ fontSize: 22, background: 'none', lineHeight: 1 }}
type="button"
aria-label="Zoom in"
title="Zoom in"
className="ril-zoom-in ril__toolbarItemChild ril__builtinButton"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
const file = images[photoIndex];
saveAs(file.url, `${file.title}${file.extname}`);
}}
>
<DownloadOutlined />
</button>,
]}
/>
)}
</div>
);
}),
);
Upload.Dragger = Dragger;
export default Upload;