├── mock ├── .gitkeep └── api.ts ├── .env ├── src ├── models │ ├── .gitkeep │ ├── connect.d.ts │ ├── login.ts │ └── user.ts ├── locales │ └── en-US.ts ├── components │ ├── Image │ │ ├── index.less │ │ └── index.tsx │ ├── styles │ │ ├── index.less │ │ └── mixins │ │ │ ├── size.less │ │ │ └── reset.less │ ├── _util │ │ ├── style.ts │ │ └── type.ts │ ├── AsyncRender │ │ ├── index.ts │ │ ├── WithData │ │ │ └── index.tsx │ │ ├── WithFetch │ │ │ └── index.tsx │ │ └── utils.tsx │ ├── StandardList │ │ ├── index.less │ │ └── index.tsx │ ├── Statistics │ │ ├── index.less │ │ └── index.tsx │ ├── Form │ │ ├── FormContext.tsx │ │ ├── createFormItems │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── index.less │ │ ├── Props.ts │ │ ├── components │ │ │ └── CustomImagePicker │ │ │ │ └── index.tsx │ │ └── index.tsx │ ├── custom-antd-mobile │ │ ├── index.ts │ │ ├── CustomResult │ │ │ ├── index.less │ │ │ └── index.tsx │ │ ├── PopupModal.tsx │ │ ├── Button.tsx │ │ ├── SlideUpModal.tsx │ │ └── ImagesCarousel.tsx │ ├── PageLoading │ │ └── index.tsx │ ├── Paragraph │ │ ├── index.less │ │ └── index.tsx │ ├── Ellipsis │ │ ├── index.less │ │ └── index.tsx │ ├── PageWrapper │ │ ├── index.less │ │ └── index.tsx │ ├── Description │ │ ├── index.less │ │ └── index.tsx │ ├── SimpleCard │ │ ├── index.less │ │ └── index.tsx │ ├── CustomIcon │ │ └── index.tsx │ ├── Paper │ │ ├── index.less │ │ └── index.tsx │ ├── Avatar │ │ ├── index.less │ │ └── index.tsx │ ├── CutomAMap │ │ ├── PlaceSearch.tsx │ │ └── index.tsx │ └── Spin │ │ ├── index.less │ │ └── index.tsx ├── assets │ └── yay.jpg ├── pages │ ├── __tests__ │ │ ├── __mocks__ │ │ │ └── umi-plugin-locale.ts │ │ └── index.test.tsx │ ├── Demo │ │ ├── AMap │ │ │ └── index.js │ │ ├── StandardList │ │ │ └── index.tsx │ │ ├── Avatar │ │ │ └── index.tsx │ │ ├── mock.ts │ │ └── Form │ │ │ └── index.tsx │ ├── 404.tsx │ ├── User │ │ ├── Login.less │ │ └── Login.tsx │ ├── document.ejs │ └── Welcome │ │ ├── index.less │ │ └── index.tsx ├── app.ts ├── services │ ├── api.ts │ └── user.ts ├── utils │ ├── env.ts │ ├── hooks.ts │ ├── model.ts │ ├── reactUtils.tsx │ ├── authority.ts │ ├── request.ts │ ├── customUtils.ts │ └── utils.js ├── typings.d.ts ├── defaultSettings.ts ├── layouts │ ├── UserLayout.less │ ├── Footer.less │ ├── __tests__ │ │ └── index.test.tsx │ ├── Footer.tsx │ ├── BasicLayout.tsx │ └── UserLayout.tsx ├── global.ts └── global.less ├── .eslintrc ├── public ├── favicon.png └── logo.svg ├── .prettierignore ├── config ├── plugin.config.ts ├── router.config.ts └── config.ts ├── .prettierrc ├── tslint.yml ├── .editorconfig ├── .gitignore ├── jsconfig.json ├── tsconfig.json ├── LICENSE ├── README.md └── package.json /mock/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | ESLINT=1 2 | -------------------------------------------------------------------------------- /src/models/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-umi" 3 | } 4 | -------------------------------------------------------------------------------- /src/locales/en-US.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | 'index.start': 'Getting Started', 3 | } 4 | -------------------------------------------------------------------------------- /src/components/Image/index.less: -------------------------------------------------------------------------------- 1 | .imgWrapper { 2 | width: 100%; 3 | max-height: 800px; 4 | } 5 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunsii/ant-design-mobile-pro/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/assets/yay.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yunsii/ant-design-mobile-pro/HEAD/src/assets/yay.jpg -------------------------------------------------------------------------------- /src/components/styles/index.less: -------------------------------------------------------------------------------- 1 | @import './mixins/reset.less'; 2 | @import './mixins/size.less'; 3 | 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/*.md 2 | **/*.svg 3 | **/*.ejs 4 | **/*.html 5 | package.json 6 | .umi 7 | .umi-production 8 | -------------------------------------------------------------------------------- /src/pages/__tests__/__mocks__/umi-plugin-locale.ts: -------------------------------------------------------------------------------- 1 | export const formatMessage = (): string => 'Mock text'; 2 | -------------------------------------------------------------------------------- /src/components/_util/style.ts: -------------------------------------------------------------------------------- 1 | export const useStyles = (styles: React.CSSProperties) => (className: string) => { 2 | return styles[className]; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/AsyncRender/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AsyncRenderWithData } from './WithData'; 2 | export { default as AsyncRenderWithFetch } from './WithFetch'; 3 | -------------------------------------------------------------------------------- /src/components/StandardList/index.less: -------------------------------------------------------------------------------- 1 | .standardList { 2 | :global { 3 | .am-list-body { 4 | border-top: 0; 5 | background-color: unset; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | 2 | export const dva = { 3 | config: { 4 | onError(err: ErrorEvent) { 5 | err.preventDefault(); 6 | console.error(err.message); 7 | }, 8 | }, 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/styles/mixins/size.less: -------------------------------------------------------------------------------- 1 | // Sizing shortcuts 2 | 3 | .size(@width; @height) { 4 | width: @width; 5 | height: @height; 6 | } 7 | 8 | .square(@size) { 9 | .size(@size; @size); 10 | } 11 | -------------------------------------------------------------------------------- /config/plugin.config.ts: -------------------------------------------------------------------------------- 1 | const defaultSettings = require('../src/defaultSettings'); 2 | 3 | const { publicPath } = defaultSettings; 4 | 5 | export default (config: any) => { 6 | config.output.publicPath(publicPath); 7 | } 8 | -------------------------------------------------------------------------------- /src/components/Statistics/index.less: -------------------------------------------------------------------------------- 1 | .item { 2 | display: inline-block; 3 | width: 100%; 4 | height: 100%; 5 | margin-bottom: 8px; 6 | color: rgba(0, 0, 0, 0.45); 7 | } 8 | 9 | .value { 10 | font-size: 40px; 11 | margin: 0; 12 | } 13 | -------------------------------------------------------------------------------- /src/components/Form/FormContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const FormContext = React.createContext(null); 4 | export default FormContext; 5 | export const FormProvider = FormContext.Provider; 6 | export const FormConsumer = FormContext.Consumer; 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "trailingComma": "all", 5 | "printWidth": 100, 6 | "overrides": [ 7 | { 8 | "files": ".prettierrc", 9 | "options": { "parser": "json" } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /src/components/custom-antd-mobile/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Button } from './Button'; 2 | export { default as SlideUpModal } from './SlideUpModal'; 3 | export { default as PopupModal } from './PopupModal'; 4 | export { default as ImagesCarousel } from './ImagesCarousel'; -------------------------------------------------------------------------------- /src/services/api.ts: -------------------------------------------------------------------------------- 1 | // import { stringify } from 'qs'; 2 | import request from '@/utils/request'; 3 | 4 | export async function fakeAccountLogin(params) { 5 | return request('/api/login/account', { 6 | method: 'POST', 7 | data: params, 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/components/custom-antd-mobile/CustomResult/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .noTitle { 4 | padding-top: 30 * @hd; 5 | padding-bottom: 30 * @hd; 6 | 7 | :global { 8 | .am-result-message { 9 | margin-top: 0; 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/env.ts: -------------------------------------------------------------------------------- 1 | export function isWechatEnv(): boolean { 2 | const ua = navigator.userAgent.toLowerCase(); 3 | return !!ua.match(/MicroMessenger/i); 4 | } 5 | 6 | export function isIOSEnv(): boolean { 7 | const ua = navigator.userAgent.toLowerCase(); 8 | return !!ua.match(/(iPhone|iPad|iPod|iOS)/i); 9 | } 10 | -------------------------------------------------------------------------------- /tslint.yml: -------------------------------------------------------------------------------- 1 | defaultSeverity: error 2 | extends: 3 | - tslint-react 4 | - tslint-eslint-rules 5 | rules: 6 | eofline: true 7 | no-console: false 8 | no-construct: true 9 | no-debugger: true 10 | no-reference: true 11 | jsx-no-lambda: false 12 | jsx-no-multiline-js: false 13 | jsx-boolean-value: false 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = crlf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | 15 | [Makefile] 16 | indent_style = tab 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /npm-debug.log* 6 | /yarn-error.log 7 | /yarn.lock 8 | /package-lock.json 9 | 10 | # production 11 | /dist 12 | 13 | # misc 14 | .DS_Store 15 | 16 | # umi 17 | .umi 18 | .umi-production 19 | -------------------------------------------------------------------------------- /src/components/_util/type.ts: -------------------------------------------------------------------------------- 1 | export type Omit = Pick>; 2 | // https://stackoverflow.com/questions/46176165/ways-to-get-string-literal-type-of-array-values-without-enum-overhead 3 | export const tuple = (...args: T) => args; 4 | 5 | export const tupleNum = (...args: T) => args; 6 | -------------------------------------------------------------------------------- /src/components/styles/mixins/reset.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | @text-color: @color-text-base; 4 | 5 | .reset-component() { 6 | box-sizing: border-box; 7 | margin: 0; 8 | padding: 0; 9 | color: @text-color; 10 | font-size: @font-size-base; 11 | line-height: @line-height-base; 12 | list-style: none; 13 | } 14 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "emitDecoratorMetadata": true, 4 | "experimentalDecorators": true, 5 | "allowSyntheticDefaultImports": true, 6 | "baseUrl": "./", 7 | "paths": { 8 | "@/*": [ 9 | "./src/*" 10 | ] 11 | } 12 | }, 13 | "exclude": [ 14 | "build", 15 | "node_modules", 16 | "static" 17 | ] 18 | } -------------------------------------------------------------------------------- /src/components/PageLoading/index.tsx: -------------------------------------------------------------------------------- 1 | 2 | import React from 'react'; 3 | import Spin from '@/components/Spin'; 4 | 5 | // loading components from code split 6 | // https://umijs.org/plugin/umi-plugin-react.html#dynamicimport 7 | export default () => ( 8 |
9 | 10 |
11 | ); 12 | -------------------------------------------------------------------------------- /src/services/user.ts: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request'; 2 | 3 | export async function query(): Promise { 4 | return request('/api/users'); 5 | } 6 | 7 | export async function queryCurrent(): Promise { 8 | return request('/api/currentUser'); 9 | } 10 | 11 | export async function queryNotices(): Promise { 12 | return request('/api/notices'); 13 | } 14 | -------------------------------------------------------------------------------- /src/components/Paragraph/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .p { 4 | margin: unset; 5 | } 6 | 7 | .xs { 8 | font-size: @font-size-caption-sm; 9 | } 10 | 11 | .sm { 12 | font-size: @font-size-base; 13 | } 14 | 15 | .md { 16 | font-size: @font-size-subhead; 17 | } 18 | 19 | .lg { 20 | font-size: @font-size-caption; 21 | } 22 | 23 | .xl { 24 | font-size: @font-size-heading; 25 | } 26 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | declare module '*.less'; 3 | declare module '*.scss'; 4 | declare module '*.sass'; 5 | declare module '*.svg'; 6 | declare module '*.png'; 7 | declare module '*.jpg'; 8 | declare module '*.jpeg'; 9 | declare module '*.gif'; 10 | declare module '*.bmp'; 11 | declare module '*.tiff'; 12 | 13 | interface Window { 14 | AMap?: any; 15 | FastClick?: any; 16 | Promise?: any; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/Form/createFormItems/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .picture-extra { 4 | color: @color-text-caption; 5 | font-size: @font-size-caption; 6 | } 7 | 8 | .required { 9 | :global { 10 | .am-list-item { 11 | position: relative; 12 | 13 | &::before { 14 | position: absolute; 15 | top: 10 * @hd; 16 | left: 6 * @hd; 17 | color: @brand-important; 18 | content: '*'; 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/defaultSettings.ts: -------------------------------------------------------------------------------- 1 | // 不能使用 export 语法,umi dev 报错:SyntaxError: Unexpected token export 2 | module.exports = { 3 | // Your custom iconfont Symbol script Url 4 | // eg://at.alicdn.com/t/font_1039637_btcrd5co4w.js 5 | // 注意:如果需要图标多色,Iconfont 图标项目里要进行批量去色处理 6 | // Usage: https://github.com/ant-design/ant-design-pro/pull/3517 7 | iconfontUrl: '//at.alicdn.com/t/font_1347723_76wwxr8lpwf.js', 8 | 9 | base: '/ant-design-mobile-pro/', 10 | publicPath: '/ant-design-mobile-pro/', 11 | }; 12 | -------------------------------------------------------------------------------- /mock/api.ts: -------------------------------------------------------------------------------- 1 | import { delay } from 'roadhog-api-doc'; 2 | 3 | export default delay({ 4 | 'POST /api/login/account': (req, res) => { 5 | const { password, username } = req.body; 6 | if (password === 'password' && username === 'admin') { 7 | res.send({ 8 | status: 'ok', 9 | currentAuthority: 'admin', 10 | }); 11 | return; 12 | } 13 | res.send({ 14 | status: 'error', 15 | currentAuthority: 'guest', 16 | }); 17 | }, 18 | }, 1200); 19 | -------------------------------------------------------------------------------- /src/components/Ellipsis/index.less: -------------------------------------------------------------------------------- 1 | .ellipsis { 2 | display: inline-block; 3 | width: 100%; 4 | overflow: hidden; 5 | word-break: break-all; 6 | } 7 | 8 | .lines { 9 | position: relative; 10 | .shadow { 11 | position: absolute; 12 | z-index: -999; 13 | display: block; 14 | color: transparent; 15 | opacity: 0; 16 | } 17 | } 18 | 19 | .lineClamp { 20 | position: relative; 21 | display: -webkit-box; 22 | overflow: hidden; 23 | text-overflow: ellipsis; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/custom-antd-mobile/PopupModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal } from 'antd-mobile'; 3 | import { ModalProps } from 'antd-mobile/lib/modal/Modal'; 4 | 5 | export interface PopupModalProps extends ModalProps { 6 | children: any; 7 | } 8 | 9 | export default function PopupModal(props: PopupModalProps) { 10 | const { children, ...rest } = props; 11 | return ( 12 | 16 | {children} 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/custom-antd-mobile/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Button } from 'antd-mobile'; 3 | import { ButtonProps } from 'antd-mobile/lib/button'; 4 | 5 | export interface CustomButtonProps extends ButtonProps { 6 | children?: any; 7 | } 8 | 9 | export default function CustomButton(props: CustomButtonProps) { 10 | const { style, children, ...rest } = props; 11 | return ( 12 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /src/pages/Demo/AMap/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | // import { List, Switch, TextareaItem } from 'antd-mobile'; 3 | import PageWrapper from '@/components/PageWrapper'; 4 | import CutomAMap from '@/components/CutomAMap'; 5 | 6 | class FormDemo extends React.PureComponent { 7 | render() { 8 | return ( 9 | 10 | 13 | 14 | ); 15 | } 16 | } 17 | 18 | export default FormDemo; 19 | -------------------------------------------------------------------------------- /src/components/custom-antd-mobile/SlideUpModal.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Modal } from 'antd-mobile'; 3 | import { ModalProps } from 'antd-mobile/lib/modal/Modal'; 4 | 5 | export interface SlideUpModalProps extends ModalProps { 6 | children: any; 7 | } 8 | 9 | export default function SlideUpModal(props: SlideUpModalProps) { 10 | const { children, ...rest } = props; 11 | return ( 12 | 17 | {children} 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/utils/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export const useResolution = () => { 4 | const [resolution, setResolution] = useState({ width: window.innerWidth, height: window.innerHeight }); 5 | function handleResize() { 6 | setResolution({ width: window.innerWidth, height: window.innerHeight }); 7 | } 8 | useEffect(() => { 9 | window.addEventListener('resize', handleResize); 10 | return () => { 11 | window.removeEventListener('resize', handleResize); 12 | }; 13 | }, [resolution]); 14 | return resolution; 15 | }; 16 | -------------------------------------------------------------------------------- /src/components/Paragraph/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './index.less'; 4 | 5 | export interface ParagraphProps { 6 | children?: any; 7 | size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; 8 | style?: React.CSSProperties; 9 | className?: string; 10 | } 11 | 12 | export default function Paper(props: ParagraphProps) { 13 | const { children, size = 'sm', className, ...rest } = props; 14 | return ( 15 |

{children}

16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "importHelpers": true, 7 | "jsx": "react", 8 | "esModuleInterop": true, 9 | "sourceMap": true, 10 | "baseUrl": ".", 11 | "strict": true, 12 | "allowJs": true, 13 | "noEmit": true, 14 | "paths": { 15 | "@/*": ["src/*"], 16 | "@@/*": ["src/.umi/*"] 17 | }, 18 | "allowSyntheticDefaultImports": true, 19 | "experimentalDecorators": true, 20 | "noImplicitAny": false 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/components/custom-antd-mobile/CustomResult/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Result } from 'antd-mobile'; 3 | import { ResultProps } from 'antd-mobile/lib/result'; 4 | import classNames from 'classnames'; 5 | import styles from './index.less'; 6 | 7 | export const CustomResult: React.FC = ({ className, ...rest }) => { 8 | return ( 9 | 14 | ) 15 | } 16 | 17 | export default CustomResult; 18 | -------------------------------------------------------------------------------- /src/layouts/UserLayout.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .container { 4 | display: flex; 5 | flex-direction: column; 6 | height: 100vh; 7 | overflow: auto; 8 | background: linear-gradient(@brand-primary, #63c9ff); 9 | } 10 | 11 | .content { 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | flex: 1; 16 | padding: 32px 0; 17 | } 18 | 19 | .icon { 20 | color: white; 21 | width: @font-size-base; 22 | height: @font-size-base; 23 | position: relative; 24 | top: 4px; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/Form/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | // 4vw == 15 * @hd; 4 | 5 | @transition-duration: .3s; 6 | 7 | .render-footer { 8 | :global { 9 | .am-list-footer { 10 | transition: padding-top @transition-duration ease-in-out; 11 | padding: 0; 12 | } 13 | } 14 | } 15 | 16 | .show-errors { 17 | :global { 18 | .am-list-footer { 19 | padding: 9 * @hd 15 * @hd; 20 | } 21 | } 22 | } 23 | 24 | .hide-errors { 25 | :global { 26 | .am-list-footer { 27 | padding: 0; 28 | } 29 | } 30 | } 31 | 32 | .error-text { 33 | color: @brand-error; 34 | } 35 | -------------------------------------------------------------------------------- /src/layouts/Footer.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .globalFooter { 4 | margin: 36px 0 24px 0; 5 | padding: 0 16px; 6 | text-align: center; 7 | 8 | .links { 9 | margin-bottom: 8px; 10 | 11 | a { 12 | color: @color-text-secondary; 13 | transition: all 0.3s; 14 | 15 | &:not(:last-child) { 16 | margin-right: 40px; 17 | } 18 | 19 | &:hover { 20 | color: @color-text-base; 21 | } 22 | } 23 | } 24 | 25 | .copyright { 26 | color: @color-text-base-inverse; 27 | font-size: @font-size-base; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Image/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from './index.less'; 3 | import classNames from 'classnames'; 4 | 5 | export interface ImageProps extends React.HTMLAttributes { 6 | src?: string; 7 | } 8 | 9 | const Image: React.FC = (props) => { 10 | const { src, className, ...rest } = props; 11 | 12 | return ( 13 |
14 | 22 |
23 | ); 24 | } 25 | 26 | export default Image; 27 | -------------------------------------------------------------------------------- /src/components/PageWrapper/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .fixed { 4 | position: fixed; 5 | width: 100%; 6 | top: 0; 7 | z-index: 1000; 8 | } 9 | 10 | .fixedContent { 11 | padding-top: 45 * @hd; 12 | } 13 | 14 | .drawer { 15 | position: absolute; 16 | top: 90px; 17 | overflow: auto; 18 | -webkit-overflow-scrolling: touch; 19 | 20 | :global { 21 | .am-drawer-sidebar { 22 | background-color: #fff; 23 | overflow: auto; 24 | z-index: 120; // TabBar z-index is 100 25 | -webkit-overflow-scrolling: touch; 26 | } 27 | 28 | .am-drawer-sidebar .am-list { 29 | width: 260 * @hd; 30 | padding: 0; 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/pages/404.tsx: -------------------------------------------------------------------------------- 1 | import { Result, Icon } from 'antd-mobile'; 2 | import React from 'react'; 3 | import { history } from 'umi'; 4 | 5 | const NoFoundPage: React.FC<{}> = () => ( 6 | 13 | } 14 | title='404' 15 | message='Sorry, the page you visited does not exist.' 16 | buttonText='Back Home' 17 | buttonType='primary' 18 | onButtonClick={() => history.push('/')} 19 | style={{ height: '100vh', paddingTop: 'calc(50vh - 3rem)' }} 20 | /> 21 | ); 22 | 23 | export default NoFoundPage; 24 | -------------------------------------------------------------------------------- /src/components/Statistics/index.tsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | import * as React from 'react'; 3 | import styles from './index.less'; 4 | 5 | type Props = { 6 | title: any; 7 | value: any; 8 | align?: string; 9 | className?: string; 10 | }; 11 | const Statistics = (props: Props) => { 12 | const { title, value, align, className } = props; 13 | 14 | let style: React.CSSProperties = {}; 15 | if (align === 'center') { 16 | style = { textAlign: 'center' }; 17 | } 18 | return ( 19 |
20 |
{title}
21 |

{value || '-'}

22 |
23 | ); 24 | }; 25 | 26 | export default Statistics; 27 | -------------------------------------------------------------------------------- /src/layouts/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import BasicLayout from '..'; 3 | import React from 'react'; 4 | import renderer, { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer'; 5 | 6 | describe('Layout: BasicLayout', () => { 7 | it('Render correctly', () => { 8 | const wrapper: ReactTestRenderer = renderer.create(); 9 | expect(wrapper.root.children.length).toBe(1); 10 | const outerLayer = wrapper.root.children[0] as ReactTestInstance; 11 | expect(outerLayer.type).toBe('div'); 12 | const title = outerLayer.children[0] as ReactTestInstance; 13 | expect(title.type).toBe('h1'); 14 | expect(title.children[0]).toBe('Yay! Welcome to umi!'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/components/AsyncRender/WithData/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _repeat from 'lodash/repeat'; 3 | import _isFunction from 'lodash/isFunction'; 4 | import { checkAndRender } from '../utils'; 5 | 6 | export interface AsyncRenderWithDataProps extends React.PropsWithoutRef> { 7 | loading: boolean; 8 | data: T; 9 | children: ((data: T) => JSX.Element) | JSX.Element; 10 | } 11 | 12 | export function AsyncRenderWithData({ loading, data, children }: AsyncRenderWithDataProps): JSX.Element { 13 | const renderChildren = () => _isFunction(children) ? children(data) : children; 14 | return checkAndRender(loading, data) || renderChildren(); 15 | } 16 | 17 | export default AsyncRenderWithData; 18 | -------------------------------------------------------------------------------- /src/components/Description/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .description { 4 | text-align: start; 5 | margin: 0; 6 | padding-bottom: 12 * @hd; 7 | } 8 | 9 | .label { 10 | display: table-cell; 11 | color: @color-text-base; 12 | font-size: @font-size-base; 13 | white-space: nowrap; 14 | 15 | &::after { 16 | content: ':'; 17 | position: relative; 18 | top: -0.5px; 19 | margin: 0 8px 0 2px; 20 | } 21 | } 22 | 23 | .labelVertical { 24 | display: block; 25 | padding-bottom: 8 * @hd; 26 | } 27 | 28 | .text { 29 | display: table-cell; 30 | color: rgba(0, 0, 0, 0.65); 31 | font-size: @font-size-base; 32 | } 33 | 34 | .textVertical { 35 | display: block; 36 | padding-bottom: 4 * @hd; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/custom-antd-mobile/ImagesCarousel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Carousel } from 'antd-mobile'; 3 | import Image from '@/components/Image'; 4 | 5 | const imgHeight = "56vw"; 6 | 7 | export default (props: { 8 | images: string[]; 9 | }) => { 10 | const { 11 | images, 12 | } = props; 13 | 14 | console.log(images); 15 | const renderImage = (src) => ( 16 | 17 | ) 18 | 19 | return ( 20 |
21 | {images.length === 1 ? ( 22 | renderImage(images[0]) 23 | ) : ( 24 | 25 | {images.map((item) => ( 26 | renderImage(item) 27 | ))} 28 | 29 | )} 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /src/global.ts: -------------------------------------------------------------------------------- 1 | import { Toast } from 'antd-mobile'; 2 | import { addScriptToHead, setRemIfResize } from './utils/customUtils'; 3 | import { isIOSEnv } from '@/utils/env'; 4 | const defaultSettings = require('./defaultSettings'); 5 | 6 | const { iconfontUrl } = defaultSettings; 7 | 8 | addScriptToHead(iconfontUrl); 9 | setRemIfResize(750, 75); 10 | 11 | /** 12 | * 测试发现 使用 fastclick 的 iOS 设备可能会导致输入框点击多次才有响应, 13 | * 所以在 iOS 设备上先暂时不添加该功能 14 | */ 15 | if (!isIOSEnv()) { 16 | addScriptToHead('https://as.alipayobjects.com/g/component/fastclick/1.0.6/fastclick.js', () => { 17 | if ('addEventListener' in document) { 18 | document.addEventListener('DOMContentLoaded', function () { 19 | window.FastClick.attach(document.body); 20 | }, false); 21 | } 22 | }); 23 | } 24 | 25 | Toast.config({ 26 | mask: false, 27 | }); 28 | -------------------------------------------------------------------------------- /src/utils/model.ts: -------------------------------------------------------------------------------- 1 | import _find from 'lodash/find'; 2 | import _isArray from 'lodash/isArray'; 3 | import _partial from 'lodash/partial'; 4 | 5 | export function getData(response) { 6 | const { data } = response; 7 | return data || {}; 8 | } 9 | 10 | export function getList(response) { 11 | // 本地化更新数据,直接传入数组 12 | if (_isArray(response)) { 13 | return { list: response }; 14 | } 15 | // 请求接口响应数据处理 16 | const { data } = response; 17 | return data.data 18 | ? { 19 | list: _isArray(data.data) ? data.data : [], 20 | pagination: { 21 | current: data.current_page, 22 | pageSize: data.per_page, 23 | total: data.total, 24 | }, 25 | } 26 | : { list: _isArray(data) ? data : [] }; 27 | } 28 | 29 | export function isResponseOk(response) { 30 | return response && response.code === 200; 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/reactUtils.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import _debounce from 'lodash/debounce'; 3 | import { Result } from 'antd-mobile'; 4 | 5 | export function injectProps(props: any) { 6 | return (WrappedComponent: JSX.Element) => { 7 | return React.cloneElement(WrappedComponent, props); 8 | }; 9 | } 10 | 11 | export const setLoading = (ins: React.Component, field: string) => (func: Function) => async (...rest: any) => { 12 | ins.setState({ 13 | [field]: true, 14 | }); 15 | await func(...rest); 16 | ins.setState({ 17 | [field]: false, 18 | }); 19 | } 20 | 21 | export const setLoadingDebounceWith = 22 | (wait = 400) => 23 | (ins: React.Component, field: string) => 24 | (func: Function) => 25 | _debounce(setLoading(ins, field)(func), wait); 26 | 27 | export const setLoadingDebounce = setLoadingDebounceWith(); 28 | -------------------------------------------------------------------------------- /src/components/SimpleCard/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | .wrapper { 4 | background-color: @fill-base; 5 | padding: 8 * @hd 0 2 * @hd 0; 6 | border: 1PX solid @border-color-base; 7 | border-radius: @radius-md; 8 | 9 | .header { 10 | display: flex; 11 | align-items: center; 12 | font-size: @font-size-heading; 13 | 14 | div { 15 | flex: 1; 16 | } 17 | 18 | &::before { 19 | content: ''; 20 | border-left: 2 * @hd solid @brand-primary; 21 | border-right: 2 * @hd solid @brand-primary; 22 | margin-right: 8 * @hd; 23 | height: @font-size-heading; 24 | } 25 | 26 | .title { 27 | text-align: left; 28 | } 29 | 30 | .extra { 31 | text-align: right; 32 | padding-right: 12 * @hd; 33 | color: @color-text-caption; 34 | } 35 | } 36 | 37 | .body { 38 | margin: 8 * @hd; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/SimpleCard/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './index.less'; 4 | 5 | export interface SimpleCardProps { 6 | title: string; 7 | extra?: any; 8 | children: React.ReactChildren | React.ReactNode; 9 | style?: React.CSSProperties; 10 | className?: string; 11 | } 12 | 13 | export default (props: SimpleCardProps) => { 14 | const { title, extra, style, className, children } = props; 15 | return ( 16 |
17 |
18 |
19 | {title} 20 |
21 |
22 | {extra} 23 |
24 |
25 |
26 | {children} 27 |
28 |
29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/__tests__/index.test.tsx: -------------------------------------------------------------------------------- 1 | import 'jest'; 2 | import Index from '..'; 3 | import React from 'react'; 4 | import renderer, { ReactTestInstance, ReactTestRenderer } from 'react-test-renderer'; 5 | 6 | jest.mock('umi-plugin-locale'); 7 | 8 | describe('Page: index', () => { 9 | it('Render correctly', () => { 10 | const wrapper: ReactTestRenderer = renderer.create(); 11 | expect(wrapper.root.children.length).toBe(1); 12 | const outerLayer = wrapper.root.children[0] as ReactTestInstance; 13 | expect(outerLayer.type).toBe('div'); 14 | expect(outerLayer.children.length).toBe(2); 15 | const getStartLink = outerLayer.findAllByProps({ 16 | href: 'https://umijs.org/guide/getting-started.html', 17 | }) as ReactTestInstance[]; 18 | expect(getStartLink.length).toBe(1); 19 | expect(getStartLink[0].children).toMatchObject(['Mock text']); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/components/CustomIcon/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import classNames from 'classnames'; 3 | import { pxToRem } from '@/utils/customUtils'; 4 | 5 | type Props = { 6 | type: string; 7 | size?: number; 8 | style?: React.CSSProperties; 9 | className?: string; 10 | onClick?: () => void; 11 | }; 12 | const CustomIcon: React.FC = props => { 13 | const { type, className, style, size, ...rest } = props; 14 | 15 | const setStyle = () => { 16 | return size 17 | ? { 18 | width: pxToRem(size), 19 | height: pxToRem(size), 20 | } 21 | : {}; 22 | }; 23 | 24 | return ( 25 | 33 | ); 34 | }; 35 | 36 | export default CustomIcon; 37 | -------------------------------------------------------------------------------- /src/utils/authority.ts: -------------------------------------------------------------------------------- 1 | // use localStorage to store the authority info, which might be sent from server in actual project. 2 | export function getAuthority(str?: any) { 3 | // return localStorage.getItem('authority') || ['admin']; 4 | const authorityString = 5 | typeof str === 'undefined' ? localStorage.getItem('authority') : str; 6 | // authorityString could be admin, "admin", ["admin"] 7 | let authority; 8 | try { 9 | authority = JSON.parse(authorityString); 10 | } catch (e) { 11 | authority = authorityString; 12 | } 13 | if (typeof authority === 'string') { 14 | return [authority]; 15 | } 16 | if (!authority) { 17 | return ['guest']; 18 | } 19 | return authority; 20 | } 21 | 22 | export function setAuthority(authority) { 23 | const proAuthority = typeof authority === 'string' ? [authority] : authority; 24 | return localStorage.setItem('authority', JSON.stringify(proAuthority)); 25 | } 26 | -------------------------------------------------------------------------------- /src/components/AsyncRender/WithFetch/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import _repeat from 'lodash/repeat'; 3 | import _isFunction from 'lodash/isFunction'; 4 | import { checkAndRender } from '../utils'; 5 | 6 | export interface AsyncRenderWithFetchProps extends React.PropsWithoutRef> { 7 | getData: () => Promise | T; 8 | children: (data: T) => any; 9 | } 10 | 11 | export function AsyncRenderWithFetch({ getData, children }: AsyncRenderWithFetchProps): any { 12 | const [loading, setLoading] = useState(true); 13 | const [data, setData] = useState(); 14 | 15 | useEffect(() => { 16 | async function fetchData() { 17 | const data = await getData(); 18 | setLoading(false); 19 | setData(data); 20 | } 21 | 22 | fetchData(); 23 | }, []); 24 | 25 | return checkAndRender(loading, data) || children(data!); 26 | } 27 | 28 | export default AsyncRenderWithFetch; 29 | -------------------------------------------------------------------------------- /src/pages/Demo/StandardList/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'dva'; 3 | import { Card } from 'antd-mobile'; 4 | import PageWrapper from '@/components/PageWrapper'; 5 | import StandardList from '@/components/StandardList'; 6 | import { getMockData } from '../mock'; 7 | 8 | class StandardListDemo extends React.PureComponent { 9 | renderRow = (rowData, sectionID, rowID) => { 10 | return ( 11 | 14 | 15 | 16 |
17 |
{rowData.desc}
18 |
19 |
20 |
21 | ) 22 | } 23 | 24 | render() { 25 | return ( 26 | 27 | 31 | 32 | ); 33 | } 34 | } 35 | 36 | export default connect()(StandardListDemo); 37 | -------------------------------------------------------------------------------- /src/layouts/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './Footer.less'; 4 | 5 | export interface FooterProps { 6 | links?: Array<{ 7 | key?: string; 8 | title: React.ReactNode; 9 | href: string; 10 | blankTarget?: boolean; 11 | }>; 12 | copyright?: React.ReactNode; 13 | className?: string; 14 | } 15 | 16 | const Footer = ({ className, links, copyright }: FooterProps) => { 17 | const clsString = classNames(styles.globalFooter, className); 18 | return ( 19 |
20 | {links && ( 21 |
22 | {links.map(link => ( 23 | 29 | {link.title} 30 | 31 | ))} 32 |
33 | )} 34 | {copyright &&
{copyright}
} 35 |
36 | ); 37 | }; 38 | 39 | export default Footer; 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Yuns Xie 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/global.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | html, 4 | body, 5 | #root { 6 | height: 100%; 7 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 8 | 'Microsoft YaHei', 'Helvetica Neue', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 9 | 'Segoe UI Emoji', 'Segoe UI Symbol'; 10 | } 11 | 12 | body { 13 | margin: 0; 14 | 15 | /* IOS 禁止微信调整字体大小 */ 16 | -webkit-text-size-adjust: 100%; 17 | 18 | * { 19 | box-sizing: border-box; 20 | } 21 | } 22 | 23 | // router animation 24 | .fade-enter { 25 | opacity: 0; 26 | z-index: 1; 27 | } 28 | 29 | .fade-enter.fade-enter-active { 30 | opacity: 1; 31 | transition: opacity 250ms ease-in; 32 | } 33 | 34 | .fade-exit.fade-exit-active { 35 | position: absolute; 36 | top: 0; 37 | left: 0; 38 | right: 0; 39 | opacity: 0; 40 | transition: opacity 250ms ease-in; 41 | } 42 | 43 | // 默认 iconfont 样式 44 | .icon { 45 | width: @icon-size-xs; 46 | height: @icon-size-xs; 47 | vertical-align: -0.15 * @icon-size-xs; 48 | fill: currentColor; 49 | overflow: hidden; 50 | color: @brand-primary; 51 | } 52 | -------------------------------------------------------------------------------- /src/pages/User/Login.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | @circle-size: 180px; 4 | @logo-size: 150px; 5 | 6 | .wrapper { 7 | padding: 0 36px; 8 | width: 680px; 9 | } 10 | 11 | .logoWrapper { 12 | position: relative; 13 | 14 | > img { 15 | position: relative; 16 | width: @logo-size; 17 | padding: 20px; 18 | background-color: white; 19 | border-radius: 50%; 20 | z-index: 10; 21 | box-shadow: @brand-primary 0 4px 16px; 22 | } 23 | 24 | &::after { 25 | content: ''; 26 | position: absolute; 27 | width: @circle-size; 28 | height: @circle-size; 29 | top: -(@circle-size - @logo-size) / 2; 30 | left: -(@circle-size - @logo-size) / 2; 31 | box-sizing: border-box; 32 | border-radius: 50%; 33 | border: 1/75rem solid white; 34 | } 35 | 36 | // & ::before { 37 | // content : ''; 38 | // background-color: white; 39 | // position : absolute; 40 | // width : 60px; 41 | // height : 60px; 42 | // top : -6px; 43 | // left : -6px; 44 | // border-radius : 50%; 45 | // } 46 | } 47 | -------------------------------------------------------------------------------- /src/layouts/BasicLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { history, withRouter } from 'umi'; 3 | import { connect } from 'dva'; 4 | import { TransitionGroup, CSSTransition } from 'react-transition-group'; 5 | import { Dispatch, ConnectState } from '@/models/connect'; 6 | import { getAuthority } from '@/utils/authority'; 7 | // import { formatMessage } from 'umi-plugin-react/locale'; 8 | 9 | export interface BasicLayoutProps { 10 | dispatch: Dispatch; 11 | location: Location; 12 | } 13 | 14 | const BasicLayout: React.FC = props => { 15 | const { dispatch, children, location } = props; 16 | useEffect(() => { 17 | if (getAuthority()[0] !== 'admin') { 18 | history.push('/user/login'); 19 | } 20 | if (dispatch) { 21 | dispatch({ 22 | type: 'user/fetchCurrent', 23 | }); 24 | } 25 | }, []); 26 | 27 | return ( 28 | 29 | 30 | {children} 31 | 32 | 33 | ); 34 | }; 35 | 36 | export default connect(({ settings }: ConnectState) => ({ 37 | settings, 38 | }))(withRouter(BasicLayout as any)); 39 | -------------------------------------------------------------------------------- /src/pages/Demo/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Grid } from 'antd-mobile'; 3 | import PageWrapper from '@/components/PageWrapper'; 4 | import CustomIcon from '@/components/CustomIcon'; 5 | import Avatar from '@/components/Avatar'; 6 | 7 | const data = [ 8 | { 9 | icon: , 10 | }, 11 | { 12 | icon: } />, 13 | }, 14 | { 15 | icon: U, 16 | }, 17 | { 18 | icon: USER, 19 | }, 20 | { 21 | icon: , 22 | }, 23 | { 24 | icon: U, 25 | }, 26 | { 27 | icon: , 28 | }, 29 | ] 30 | 31 | class AvatarDemo extends React.PureComponent { 32 | render() { 33 | return ( 34 | 35 | 39 | 40 | ); 41 | } 42 | } 43 | 44 | export default AvatarDemo; 45 | -------------------------------------------------------------------------------- /src/layouts/UserLayout.tsx: -------------------------------------------------------------------------------- 1 | // import DocumentTitle from 'react-document-title'; 2 | import React, { Fragment } from 'react'; 3 | import { connect } from 'dva'; 4 | import { } from 'antd-mobile' 5 | // import { formatMessage } from 'umi-plugin-react/locale'; 6 | import Footer from './Footer'; 7 | import CustomIcon from '@/components/CustomIcon'; 8 | import { ConnectState } from '@/models/connect'; 9 | import styles from './UserLayout.less'; 10 | 11 | export interface UserLayoutProps { 12 | } 13 | 14 | const copyright = ( 15 | 16 | Copyright 2019 theprimone 出品 17 | 18 | ); 19 | 20 | const UserLayout: React.SFC = props => { 21 | const { 22 | children, 23 | } = props; 24 | 25 | return ( 26 |
27 |
28 | {children} 29 |
30 |
31 |
32 | ); 33 | }; 34 | 35 | export default connect(({ settings }: ConnectState) => ({ 36 | ...settings, 37 | }))(UserLayout); 38 | -------------------------------------------------------------------------------- /config/router.config.ts: -------------------------------------------------------------------------------- 1 | export default [ 2 | // user 3 | { 4 | path: '/user', 5 | component: '../layouts/UserLayout', 6 | routes: [ 7 | { path: '/user', redirect: '/user/login' }, 8 | { path: '/user/login', component: './User/Login' }, 9 | ], 10 | }, 11 | { 12 | path: '/', 13 | component: '../layouts/BasicLayout', 14 | // Routes: ['src/pages/Authorized'], 15 | // authority: ['admin', 'user'], 16 | routes: [ 17 | { 18 | path: '/', 19 | component: './Welcome', 20 | }, 21 | { 22 | path: '/demo', 23 | routes: [ 24 | { 25 | path: '/demo/standard-list', 26 | component: './Demo/StandardList', 27 | }, 28 | { 29 | path: '/demo/form', 30 | component: './Demo/Form', 31 | }, 32 | { 33 | path: '/demo/amap', 34 | component: './Demo/AMap', 35 | }, 36 | { 37 | path: '/demo/avatar', 38 | component: './Demo/Avatar', 39 | }, 40 | ] 41 | }, 42 | { 43 | component: './404', 44 | }, 45 | ], 46 | }, 47 | { 48 | component: './404', 49 | }, 50 | ]; 51 | -------------------------------------------------------------------------------- /src/components/AsyncRender/utils.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import CustomResult from '@/components/custom-antd-mobile/CustomResult'; 3 | import _repeat from 'lodash/repeat'; 4 | import _isFunction from 'lodash/isFunction'; 5 | 6 | export function isEmptyData(data: T) { 7 | const isEmptyArray = Array.isArray(data) && !data.length; 8 | return isEmptyArray || !data || !Object.keys(data).length; 9 | } 10 | 11 | export const Loading: React.FC<{}> = () => { 12 | const [dotsNo, setDotsNo] = useState(0); 13 | const dotsLength = 3; 14 | 15 | useEffect(() => { 16 | const setDotNumsInterval = setInterval(() => { 17 | setDotsNo((dotsNo + 1) % (dotsLength + 1)); 18 | }, 333); 19 | 20 | return () => { clearInterval(setDotNumsInterval); } 21 | }, [dotsNo]); 22 | 23 | return ( 24 |

25 | {`加载中${_repeat('.', dotsNo)}`} 26 | 27 | {`${_repeat('.', (dotsLength + 1) - dotsNo)}`} 28 | 29 |

30 | ) 31 | } 32 | 33 | export function checkAndRender(loading: boolean, data: T) { 34 | if (loading) { 35 | return } />; 36 | } 37 | if (isEmptyData(data)) { 38 | return ; 39 | } 40 | return null; 41 | } 42 | -------------------------------------------------------------------------------- /src/pages/Demo/mock.ts: -------------------------------------------------------------------------------- 1 | import _get from 'lodash/get'; 2 | 3 | const data = [ 4 | { 5 | title: 'Thomas Carlyle', 6 | desc: 'Cease to struggle and you cease to live.', 7 | }, 8 | { 9 | title: 'John Ruskin', 10 | desc: 'Living without an aim is like sailing without a compass.', 11 | }, 12 | { 13 | title: 'Julius Erving', 14 | desc: 'Gods determine what you\'re going to be.', 15 | }, 16 | ]; 17 | 18 | export interface DataItem { 19 | id: string; 20 | title: string; 21 | desc: string; 22 | } 23 | 24 | const lastPage = 4; 25 | 26 | function genData(page: number) { 27 | const result: DataItem[] = [] 28 | for (let i = 0; i < 10; i += 1) { 29 | result.push({ ...data[i % data.length], id: `${page - 1}${i}` }); 30 | } 31 | return result; 32 | } 33 | 34 | export interface IData { 35 | list: DataItem[]; 36 | pagination: { 37 | current: number; 38 | last: number; 39 | }, 40 | } 41 | 42 | export interface GetMockDataConfig { 43 | wait?: number; 44 | } 45 | 46 | export async function getMockData(current: number, config?: GetMockDataConfig): Promise { 47 | const mockData = new Promise((resolve) => { 48 | setTimeout(() => { 49 | resolve({ 50 | list: genData(current), 51 | pagination: { 52 | current, 53 | last: lastPage, 54 | } 55 | }); 56 | }, _get(config, 'wait') || 600); 57 | }) 58 | return await mockData; 59 | } 60 | -------------------------------------------------------------------------------- /src/components/Paper/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | @size: 150px; 4 | 5 | .paper { 6 | position: relative; 7 | box-sizing: border-box; 8 | background: #fff; 9 | padding: 9px 15px; 10 | border-radius: 8px; 11 | box-shadow: 0px 1px 10px 0px #f2f2f2; 12 | border: 1PX solid @border-color-base; 13 | border-radius: @radius-md; 14 | } 15 | 16 | .paper-color-pink { 17 | background: #eb2f96; 18 | } 19 | 20 | .paper-color-red { 21 | background: #f5222d; 22 | } 23 | 24 | .paper-color-yellow { 25 | background: #fadb14; 26 | } 27 | 28 | .paper-color-orange { 29 | background: #fa8c16; 30 | } 31 | 32 | .paper-color-cyan { 33 | background: #13c2c2; 34 | } 35 | 36 | .paper-color-green { 37 | background: #52c41a; 38 | } 39 | 40 | .paper-color-blue { 41 | background: #1890ff; 42 | } 43 | 44 | .paper-color-purple { 45 | background: #722ed1; 46 | } 47 | 48 | .paper-color-geekblue { 49 | background: #2f54eb; 50 | } 51 | 52 | .paper-color-magenta { 53 | background: #eb2f96; 54 | } 55 | 56 | .paper-color-volcano { 57 | background: #fa541c; 58 | } 59 | 60 | .paper-color-gold { 61 | background: #faad14; 62 | } 63 | 64 | .paper-color-lime { 65 | background: #a0d911; 66 | } 67 | 68 | .icon { 69 | position: absolute; 70 | left: 50%; 71 | top: -@size / 2; 72 | margin-left: -@size / 2; 73 | width: @size; 74 | height: @size; 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * request 网络请求工具 3 | * 更详细的 api 文档: https://github.com/umijs/umi-request 4 | */ 5 | import { extend } from 'umi-request'; 6 | import { Toast } from 'antd-mobile'; 7 | 8 | // const codeMessage = { 9 | // 200: '服务器成功返回请求的数据。', 10 | // 201: '新建或修改数据成功。', 11 | // 202: '一个请求已经进入后台排队(异步任务)。', 12 | // 204: '删除数据成功。', 13 | // 400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。', 14 | // 401: '用户没有权限(令牌、用户名、密码错误)。', 15 | // 403: '用户得到授权,但是访问是被禁止的。', 16 | // 404: '发出的请求针对的是不存在的记录,服务器没有进行操作。', 17 | // 406: '请求的格式不可得。', 18 | // 410: '请求的资源被永久删除,且不会再得到的。', 19 | // 422: '当创建一个对象时,发生一个验证错误。', 20 | // 500: '服务器发生错误,请检查服务器。', 21 | // 502: '网关错误。', 22 | // 503: '服务不可用,服务器暂时过载或维护。', 23 | // 504: '网关超时。', 24 | // }; 25 | 26 | /** 27 | * 异常处理程序 28 | */ 29 | const errorHandler = (error: { response: Response }): Response => { 30 | const { response } = error; 31 | if (response && response.status) { 32 | // const errorText = codeMessage[response.status] || response.statusText; 33 | const { status, url } = response; 34 | 35 | Toast.fail(`请求错误 ${status}`); 36 | // notification.error({ 37 | // message: `请求错误 ${status}: ${url}`, 38 | // description: errorText, 39 | // }); 40 | } 41 | return response; 42 | }; 43 | 44 | /** 45 | * 配置request请求时的默认参数 46 | */ 47 | const request = extend({ 48 | errorHandler, // 默认错误处理 49 | credentials: 'include', // 默认请求是否带上cookie 50 | }); 51 | 52 | export default request; 53 | -------------------------------------------------------------------------------- /src/models/connect.d.ts: -------------------------------------------------------------------------------- 1 | import { AnyAction } from 'redux'; 2 | import { EffectsCommandMap } from 'dva'; 3 | import { RouterTypes } from 'umi'; 4 | // import { GlobalModelState } from './global'; 5 | import { UserModelState } from './user'; 6 | import { LoginState } from './login'; 7 | 8 | export interface SettingModelState { 9 | primaryColor: string; 10 | primaryTapColor: string; 11 | iconfontUrl: string; 12 | } 13 | 14 | export { UserModelState }; 15 | 16 | export interface Loading { 17 | global: boolean; 18 | effects: { [key: string]: boolean | undefined }; 19 | models: { 20 | global?: boolean; 21 | menu?: boolean; 22 | setting?: boolean; 23 | user?: boolean; 24 | }; 25 | } 26 | 27 | export interface ConnectState { 28 | // global: GlobalModelState; 29 | loading: Loading; 30 | settings: SettingModelState; 31 | user: UserModelState; 32 | login: LoginState; 33 | } 34 | 35 | export type Effect = ( 36 | action: AnyAction, 37 | effects: EffectsCommandMap & { select: (func: (state: ConnectState) => T) => T }, 38 | ) => void; 39 | 40 | /** 41 | * @type P: Type of payload 42 | * @type C: Type of callback 43 | */ 44 | export type Dispatch =

void>(action: { 45 | type: string; 46 | payload?: P; 47 | callback?: C; 48 | [key: string]: any; 49 | }) => any; 50 | 51 | /** 52 | * @type T: Params matched in dynamic routing 53 | */ 54 | export interface ConnectProps { 55 | dispatch?: Dispatch; 56 | } 57 | -------------------------------------------------------------------------------- /src/components/Avatar/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | @import '../styles/index.less'; 3 | 4 | @avatar-size-base: 32 * @hd; 5 | @avatar-size-lg: 40 * @hd; 6 | @avatar-size-sm: 24 * @hd; 7 | @avatar-font-size-base: 18 * @hd; 8 | @avatar-font-size-lg: 24 * @hd; 9 | @avatar-font-size-sm: 14 * @hd; 10 | @avatar-bg: #ccc; 11 | @avatar-color: #fff; 12 | @avatar-border-radius: 4 * @hd; 13 | 14 | @avatar-prefix-cls: ~'avatar'; 15 | 16 | .@{avatar-prefix-cls} { 17 | .reset-component(); 18 | 19 | position: relative; 20 | display: inline-block; 21 | overflow: hidden; 22 | color: @avatar-color; 23 | white-space: nowrap; 24 | text-align: center; 25 | vertical-align: middle; 26 | background: @avatar-bg; 27 | 28 | &-image { 29 | background: transparent; 30 | } 31 | 32 | .avatar-size(@avatar-size-base, @avatar-font-size-base); 33 | 34 | &-lg { 35 | .avatar-size(@avatar-size-lg, @avatar-font-size-lg); 36 | } 37 | 38 | &-sm { 39 | .avatar-size(@avatar-size-sm, @avatar-font-size-sm); 40 | } 41 | 42 | &-square { 43 | border-radius: @avatar-border-radius; 44 | } 45 | 46 | & > img { 47 | display: block; 48 | width: 100%; 49 | height: 100%; 50 | object-fit: cover; 51 | } 52 | } 53 | 54 | .avatar-size(@size, @font-size) { 55 | width: @size; 56 | height: @size; 57 | line-height: @size; 58 | border-radius: 50%; 59 | 60 | &-string { 61 | position: absolute; 62 | left: 50%; 63 | transform-origin: 0 center; 64 | } 65 | 66 | &.@{avatar-prefix-cls}-icon { 67 | font-size: @font-size; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/Form/Props.ts: -------------------------------------------------------------------------------- 1 | import _values from 'lodash/values'; 2 | import _get from 'lodash/get'; 3 | import _find from 'lodash/find'; 4 | import { InputItemProps } from 'antd-mobile/lib/input-item' 5 | import { TextareaItemProps } from 'antd-mobile/lib/textarea-item' 6 | import { PickerPropsType } from 'antd-mobile/lib/picker/PropsType' 7 | import { DatePickerPropsType } from 'antd-mobile/lib/date-picker/PropsType' 8 | import { ImagePickerProps } from './components/CustomImagePicker'; 9 | 10 | export interface WrappedImagePickerProps extends ImagePickerProps { 11 | extra?: string; 12 | label?: string; 13 | } 14 | 15 | export type ComponentType = 16 | | "custom" 17 | | "picker" 18 | | "picture" 19 | | "date" 20 | | "time" 21 | | "datetime" 22 | | "textarea" 23 | 24 | | "string" 25 | | "number" 26 | | "password" 27 | | "text" 28 | | "bankCard" 29 | | "phone" 30 | | "digit" 31 | | "money" 32 | | "hidden" 33 | 34 | export type ComponentProps = 35 | | PickerPropsType 36 | | DatePickerPropsType 37 | | WrappedImagePickerProps 38 | | TextareaItemProps 39 | | InputItemProps 40 | 41 | export interface ItemConfig { 42 | type?: ComponentType, 43 | field: string, 44 | label?: any, 45 | componentProps?: ComponentProps, 46 | fieldProps?: { 47 | initialValue?: any, 48 | rules?: any, 49 | valuePropName?: any, 50 | normalize?: (value: any, prevValue: any, allValues: any) => any, 51 | trigger?: string, 52 | validateFirst?: boolean, 53 | validateTrigger?: string | string[], 54 | }, 55 | component?: JSX.Element, 56 | } 57 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

Ant Design Mobile Pro

2 | 3 |
4 | 5 | 像 [Ant Design Pro](https://github.com/ant-design/ant-design-pro) 一样使用 Ant Design Mobile。 6 | 7 |
8 | 9 | ![ant-deisgn-mobile-pro-puzzle-min.png](https://i.loli.net/2019/12/20/6nMpEzVkIruC15Y.png) 10 | 11 | 在线预览,请[猛戳这里](https://theprimone.github.io/ant-design-mobile-pro)。接口报错可以忽略。 12 | 13 | ## 已有特性 14 | 15 | * 代码分割 16 | * 高清方案 17 | * 结合 CustomIcon 快速使用 iconfont 18 | 19 | ## 核心组件 20 | 21 | * [AsyncRender](/src/components/AsyncRender/index.ts) - 数据异步渲染组件 22 | * [Avatar](/src/components/Avatar/index.tsx) - 从 ant-design 迁移的 [Avatar](https://ant.design/components/avatar-cn/) 组件 23 | * [CustomIcon](/src/components/CustomIcon/index.tsx) - 配置 `type` 即可使用 iconfont 中的图标 24 | * [Description](/src/components/Description/index.tsx) - 描述字段 25 | * [Ellipsis](/src/components/Ellipsis/index.tsx) - 文本自动省略号,引用自 https://v2-pro.ant.design/components/ellipsis-cn 26 | * [Form](/src/components/Form/index.tsx) - 基于 antd-mobile 的配置化实现表单功能的组件,仿 [antd-form-mate](https://github.com/theprimone/antd-form-mate) 实现 27 | * [PageWrapper](/src/components/PageWrapper/index.tsx) - 定制导航栏实现 28 | * [Paper](/src/components/Paper/index.tsx) - 纸张组件 29 | * [SimpleCard](/src/components/SimpleCard/index.tsx) - 简单卡片 30 | * [Spin](/src/components/Spin/index.tsx) - 从 ant-design 迁移的 [Spin](https://ant.design/components/spin-cn/) 组件 31 | * [StandardList](/src/components/StandardList/index.tsx) - 基于 ListView 封装的快速实现数据长列表渲染的组件 32 | * [Statistics](/src/components/Statistics/index.tsx) - 统计数字展示 33 | 34 | ## 一些问题 35 | 36 | 1. 实测发现 fastclick 的功能在 iOS 设备上会导致输入框点击多次才有响应,故只在非 iOS 设备上[添加](/src/global.ts#L15)该功能。 37 | -------------------------------------------------------------------------------- /src/models/login.ts: -------------------------------------------------------------------------------- 1 | import { routerRedux } from 'dva/router'; 2 | import { Toast } from 'antd-mobile'; 3 | import { fakeAccountLogin } from '@/services/api'; 4 | import { setAuthority } from '@/utils/authority'; 5 | export interface LoginState { 6 | status?: any; 7 | } 8 | 9 | export default { 10 | namespace: 'login', 11 | 12 | state: { 13 | status: undefined, 14 | }, 15 | 16 | effects: { 17 | *login({ payload }, { call, put }) { 18 | let response = { 19 | status: 'ok', 20 | currentAuthority: 'admin', 21 | } 22 | 23 | if (process.env.NODE_ENV !== 'production') { 24 | response = yield call(fakeAccountLogin, payload); 25 | } 26 | 27 | yield put({ 28 | type: 'changeLoginStatus', 29 | payload: response, 30 | }); 31 | setAuthority(response.currentAuthority); 32 | if (response.status === 'ok') { 33 | yield put(routerRedux.replace('/')); 34 | } else { 35 | Toast.fail('帐号或密码错误!'); 36 | } 37 | }, 38 | 39 | *logout(_, { put }) { 40 | yield put({ 41 | type: 'changeLoginStatus', 42 | payload: { 43 | status: false, 44 | currentAuthority: 'guest', 45 | }, 46 | }); 47 | // redirect 48 | if (window.location.pathname !== '/user/login') { 49 | yield put( 50 | routerRedux.replace({ 51 | pathname: '/user/login', 52 | }) 53 | ); 54 | } 55 | }, 56 | }, 57 | 58 | reducers: { 59 | changeLoginStatus(state, { payload }) { 60 | return { 61 | ...state, 62 | status: payload.status, 63 | type: payload.type, 64 | }; 65 | }, 66 | }, 67 | }; 68 | -------------------------------------------------------------------------------- /src/components/CutomAMap/PlaceSearch.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export interface PlaceSearchProps { 4 | __map__?: any; 5 | onPlaceSelect?: (poi: any) => void; 6 | style?: React.CSSProperties; 7 | } 8 | 9 | export default class PlaceSearch extends React.Component { 10 | constructor(props) { 11 | super(props); 12 | const { __map__: map } = props; 13 | if (!map) { 14 | throw new Error("PlaceSearch has to be a child of Map component"); 15 | } 16 | } 17 | 18 | componentDidMount() { 19 | const { __map__: map, onPlaceSelect } = this.props; 20 | if (!map) return; 21 | 22 | const auto = new window.AMap.Autocomplete({ 23 | input: "placeSearch" 24 | }); 25 | // const placeSearch = new window.AMap.PlaceSearch({ 26 | // map 27 | // }); // 构造地点查询类 28 | function select(e) { 29 | // placeSearch.setCity(e.poi.adcode); 30 | // placeSearch.search(e.poi.name); // 关键字查询查询 31 | map.setCenter( 32 | new window.AMap.LngLat(e.poi.location.lng, e.poi.location.lat) 33 | ); 34 | if (onPlaceSelect) { 35 | onPlaceSelect(e.poi); 36 | } 37 | } 38 | window.AMap.event.addListener(auto, "select", select); // 注册监听,当选中某条记录时会触发 39 | } 40 | 41 | render() { 42 | const { style: customStyle } = this.props; 43 | const style = { 44 | position: "absolute", 45 | top: ".2rem", 46 | left: ".2rem", 47 | background: "#fff", 48 | width: '4.2rem', 49 | height: '.48rem', 50 | fontSize: '.32rem', 51 | ...customStyle 52 | }; 53 | 54 | return; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/pages/document.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 9 | 15 | Ant Design Mobile Pro 16 | 17 | 18 | 19 | 20 | 21 |
22 | 43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /src/pages/Welcome/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | 3 | @user-info-padding: 8px; 4 | @user-info-left: 148px; 5 | @user-info-right: @user-info-padding + 24px; 6 | 7 | @monitor-height: 280px; 8 | @monitor-over-height: 100px; 9 | 10 | @infoStyle: { 11 | position: absolute; 12 | left: @user-info-left; 13 | font-size: 32px; 14 | color: white; 15 | }; 16 | 17 | @buttonStyle: { 18 | position: absolute; 19 | right: @user-info-right; 20 | color: white; 21 | border: 1px solid white; 22 | height: 44px; 23 | line-height: 44px; 24 | background: unset; 25 | border-radius: 22px; 26 | width: 240px; 27 | font-size: 32px; 28 | }; 29 | 30 | .hero { 31 | width: 100%; 32 | background: white; 33 | } 34 | 35 | .header { 36 | position: relative; 37 | width: 100%; 38 | height: 400px; 39 | background: @brand-primary; 40 | 41 | .userInfo { 42 | position: relative; 43 | top: calc(50% - 88px); 44 | width: 100%; 45 | height: 120px; 46 | padding: 0 @user-info-padding 0 @user-info-padding * 4; 47 | 48 | .username { 49 | @infoStyle(); 50 | bottom: 84px; 51 | } 52 | 53 | .lastLogin { 54 | @infoStyle(); 55 | bottom: 26px; 56 | } 57 | 58 | .updatePassword { 59 | @buttonStyle(); 60 | bottom: 84px; 61 | } 62 | 63 | .exit { 64 | @buttonStyle(); 65 | bottom: 26px; 66 | } 67 | } 68 | } 69 | 70 | .monitor { 71 | position: absolute; 72 | top: calc(400px - @monitor-over-height); 73 | width: 100%; 74 | height: @monitor-height; 75 | padding: 0 32px; 76 | } 77 | 78 | .menu { 79 | padding-top: @monitor-height - @monitor-over-height; 80 | 81 | :global { 82 | .am-grid-text { 83 | font-size: 32px !important; 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/components/Description/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import { pxToRem } from '@/utils/customUtils' 4 | import styles from './index.less'; 5 | 6 | export type DescriptionLayout = 'horizontal' | 'vertical'; 7 | 8 | export interface DescriptionProps extends React.Attributes { 9 | label: string; 10 | children: React.ReactNode; 11 | gutter?: number; 12 | layout?: DescriptionLayout; 13 | style?: React.CSSProperties; 14 | labelStyle?: React.CSSProperties; 15 | textStyle?: React.CSSProperties; 16 | labelClassName?: string; 17 | textClassName?: string; 18 | className?: string; 19 | } 20 | 21 | export default (props: DescriptionProps) => { 22 | const { 23 | label, 24 | children, 25 | gutter = 32, 26 | layout = 'horizontal', 27 | style, 28 | labelStyle, 29 | textStyle, 30 | className, 31 | labelClassName, 32 | textClassName, 33 | ...rest 34 | } = props; 35 | 36 | const isVertical = layout === 'vertical'; 37 | 38 | const containerCls = classNames(styles.description, className); 39 | const lableCls = classNames(classNames(styles.label, isVertical && styles.labelVertical, labelClassName)); 40 | const textCls = classNames(styles.text, isVertical && styles.textVertical, textClassName); 41 | 42 | return ( 43 |

52 | 56 | {label} 57 | 58 | 62 | {children || '-'} 63 | 64 |

65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /src/components/Paper/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './index.less'; 4 | 5 | const colors: any = [ 6 | 'pink', 7 | 'red', 8 | 'yellow', 9 | 'orange', 10 | 'cyan', 11 | 'green', 12 | 'blue', 13 | 'purple', 14 | 'geekblue', 15 | 'magenta', 16 | 'volcano', 17 | 'gold', 18 | 'lime', 19 | ]; 20 | 21 | export interface PaperProps { 22 | full?: boolean; 23 | icon?: React.ReactNode; 24 | color?: 'pink' | 'red' | 'yellow' | 'orange' | 'cyan' | 'green' | 'blue' | 'purple' | 'geekblue' | 'magenta' | 'volcano' | 'gold' | 'lime' | string; 25 | children?: any; 26 | /** paper wrapper className */ 27 | clearPadding?: boolean; 28 | onClick?: () => void; 29 | } 30 | 31 | export default function Paper(props: PaperProps) { 32 | const { children, full, icon, clearPadding, color, ...rest } = props; 33 | 34 | const setRenderIcon = () => icon &&
{icon}
; 35 | 36 | const setStyle = () => { 37 | let style: React.CSSProperties = {}; 38 | if (full) { 39 | style = { ...style, borderRadius: 0, margin: 0 }; 40 | } 41 | if (setRenderIcon()) { 42 | style = { 43 | ...style, 44 | marginTop: '.3rem', 45 | paddingTop: '1rem', 46 | }; 47 | } 48 | if (clearPadding) { 49 | style = { 50 | ...style, 51 | paddingLeft: 0, 52 | paddingRight: 0, 53 | } 54 | } 55 | if (!colors.includes(color)) { 56 | style = { 57 | ...style, 58 | background: color, 59 | } 60 | } 61 | return style; 62 | } 63 | 64 | return ( 65 |
70 | {setRenderIcon()} 71 | {children} 72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /src/models/user.ts: -------------------------------------------------------------------------------- 1 | import { Effect } from 'dva'; 2 | import { Reducer } from 'redux'; 3 | 4 | import { queryCurrent, query as queryUsers } from '@/services/user'; 5 | 6 | export interface CurrentUser { 7 | avatar?: string; 8 | name?: string; 9 | title?: string; 10 | group?: string; 11 | signature?: string; 12 | tags?: { 13 | key: string; 14 | label: string; 15 | }[]; 16 | unreadCount?: number; 17 | } 18 | 19 | export interface UserModelState { 20 | currentUser?: CurrentUser; 21 | } 22 | 23 | export interface UserModelType { 24 | namespace: 'user'; 25 | state: UserModelState; 26 | effects: { 27 | fetch: Effect; 28 | fetchCurrent: Effect; 29 | }; 30 | reducers: { 31 | saveCurrentUser: Reducer; 32 | changeNotifyCount: Reducer; 33 | }; 34 | } 35 | 36 | const UserModel: UserModelType = { 37 | namespace: 'user', 38 | 39 | state: { 40 | currentUser: {}, 41 | }, 42 | 43 | effects: { 44 | *fetch(_, { call, put }) { 45 | const response = yield call(queryUsers); 46 | yield put({ 47 | type: 'save', 48 | payload: response, 49 | }); 50 | }, 51 | *fetchCurrent(_, { call, put }) { 52 | const response = yield call(queryCurrent); 53 | yield put({ 54 | type: 'saveCurrentUser', 55 | payload: response, 56 | }); 57 | }, 58 | }, 59 | 60 | reducers: { 61 | saveCurrentUser(state, action) { 62 | return { 63 | ...state, 64 | currentUser: action.payload || {}, 65 | }; 66 | }, 67 | changeNotifyCount( 68 | state = { 69 | currentUser: {}, 70 | }, 71 | action, 72 | ) { 73 | return { 74 | ...state, 75 | currentUser: { 76 | ...state.currentUser, 77 | notifyCount: action.payload.totalCount, 78 | unreadCount: action.payload.unreadCount, 79 | }, 80 | }; 81 | }, 82 | }, 83 | }; 84 | 85 | export default UserModel; 86 | -------------------------------------------------------------------------------- /src/utils/customUtils.ts: -------------------------------------------------------------------------------- 1 | export const defaultDesignWidth = 750; 2 | export const defaultBaseFontSize = 75; 3 | 4 | export function addScriptToHead(url: string, onload?: () => void) { 5 | const head = document.getElementsByTagName('head')[0]; 6 | const script = document.createElement('script'); 7 | script.src = url; 8 | if (onload) { script.onload = onload } 9 | head.appendChild(script); 10 | } 11 | 12 | export function addScriptsToHead(urls: string[]) { 13 | urls.map(item => { addScriptToHead(item); }); 14 | } 15 | 16 | // reference: https://juejin.im/post/5c0dd7ac6fb9a049c43d7edc 17 | export function setRem(designWidth?: number, baseFontSize?: number) { 18 | const _designWidth = designWidth || defaultDesignWidth; 19 | const _baseFontSize = baseFontSize || defaultBaseFontSize; 20 | // const ua = navigator.userAgent; 21 | // const matches = ua.match(/Android[\S\s]+AppleWebkit\/(\d{3})/i); 22 | // const isIos = navigator.appVersion.match(/(iphone|ipad|ipod)/gi); 23 | // let dpr = window.devicePixelRatio || 1; 24 | // if (!isIos && !(matches && +matches[1] > 534)) { 25 | // // 如果非iOS, 非Android4.3以上, dpr设为1; 26 | // dpr = 1; 27 | // } 28 | // const scale = 1 / dpr; 29 | // let metaEl = document.querySelector('meta[name="viewport"]'); 30 | // if (!metaEl) { 31 | // metaEl = document.createElement('meta'); 32 | // metaEl.setAttribute('name', 'viewport'); 33 | // window.document.head.appendChild(metaEl); 34 | // } 35 | // metaEl.setAttribute('content', 'width=device-width,user-scalable=no,initial-scale=' + scale + ',maximum-scale=' + scale + ',minimum-scale=' + scale); 36 | document.documentElement.style.fontSize = document.documentElement.clientWidth / (_designWidth / _baseFontSize) + 'px'; 37 | }; 38 | 39 | export function setRemIfResize(designWidth?: number, baseFontSize?: number) { 40 | const resizeEvt = "orientationchange" in window ? "orientationchange" : "resize"; 41 | if (!document.addEventListener) { 42 | return 43 | } 44 | const event = () => setRem(designWidth, baseFontSize); 45 | window.addEventListener(resizeEvt, event, false); 46 | document.addEventListener("DOMContentLoaded", event, false); 47 | } 48 | 49 | export function pxToRem(px: number) { 50 | return `${px / defaultBaseFontSize}rem`; 51 | } 52 | -------------------------------------------------------------------------------- /src/components/PageWrapper/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { history } from 'umi'; 3 | import classNames from 'classnames'; 4 | import { NavBar, Icon, Drawer } from 'antd-mobile'; 5 | import { DrawerProps } from 'antd-mobile/lib/drawer/PropsType'; 6 | import { NavBarProps } from 'antd-mobile/lib/nav-bar/PropsType'; 7 | import styles from './index.less'; 8 | 9 | export interface PageWrapperProps extends NavBarProps { 10 | title?: any; 11 | backable?: boolean; 12 | backPath?: string; 13 | fixed?: boolean; 14 | setSidebar?: (setOpen: (open: boolean) => void) => DrawerProps['sidebar']; 15 | } 16 | 17 | export default function PageWrapper(props: PageWrapperProps) { 18 | const { 19 | title, 20 | backable, 21 | backPath, 22 | fixed = true, 23 | className, 24 | leftContent, 25 | setSidebar, 26 | children, 27 | ...rest 28 | } = props; 29 | const [open, setOpen] = useState(false); 30 | const useDrawer = leftContent && setSidebar; 31 | 32 | let backableConfig: any = {}; 33 | let drawerConfig: any = {}; 34 | if (leftContent && setSidebar) { 35 | drawerConfig = { 36 | leftContent, 37 | onLeftClick: () => { 38 | setOpen(!open); 39 | }, 40 | }; 41 | } else if (backable) { 42 | backableConfig = { 43 | icon: , 44 | onLeftClick: () => (backPath ? history.push(backPath) : history.goBack()), 45 | }; 46 | } 47 | 48 | const renderChildren = () => { 49 | if (useDrawer && setSidebar) { 50 | return ( 51 | { 57 | setOpen(!open); 58 | }} 59 | > 60 | {children} 61 | 62 | ); 63 | } 64 | return fixed ?
{children}
: children; 65 | }; 66 | 67 | return ( 68 |
69 | 76 | {title} 77 | 78 | {renderChildren()} 79 |
80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ant-design-mobile-pro", 3 | "version": "1.0.0", 4 | "private": true, 5 | "homepage": "https://theprimone.top/ant-design-mobile-pro", 6 | "description": "Use Ant Design Mobile like And Design Pro", 7 | "scripts": { 8 | "start": "umi dev", 9 | "build": "umi build", 10 | "test": "umi test", 11 | "lint:es": "eslint --ext .js src mock tests", 12 | "lint:ts": "tslint \"src/**/*.ts\" \"src/**/*.tsx\"", 13 | "precommit": "lint-staged", 14 | "predeploy": "npm run build", 15 | "deploy": "gh-pages -d dist" 16 | }, 17 | "dependencies": { 18 | "antd-mobile": "^2.3.1", 19 | "classnames": "^2.2.6", 20 | "dva": "^2.6.0-beta.6", 21 | "lodash": "^4.17.15", 22 | "moment": "^2.24.0", 23 | "nzh": "^1.0.4", 24 | "qs": "^6.7.0", 25 | "rc-form": "^2.4.8", 26 | "react": "^16.8.6", 27 | "react-amap": "^1.2.8", 28 | "react-amap-plugin-custom-geolocation": "^1.0.1", 29 | "react-dom": "^16.8.6", 30 | "react-transition-group": "^4.2.2", 31 | "umi-request": "^1.2.2" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "^23.3.12", 35 | "@types/qs": "^6.5.3", 36 | "@types/react": "^16.7.18", 37 | "@types/react-dom": "^16.0.11", 38 | "@types/react-test-renderer": "^16.0.3", 39 | "@umijs/preset-react": "^1.4.11", 40 | "babel-eslint": "^9.0.0", 41 | "babel-plugin-import": "^1.12.0", 42 | "eslint": "^5.4.0", 43 | "eslint-config-umi": "^1.4.0", 44 | "eslint-plugin-flowtype": "^2.50.0", 45 | "eslint-plugin-import": "^2.14.0", 46 | "eslint-plugin-jsx-a11y": "^5.1.1", 47 | "eslint-plugin-react": "^7.11.1", 48 | "gh-pages": "^2.1.1", 49 | "husky": "^0.14.3", 50 | "lint-staged": "^7.2.2", 51 | "postcss-px-to-viewport": "^1.1.1", 52 | "react-test-renderer": "^16.7.0", 53 | "roadhog-api-doc": "^1.1.2", 54 | "tslint": "^5.12.0", 55 | "tslint-eslint-rules": "^5.4.0", 56 | "tslint-react": "^3.6.0", 57 | "umi": "^3.1.0", 58 | "umi-plugin-gh-pages": "^0.2.0", 59 | "umi-types": "^0.3.0" 60 | }, 61 | "husky": { 62 | "hooks": { 63 | "pre-commit": "npm run lint-staged" 64 | } 65 | }, 66 | "lint-staged": { 67 | "*.{ts,tsx}": [ 68 | "tslint --fix", 69 | "git add" 70 | ], 71 | "*.{js,jsx}": [ 72 | "eslint --fix", 73 | "git add" 74 | ] 75 | }, 76 | "engines": { 77 | "node": ">=8.0.0" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/components/Form/components/CustomImagePicker/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ImagePicker } from 'antd-mobile'; 3 | import { ImagePickerPropTypes } from 'antd-mobile/lib/image-picker'; 4 | import Image from '@/components/Image'; 5 | import PopupModal from '@/components/custom-antd-mobile/PopupModal'; 6 | 7 | export interface ImagePickerFile { 8 | file: File; 9 | orientation: 1; 10 | url: string; 11 | } 12 | 13 | export interface ImagePickerProps extends Omit { 14 | disabled?: boolean; 15 | filesCountLimit?: number; 16 | onChange?: (files: ImagePickerFile[]) => void; 17 | } 18 | interface ImagePickerState { 19 | visible: boolean; 20 | previewUrl: string; 21 | } 22 | export default class extends React.Component { 23 | 24 | state = { 25 | visible: false, 26 | previewUrl: '', 27 | } 28 | 29 | /** 30 | * remove with index 31 | */ 32 | onChange = (files: ImagePickerFile[], type: 'add' | 'remove', index: number) => { 33 | const { onChange = () => { }, filesCountLimit } = this.props; 34 | console.log(files, type, index); 35 | onChange(filesCountLimit ? files.slice(0, filesCountLimit) : files); 36 | } 37 | 38 | render() { 39 | const { onChange, filesCountLimit = 1, disabled, ...rest } = this.props; 40 | const { visible, previewUrl } = this.state; 41 | return ( 42 | <> 43 | { 48 | if (index !== undefined && files) { 49 | // console.log(index, files); 50 | this.setState({ 51 | visible: true, 52 | previewUrl: (files[index] as any).url, 53 | }); 54 | } 55 | }} 56 | {...rest} 57 | /> 58 | { this.setState({ visible: false }) }} 61 | footer={[ 62 | { 63 | text: '关闭', 64 | onPress: () => { 65 | this.setState({ visible: false }); 66 | } 67 | } 68 | ]} 69 | > 70 | 71 | 72 | 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /config/config.ts: -------------------------------------------------------------------------------- 1 | import pageRoutes from './router.config'; 2 | import webpackPlugin from './plugin.config'; 3 | import pxToViewPort from 'postcss-px-to-viewport'; 4 | const defaultSettings = require('../src/defaultSettings'); 5 | 6 | const { base, publicPath } = defaultSettings; 7 | 8 | export default { 9 | // code split need base url 10 | base, 11 | publicPath, 12 | define: { 13 | APP_TYPE: process.env.APP_TYPE || '', 14 | }, 15 | 16 | antd: false, 17 | dva: { 18 | immer: true, 19 | hmr: false, 20 | }, 21 | dynamicImport: { 22 | loading: '@/components/PageLoading/index', 23 | }, 24 | title: 'ant-design-mobile-pro', 25 | locale: { 26 | default: 'zh-CN', 27 | }, 28 | 29 | // exportStatic: {}, 30 | // 路由配置 31 | routes: pageRoutes, 32 | // Theme for antd-mobile 33 | // https://mobile.ant.design/docs/react/customize-theme-cn 34 | // theme: { 35 | // 'brand-primary': theme.primaryColor, 36 | // 'brand-primary-tap': theme.brandPrimaryTap, 37 | // }, 38 | externals: {}, 39 | lessLoader: { 40 | javascriptEnabled: true, 41 | }, 42 | cssnano: { 43 | mergeRules: false, 44 | }, 45 | targets: { 46 | android: 5, 47 | chrome: 58, 48 | edge: 13, 49 | firefox: 45, 50 | ie: 9, 51 | ios: 7, 52 | safari: 10, 53 | }, 54 | outputPath: './dist', 55 | alias: {}, 56 | proxy: { 57 | '/server/api/': { 58 | changeOrigin: true, 59 | pathRewrite: { '^/server': '' }, 60 | target: 'https://preview.pro.ant.design/', 61 | }, 62 | '/wx/api/': { 63 | changeOrigin: true, 64 | pathRewrite: { '^/wx/api': '' }, 65 | target: 'https://games.parsec.com.cn/', 66 | }, 67 | }, 68 | ignoreMomentLocale: true, 69 | manifest: { 70 | basePath: '/', 71 | }, 72 | hash: true, 73 | chainWebpack: webpackPlugin, 74 | extraBabelPlugins: [ 75 | [ 76 | 'import', 77 | { 78 | libraryName: 'antd-mobile', 79 | //style: 'css', 80 | style: true, // use less for customized theme 81 | }, 82 | ], 83 | ], 84 | // reference: https://umijs.org/zh/config/#extrapostcssplugins 85 | extraPostCSSPlugins: [ 86 | // reference: https://github.com/evrone/postcss-px-to-viewport/blob/master/README_CN.md 87 | pxToViewPort({ 88 | viewportWidth: 750, 89 | mediaQuery: false, 90 | }), 91 | ], 92 | theme: { 93 | hd: '2px', 94 | // 'brand-primary': '', 95 | // 'brand-primary-tap': '', 96 | }, 97 | }; 98 | -------------------------------------------------------------------------------- /src/components/StandardList/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState, useRef } from 'react'; 2 | import { WingBlank, ListView, ActivityIndicator } from 'antd-mobile'; 3 | import { ListViewProps } from 'antd-mobile/lib/list-view'; 4 | import classNames from 'classnames'; 5 | import styles from './index.less'; 6 | 7 | export interface StandardListData { 8 | list: T[]; 9 | pagination: { 10 | current: number; 11 | last: number; 12 | }, 13 | } 14 | 15 | export interface StandardListProps extends Omit { 16 | getData: (current: number) => StandardListData | Promise>; 17 | onUnmount?: () => void; 18 | style?: React.CSSProperties; 19 | className?: string; 20 | } 21 | 22 | export default function StandardList(props: StandardListProps) { 23 | const { getData, onUnmount, style, className, ...rest } = props; 24 | const [loading, setLoading] = useState(true); 25 | const [pageData, setPageData] = useState>({ list: [], pagination: { current: 1, last: 10 } }); 26 | const [list, setList] = useState(new ListView.DataSource({ 27 | rowHasChanged: (row1, row2) => row1 !== row2, 28 | })); 29 | 30 | async function fetchData(current: number = 1) { 31 | setLoading(true); 32 | const data = await getData(current); 33 | const mergeData = { 34 | list: [...pageData.list, ...data.list], 35 | pagination: data.pagination, 36 | }; 37 | setPageData(mergeData); 38 | setList(list.cloneWithRows(mergeData.list)); 39 | setLoading(false); 40 | } 41 | 42 | useEffect(() => { 43 | fetchData(); 44 | 45 | return () => { 46 | if (onUnmount) { onUnmount(); } 47 | }; 48 | }, []); 49 | 50 | const listViewEl = useRef(null); 51 | 52 | const separator = (sectionID, rowID) => ( 53 |
60 | ); 61 | 62 | const onEndReached = () => { 63 | const { pagination: { current, last } } = pageData!; 64 | if (current >= last) { return; } 65 | fetchData(current + 1); 66 | } 67 | 68 | return ( 69 |
70 | 71 | {separator(0, 0)} 72 | ( 76 |
77 | {loading ? '加载中...' : '已加载全部数据'} 78 |
79 | )} 80 | pageSize={2} 81 | renderSeparator={separator} 82 | useBodyScroll 83 | scrollRenderAheadDistance={500} 84 | onEndReached={onEndReached} 85 | onEndReachedThreshold={10} 86 | {...rest} 87 | /> 88 |
89 | 90 |
91 | ) 92 | } 93 | -------------------------------------------------------------------------------- /src/components/Form/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import classNames from 'classnames'; 3 | import { List, Button, WhiteSpace } from 'antd-mobile'; 4 | import _values from 'lodash/values'; 5 | import _get from 'lodash/get'; 6 | import _find from 'lodash/find'; 7 | import _debounce from 'lodash/debounce'; 8 | import { ButtonProps } from 'antd-mobile/lib/button' 9 | import FormContext, { FormProvider } from './FormContext'; 10 | import createFormItems from './createFormItems'; 11 | import { ItemConfig } from './Props'; 12 | import styles from './index.less'; 13 | 14 | export interface Props { 15 | header?: any; 16 | items?: ItemConfig[]; 17 | onSubmit?: (fieldsValue: any) => void; 18 | buttonText?: string | null; 19 | buttonProps?: ButtonProps; 20 | style?: React.CSSProperties; 21 | className?: string; 22 | /** use footer errors hint, or inject errors hint, default footer errors hint */ 23 | errorsFooter?: boolean; 24 | /** whether hide required mark before label, default false */ 25 | hideRequiredMark?: boolean; 26 | } 27 | 28 | function FormList(props: Props) { 29 | const { 30 | header, 31 | items = [], 32 | errorsFooter = true, 33 | onSubmit = () => { }, 34 | style, 35 | buttonText = '确定', 36 | buttonProps, 37 | className, 38 | hideRequiredMark = false, 39 | } = props; 40 | const form = useContext(FormContext); 41 | const { getFieldsError } = form; 42 | 43 | const handleClick = _debounce(() => { 44 | if (form) { 45 | form.validateFields((err?: { [k: string]: { errors: any[] } }, values?: any) => { 46 | if (process.env.NODE_ENV !== 'production') { 47 | console.log('form err:', err); 48 | console.log('form values', values); 49 | } 50 | if (err) { return; } 51 | onSubmit(values); 52 | }); 53 | } 54 | }, 400); 55 | 56 | const renderButton = () => { 57 | return ( 58 | 65 | ) 66 | } 67 | 68 | //由于可以组装不同的表单配置,所以需要按需从各自的表单配置中取出各自的错误信息 69 | const errors = _values(getFieldsError(items.map(item => item.field))).filter(item => item); 70 | const setListProps = () => { 71 | const result: any = {}; 72 | if (header) { result.renderHeader = () => header; } 73 | if (errorsFooter) { 74 | result.renderFooter = () => {errors.join(',')}; 75 | } 76 | return result; 77 | } 78 | 79 | return ( 80 |
81 | {!!items.length && ( 82 | 83 | {createFormItems(form, !errorsFooter, !hideRequiredMark)(items)} 84 | 85 | )} 86 | 87 | {buttonText && ( 88 | <> 89 | 90 | 91 | {renderButton()} 92 | 93 | 94 | )} 95 |
96 | ) 97 | } 98 | 99 | export interface FormProps extends Props { 100 | form: any; 101 | } 102 | 103 | export default ({ form, ...rest }: FormProps) => ( 104 | 105 | 106 | 107 | ); 108 | -------------------------------------------------------------------------------- /src/pages/User/Login.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Toast, Button } from 'antd-mobile'; 3 | import { connect } from 'dva'; 4 | import { createForm } from 'rc-form'; 5 | import Paper from '@/components/Paper'; 6 | import Form from '@/components/Form'; 7 | import { ItemConfig } from '@/components/Form/Props'; 8 | import CustomIcon from '@/components/CustomIcon'; 9 | import { Dispatch, ConnectState } from '@/models/connect'; 10 | import styles from './Login.less'; 11 | const defaultSettings = require('@/defaultSettings'); 12 | 13 | const { publicPath } = defaultSettings; 14 | 15 | const logo = `${publicPath}logo.svg`; 16 | const iconStyle = { fontSize: '.48rem', color: '#776e6e' }; 17 | 18 | const setItems: (form: any, config: any) => ItemConfig[] = (form, config) => { 19 | const { handleKeyPress } = config; 20 | return [ 21 | { 22 | field: 'username', 23 | label: , 24 | fieldProps: { 25 | rules: [ 26 | { 27 | required: true, message: '请输入姓名', 28 | } 29 | ], 30 | }, 31 | componentProps: { 32 | placeholder: "请输入帐号:admin", 33 | autoComplete: 'off', 34 | labelNumber: 1, 35 | }, 36 | }, 37 | { 38 | field: 'password', 39 | label: , 40 | fieldProps: { 41 | rules: [ 42 | { 43 | required: true, message: '请输入密码', 44 | } 45 | ], 46 | }, 47 | componentProps: { 48 | type: "password", 49 | placeholder: "请输入密码:password", 50 | autoComplete: 'off', 51 | labelNumber: 1, 52 | onKeyPress: handleKeyPress 53 | }, 54 | }, 55 | ]; 56 | } 57 | 58 | export interface LoginProps { 59 | form: any; 60 | dispatch: Dispatch; 61 | login: any; 62 | loading: boolean; 63 | } 64 | 65 | class Login extends React.PureComponent { 66 | handleSubmit = () => { 67 | const { form, dispatch } = this.props; 68 | form.validateFields((err, values) => { 69 | console.log(err, values); 70 | if (err) { 71 | return; 72 | }; 73 | dispatch({ 74 | type: 'login/login', 75 | payload: { 76 | ...values, 77 | }, 78 | }); 79 | }) 80 | } 81 | 82 | handleKeyPress = (event) => { 83 | if (event.key === 'Enter') { 84 | this.handleSubmit(); 85 | } 86 | } 87 | 88 | render() { 89 | const icon = ( 90 |
91 | 95 |
96 | ); 97 | const { form, loading } = this.props; 98 | 99 | return ( 100 |
101 | 102 |
110 | 118 | 119 |
120 | ); 121 | } 122 | } 123 | 124 | export default connect( 125 | ({ login, loading }: ConnectState) => ({ 126 | login, 127 | loading: loading.effects['login/login'], 128 | }) 129 | )(createForm()(Login)); 130 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Group 28 Copy 5 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 42 | 43 | -------------------------------------------------------------------------------- /src/components/Form/createFormItems/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List, InputItem, Picker, TextareaItem, Toast, DatePicker } from 'antd-mobile'; 3 | import _values from 'lodash/values'; 4 | import _get from 'lodash/get'; 5 | import _find from 'lodash/find'; 6 | import { injectProps } from '@/utils/reactUtils'; 7 | import CustomImagePicker from '../components/CustomImagePicker'; 8 | import { 9 | WrappedImagePickerProps, 10 | ComponentType, 11 | ItemConfig, 12 | } from '../Props'; 13 | import styles from './index.less'; 14 | 15 | const WrappedImagePicker = React.forwardRef((props: WrappedImagePickerProps, ref) => { 16 | const { extra, label, ...rest } = props; 17 | return ( 18 | 19 |

{label}

20 | 24 |

{extra}

25 |
26 | ) 27 | }); 28 | 29 | const setValuePropName = (type: ComponentType) => { 30 | switch (type) { 31 | case 'picture': 32 | return 'files'; 33 | default: 34 | return 'value'; 35 | } 36 | }; 37 | 38 | const renderInputComponent = (form: any) => ( 39 | type: ComponentType, 40 | label: any, 41 | field: string, 42 | componentProps: any, 43 | component?: JSX.Element, 44 | ) => { 45 | const { setFieldsValue } = form; 46 | const { disabled } = componentProps || {}; 47 | 48 | const dismissProps = { 49 | dismissText: '重置', 50 | onDismiss: () => { setFieldsValue({ [field]: undefined }) }, 51 | }; 52 | 53 | switch (type) { 54 | case 'custom': 55 | return component; 56 | case 'picker': 57 | return ( 58 | 63 | {label} 64 | 65 | ); 66 | case 'picture': 67 | return ; 68 | case 'textarea': 69 | return ; 70 | case 'date': 71 | case 'time': 72 | case 'datetime': 73 | return ( 74 | 79 | {label} 80 | 81 | ); 82 | case 'string': 83 | case 'number': 84 | case 'password': 85 | case 'text': 86 | case 'bankCard': 87 | case 'phone': 88 | case 'digit': 89 | case 'money': 90 | default: 91 | return {label}; 92 | } 93 | } 94 | 95 | export function withRequiredMark(WrappedComponent: JSX.Element) { 96 | return ( 97 |
98 | {WrappedComponent} 99 |
100 | ) 101 | } 102 | 103 | export const createFormItems = (form: any, injectError: boolean, requiredMark: boolean) => (items: ItemConfig[]) => { 104 | const { getFieldError, getFieldDecorator } = form; 105 | return items.map(item => { 106 | const { type = 'string', label, field, componentProps, component, fieldProps = {} } = item; 107 | 108 | if (type === 'hidden') { 109 | getFieldDecorator(field, { initialValue: fieldProps.initialValue }); 110 | return null; 111 | } 112 | 113 | const setErrorProps = () => { 114 | const error = getFieldError(field); 115 | return injectError && error ? { error, onErrorClick() { Toast.info(error[0]); } } : {}; 116 | } 117 | const setInjectProps = () => ({ 118 | ...setErrorProps(), 119 | key: field, 120 | }); 121 | 122 | const inputComponent = renderInputComponent(form)(type, label, field, componentProps, component); 123 | const setRenderItem = () => { 124 | if (inputComponent) { 125 | const injectPropsComponent = injectProps(setInjectProps())(inputComponent); 126 | return getFieldDecorator(field, { 127 | valuePropName: setValuePropName(type), 128 | ...fieldProps, 129 | })(injectPropsComponent); 130 | } 131 | return null; 132 | } 133 | 134 | const isRequiredField = !!_find(_get(fieldProps, 'rules', []), { required: true }); 135 | const renderItem = setRenderItem(); 136 | const hasRequiredMark = requiredMark && isRequiredField && renderItem; 137 | return hasRequiredMark ? withRequiredMark(renderItem) : renderItem; 138 | }).filter(item => item) as JSX.Element[]; 139 | } 140 | 141 | export default createFormItems; 142 | -------------------------------------------------------------------------------- /src/pages/Demo/Form/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { List, Switch, WingBlank, WhiteSpace } from 'antd-mobile'; 3 | import { createForm } from 'rc-form'; 4 | import Paper from '@/components/Paper'; 5 | import Description from '@/components/Description'; 6 | import PageWrapper from '@/components/PageWrapper'; 7 | import { AsyncRenderWithFetch } from '@/components/AsyncRender'; 8 | import Form from '@/components/Form'; 9 | import { ItemConfig } from '@/components/Form/Props'; 10 | import { getMockData } from '../mock'; 11 | 12 | const gender = [ 13 | { 14 | label: '男', 15 | value: 'm', 16 | }, 17 | { 18 | label: '女', 19 | value: 'w', 20 | }, 21 | ] 22 | 23 | const setBasicItems: (form: any) => ItemConfig[] = (form) => { 24 | const { getFieldProps } = form; 25 | return [ 26 | { 27 | type: 'string', 28 | label: '姓名', 29 | field: 'name', 30 | fieldProps: { 31 | rules: [ 32 | { 33 | required: true, message: '请输入姓名', 34 | } 35 | ], 36 | }, 37 | }, 38 | { 39 | type: 'password', 40 | label: '密码', 41 | field: 'password', 42 | fieldProps: { 43 | rules: [ 44 | { 45 | required: true, message: '请输入密码', 46 | } 47 | ], 48 | }, 49 | }, 50 | { 51 | type: 'date', 52 | label: '出生日期', 53 | field: 'date', 54 | fieldProps: { 55 | rules: [ 56 | { 57 | required: true, message: '请选择出生日期', 58 | } 59 | ], 60 | }, 61 | }, 62 | { 63 | type: 'picker', 64 | label: '性别', 65 | field: 'gender', 66 | fieldProps: { 67 | rules: [ 68 | { 69 | required: true, message: '请选择性别', 70 | } 71 | ], 72 | }, 73 | componentProps: { 74 | data: gender, 75 | }, 76 | }, 77 | { 78 | type: 'picture', 79 | label: '照片', 80 | field: 'picture', 81 | fieldProps: { 82 | rules: [ 83 | { 84 | required: true, message: '请上传照片', 85 | } 86 | ], 87 | }, 88 | componentProps: { 89 | filesCountLimit: 5, 90 | extra: '最多5张' 91 | }, 92 | }, 93 | { 94 | type: 'custom', 95 | field: 'confirm', 96 | component: ( 97 | } 99 | > 100 | 确认信息 101 | 102 | ), 103 | }, 104 | ]; 105 | } 106 | 107 | const setAdvancedItems: (form: any) => ItemConfig[] = (form) => { 108 | return [ 109 | { 110 | type: 'textarea', 111 | field: 'profile', 112 | fieldProps: { 113 | initialValue: '各位评委老师好,我是...', 114 | rules: [ 115 | { 116 | required: true, message: '请填写个人简介', 117 | } 118 | ], 119 | }, 120 | componentProps: { 121 | rows: 5, 122 | count: 100, 123 | }, 124 | }, 125 | ]; 126 | } 127 | 128 | class FormDemo extends React.PureComponent { 129 | render() { 130 | const { form } = this.props; 131 | return ( 132 | 133 | 134 | 135 | 136 | (await getMockData(1, { wait: 2400 })).list}> 137 | {(data) => { 138 | return data.slice(0, 3).map(item => { 139 | return {item.desc} 140 | }) 141 | }} 142 | 143 | {/* A free, open source, cross-platform, graphical web browser developed by the Mozilla Corporation and hundreds of volunteers. 144 | A free, open source, cross-platform, graphical web browser developed by the Mozilla Corporation and hundreds of volunteers. 145 | A free, open source, cross-platform, graphical web browser developed by the Mozilla Corporation and hundreds of volunteers. */} 146 | 147 | 148 | 154 | console.log(values)} 159 | /> 160 | 161 | ); 162 | } 163 | } 164 | 165 | export default createForm()(FormDemo); 166 | -------------------------------------------------------------------------------- /src/pages/Welcome/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import { connect } from 'dva'; 3 | import { history } from 'umi'; 4 | import { Grid, Button, Flex } from 'antd-mobile'; 5 | import Avatar from '@/components/Avatar'; 6 | import Paper from '@/components/Paper'; 7 | import Statistics from '@/components/Statistics'; 8 | import CustomIcon from '@/components/CustomIcon'; 9 | import { Dispatch } from '@/models/connect'; 10 | import styles from './index.less'; 11 | 12 | const MenuIcon = props => { 13 | const { type, color } = props; 14 | return ; 15 | }; 16 | 17 | const statistics = [ 18 | { 19 | key: 'all', 20 | title: ( 21 | 22 | 23 |
24 |

总数量

25 |
26 | ), 27 | value: 45, 28 | align: 'center', 29 | }, 30 | { 31 | key: 'lack', 32 | title: ( 33 | 34 | 35 |
36 |

----

37 |
38 | ), 39 | value: 12, 40 | align: 'center', 41 | }, 42 | { 43 | key: 'online', 44 | title: ( 45 | 46 | 47 |
48 |

----

49 |
50 | ), 51 | value: 2, 52 | align: 'center', 53 | }, 54 | { 55 | key: 'offline', 56 | title: ( 57 | 58 | 59 |
60 |

----

61 |
62 | ), 63 | value: 18, 64 | align: 'center', 65 | }, 66 | ]; 67 | 68 | const menuItems = [ 69 | { 70 | icon: , 71 | text: '长列表', 72 | link: '/demo/standard-list', 73 | }, 74 | { 75 | icon: , 76 | text: '表单', 77 | link: '/demo/form', 78 | }, 79 | { 80 | icon: , 81 | text: '高德地图', 82 | link: '/demo/amap', 83 | }, 84 | { 85 | icon: , 86 | text: 'Avatar', 87 | link: '/demo/avatar', 88 | }, 89 | { 90 | icon: , 91 | text: '----', 92 | }, 93 | { 94 | icon: , 95 | text: '----', 96 | }, 97 | { 98 | icon: , 99 | text: '----', 100 | }, 101 | { 102 | icon: , 103 | text: '----', 104 | }, 105 | ]; 106 | 107 | export interface CenterProps { 108 | dispatch: Dispatch; 109 | } 110 | 111 | class Welcome extends React.PureComponent { 112 | handleExit = () => { 113 | const { dispatch } = this.props; 114 | dispatch({ 115 | type: 'login/logout', 116 | }); 117 | }; 118 | 119 | handleClick = item => { 120 | if (item.link) { 121 | history.push(item.link); 122 | } 123 | }; 124 | 125 | render() { 126 | return ( 127 |
128 |
129 |
130 | 136 | admin 137 | 2019-8-14 09:47:07 138 | 141 | 144 |
145 |
146 |
147 | 148 | 149 | {statistics.map(item => { 150 | return ( 151 | 152 | 153 | 154 | ); 155 | })} 156 | 157 | 158 |
159 | 166 |
167 | ); 168 | } 169 | } 170 | 171 | export default connect()(Welcome); 172 | -------------------------------------------------------------------------------- /src/components/Spin/index.less: -------------------------------------------------------------------------------- 1 | @import '~antd-mobile/lib/style/themes/default.less'; 2 | @import '../styles/index.less'; 3 | 4 | @spin-dot-size-sm: 14 * @hd; 5 | @spin-dot-size: 20 * @hd; 6 | @spin-dot-size-lg: 32 * @hd; 7 | @shadow-color-inverse: @fill-base; 8 | @component-background: @fill-base; 9 | 10 | @spin-prefix-cls: ~'spin'; 11 | @spin-dot-default: @color-text-secondary; 12 | 13 | .@{spin-prefix-cls} { 14 | .reset-component(); 15 | 16 | position: absolute; 17 | display: none; 18 | color: @brand-primary; 19 | text-align: center; 20 | vertical-align: middle; 21 | opacity: 0; 22 | transition: transform 0.3s @ease-in-out-quint; 23 | 24 | &-spinning { 25 | position: static; 26 | display: inline-block; 27 | opacity: 1; 28 | } 29 | 30 | &-nested-loading { 31 | position: relative; 32 | > div > .@{spin-prefix-cls} { 33 | position: absolute; 34 | top: 0; 35 | left: 0; 36 | z-index: 4; 37 | display: block; 38 | width: 100%; 39 | height: 100%; 40 | max-height: 400 * @hd; 41 | .@{spin-prefix-cls}-dot { 42 | position: absolute; 43 | top: 50%; 44 | left: 50%; 45 | margin: -@spin-dot-size / 2; 46 | } 47 | .@{spin-prefix-cls}-text { 48 | position: absolute; 49 | top: 50%; 50 | width: 100%; 51 | padding-top: (@spin-dot-size - @font-size-base) / 2 + 2 * @hd; 52 | text-shadow: 0 1 * @hd 2 * @hd @shadow-color-inverse; 53 | } 54 | &.@{spin-prefix-cls}-show-text .@{spin-prefix-cls}-dot { 55 | margin-top: -@spin-dot-size / 2 - 10 * @hd; 56 | } 57 | } 58 | 59 | > div > .@{spin-prefix-cls}-sm { 60 | .@{spin-prefix-cls}-dot { 61 | margin: -@spin-dot-size-sm / 2; 62 | } 63 | .@{spin-prefix-cls}-text { 64 | padding-top: (@spin-dot-size-sm - @font-size-base) / 2 + 2 * @hd; 65 | } 66 | &.@{spin-prefix-cls}-show-text .@{spin-prefix-cls}-dot { 67 | margin-top: -@spin-dot-size-sm / 2 - 10 * @hd; 68 | } 69 | } 70 | 71 | > div > .@{spin-prefix-cls}-lg { 72 | .@{spin-prefix-cls}-dot { 73 | margin: -@spin-dot-size-lg / 2; 74 | } 75 | .@{spin-prefix-cls}-text { 76 | padding-top: (@spin-dot-size-lg - @font-size-base) / 2 + 2 * @hd; 77 | } 78 | &.@{spin-prefix-cls}-show-text .@{spin-prefix-cls}-dot { 79 | margin-top: -@spin-dot-size-lg / 2 - 10 * @hd; 80 | } 81 | } 82 | } 83 | 84 | &-container { 85 | position: relative; 86 | transition: opacity 0.3s; 87 | 88 | &::after { 89 | position: absolute; 90 | top: 0; 91 | right: 0; 92 | bottom: 0; 93 | left: 0; 94 | z-index: 10; 95 | display: ~'none \9'; 96 | width: 100%; 97 | height: 100%; 98 | background: @component-background; 99 | opacity: 0; 100 | transition: all 0.3s; 101 | content: ''; 102 | pointer-events: none; 103 | } 104 | } 105 | 106 | &-blur { 107 | clear: both; 108 | overflow: hidden; 109 | opacity: 0.5; 110 | user-select: none; 111 | pointer-events: none; 112 | 113 | &::after { 114 | opacity: 0.4; 115 | pointer-events: auto; 116 | } 117 | } 118 | 119 | // tip 120 | // ------------------------------ 121 | &-tip { 122 | color: @spin-dot-default; 123 | } 124 | 125 | // dots 126 | // ------------------------------ 127 | 128 | &-dot { 129 | position: relative; 130 | display: inline-block; 131 | font-size: @spin-dot-size; 132 | 133 | .square(1em); 134 | 135 | &-item { 136 | position: absolute; 137 | display: block; 138 | width: 9 * @hd; 139 | height: 9 * @hd; 140 | background-color: @brand-primary; 141 | border-radius: 100%; 142 | transform: scale(0.75); 143 | transform-origin: 50% 50%; 144 | opacity: 0.3; 145 | animation: antSpinMove 1s infinite linear alternate; 146 | 147 | &:nth-child(1) { 148 | top: 0; 149 | left: 0; 150 | } 151 | &:nth-child(2) { 152 | top: 0; 153 | right: 0; 154 | animation-delay: 0.4s; 155 | } 156 | &:nth-child(3) { 157 | right: 0; 158 | bottom: 0; 159 | animation-delay: 0.8s; 160 | } 161 | &:nth-child(4) { 162 | bottom: 0; 163 | left: 0; 164 | animation-delay: 1.2s; 165 | } 166 | } 167 | 168 | &-spin { 169 | transform: rotate(45deg); 170 | animation: antRotate 1.2s infinite linear; 171 | } 172 | } 173 | 174 | // Sizes 175 | // ------------------------------ 176 | 177 | // small 178 | &-sm &-dot { 179 | font-size: @spin-dot-size-sm; 180 | 181 | i { 182 | width: 6 * @hd; 183 | height: 6 * @hd; 184 | } 185 | } 186 | 187 | // large 188 | &-lg &-dot { 189 | font-size: @spin-dot-size-lg; 190 | 191 | i { 192 | width: 14 * @hd; 193 | height: 14 * @hd; 194 | } 195 | } 196 | 197 | &&-show-text &-text { 198 | display: block; 199 | } 200 | } 201 | 202 | @media all and (-ms-high-contrast: none), (-ms-high-contrast: active) { 203 | /* IE10+ */ 204 | .@{spin-prefix-cls}-blur { 205 | background: @component-background; 206 | opacity: 0.5; 207 | } 208 | } 209 | 210 | @keyframes antSpinMove { 211 | to { 212 | opacity: 1; 213 | } 214 | } 215 | 216 | @keyframes antRotate { 217 | to { 218 | transform: rotate(405deg); 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import React from 'react'; 3 | import nzh from 'nzh/cn'; 4 | import { parse, stringify } from 'qs'; 5 | 6 | export function fixedZero(val) { 7 | return val * 1 < 10 ? `0${val}` : val; 8 | } 9 | 10 | export function getTimeDistance(type) { 11 | const now = new Date(); 12 | const oneDay = 1000 * 60 * 60 * 24; 13 | 14 | if (type === 'today') { 15 | now.setHours(0); 16 | now.setMinutes(0); 17 | now.setSeconds(0); 18 | return [moment(now), moment(now.getTime() + (oneDay - 1000))]; 19 | } 20 | 21 | if (type === 'week') { 22 | let day = now.getDay(); 23 | now.setHours(0); 24 | now.setMinutes(0); 25 | now.setSeconds(0); 26 | 27 | if (day === 0) { 28 | day = 6; 29 | } else { 30 | day -= 1; 31 | } 32 | 33 | const beginTime = now.getTime() - day * oneDay; 34 | 35 | return [moment(beginTime), moment(beginTime + (7 * oneDay - 1000))]; 36 | } 37 | 38 | if (type === 'month') { 39 | const year = now.getFullYear(); 40 | const month = now.getMonth(); 41 | const nextDate = moment(now).add(1, 'months'); 42 | const nextYear = nextDate.year(); 43 | const nextMonth = nextDate.month(); 44 | 45 | return [ 46 | moment(`${year}-${fixedZero(month + 1)}-01 00:00:00`), 47 | moment(moment(`${nextYear}-${fixedZero(nextMonth + 1)}-01 00:00:00`).valueOf() - 1000), 48 | ]; 49 | } 50 | 51 | const year = now.getFullYear(); 52 | return [moment(`${year}-01-01 00:00:00`), moment(`${year}-12-31 23:59:59`)]; 53 | } 54 | 55 | export function getPlainNode(nodeList, parentPath = '') { 56 | const arr = []; 57 | nodeList.forEach(node => { 58 | const item = node; 59 | item.path = `${parentPath}/${item.path || ''}`.replace(/\/+/g, '/'); 60 | item.exact = true; 61 | if (item.children && !item.component) { 62 | arr.push(...getPlainNode(item.children, item.path)); 63 | } else { 64 | if (item.children && item.component) { 65 | item.exact = false; 66 | } 67 | arr.push(item); 68 | } 69 | }); 70 | return arr; 71 | } 72 | 73 | export function digitUppercase(n) { 74 | return nzh.toMoney(n); 75 | } 76 | 77 | function getRelation(str1, str2) { 78 | if (str1 === str2) { 79 | console.warn('Two path are equal!'); // eslint-disable-line 80 | } 81 | const arr1 = str1.split('/'); 82 | const arr2 = str2.split('/'); 83 | if (arr2.every((item, index) => item === arr1[index])) { 84 | return 1; 85 | } 86 | if (arr1.every((item, index) => item === arr2[index])) { 87 | return 2; 88 | } 89 | return 3; 90 | } 91 | 92 | function getRenderArr(routes) { 93 | let renderArr = []; 94 | renderArr.push(routes[0]); 95 | for (let i = 1; i < routes.length; i += 1) { 96 | // 去重 97 | renderArr = renderArr.filter(item => getRelation(item, routes[i]) !== 1); 98 | // 是否包含 99 | const isAdd = renderArr.every(item => getRelation(item, routes[i]) === 3); 100 | if (isAdd) { 101 | renderArr.push(routes[i]); 102 | } 103 | } 104 | return renderArr; 105 | } 106 | 107 | /** 108 | * Get router routing configuration 109 | * { path:{name,...param}}=>Array<{name,path ...param}> 110 | * @param {string} path 111 | * @param {routerData} routerData 112 | */ 113 | export function getRoutes(path, routerData) { 114 | let routes = Object.keys(routerData).filter( 115 | routePath => routePath.indexOf(path) === 0 && routePath !== path 116 | ); 117 | // Replace path to '' eg. path='user' /user/name => name 118 | routes = routes.map(item => item.replace(path, '')); 119 | // Get the route to be rendered to remove the deep rendering 120 | const renderArr = getRenderArr(routes); 121 | // Conversion and stitching parameters 122 | const renderRoutes = renderArr.map(item => { 123 | const exact = !routes.some(route => route !== item && getRelation(route, item) === 1); 124 | return { 125 | exact, 126 | ...routerData[`${path}${item}`], 127 | key: `${path}${item}`, 128 | path: `${path}${item}`, 129 | }; 130 | }); 131 | return renderRoutes; 132 | } 133 | 134 | export function getPageQuery() { 135 | return parse(window.location.href.split('?')[1]); 136 | } 137 | 138 | export function getQueryPath(path = '', query = {}) { 139 | const search = stringify(query); 140 | if (search.length) { 141 | return `${path}?${search}`; 142 | } 143 | return path; 144 | } 145 | 146 | /* eslint no-useless-escape:0 */ 147 | const reg = /(((^https?:(?:\/\/)?)(?:[-;:&=\+\$,\w]+@)?[A-Za-z0-9.-]+(?::\d+)?|(?:www.|[-;:&=\+\$,\w]+@)[A-Za-z0-9.-]+)((?:\/[\+~%\/.\w-_]*)?\??(?:[-\+=&;%@.\w_]*)#?(?:[\w]*))?)$/; 148 | 149 | export function isUrl(path) { 150 | return reg.test(path); 151 | } 152 | 153 | export function formatWan(val) { 154 | const v = val * 1; 155 | if (!v || Number.isNaN(v)) return ''; 156 | 157 | let result = val; 158 | if (val > 10000) { 159 | result = Math.floor(val / 10000); 160 | result = ( 161 | 162 | {result} 163 | 172 | 万 173 | 174 | 175 | ); 176 | } 177 | return result; 178 | } 179 | 180 | export const importCDN = (url, name) => 181 | new Promise(resolve => { 182 | const dom = document.createElement('script'); 183 | dom.src = url; 184 | dom.type = 'text/javascript'; 185 | dom.onload = () => { 186 | resolve(window[name]); 187 | }; 188 | document.head.appendChild(dom); 189 | }); 190 | -------------------------------------------------------------------------------- /src/components/Spin/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import omit from 'omit.js'; 5 | import debounce from 'lodash/debounce'; 6 | import { tuple } from '../_util/type'; 7 | import { useStyles } from '../_util/style'; 8 | import styles from './index.less'; 9 | 10 | function useSpinStyles(className: string) { 11 | return useStyles(styles)(className); 12 | } 13 | 14 | const SpinSizes = tuple('small', 'default', 'large'); 15 | export type SpinSize = typeof SpinSizes[number]; 16 | export type SpinIndicator = React.ReactElement; 17 | 18 | // ref: https://github.com/ant-design/ant-design/blob/master/components/spin/index.tsx 19 | export interface SpinProps { 20 | prefixCls?: string; 21 | className?: string; 22 | spinning?: boolean; 23 | style?: React.CSSProperties; 24 | size?: SpinSize; 25 | tip?: string; 26 | delay?: number; 27 | wrapperClassName?: string; 28 | indicator?: SpinIndicator; 29 | } 30 | 31 | export interface SpinState { 32 | spinning?: boolean; 33 | notCssAnimationSupported?: boolean; 34 | } 35 | 36 | // Render indicator 37 | let defaultIndicator: React.ReactNode = null; 38 | 39 | function renderIndicator(prefixCls: string, props: SpinProps): React.ReactNode { 40 | const { indicator } = props; 41 | const dotClassName = `${prefixCls}-dot`; 42 | 43 | // should not be render default indicator when indicator value is null 44 | if (indicator === null) { 45 | return null; 46 | } 47 | 48 | if (React.isValidElement(indicator)) { 49 | return React.cloneElement(indicator, { 50 | className: classNames(indicator.props.className, useSpinStyles(dotClassName)), 51 | }); 52 | } 53 | 54 | if (React.isValidElement(defaultIndicator)) { 55 | return React.cloneElement(defaultIndicator as SpinIndicator, { 56 | className: classNames((defaultIndicator as SpinIndicator).props.className, dotClassName), 57 | }); 58 | } 59 | 60 | return ( 61 | 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | } 69 | 70 | function shouldDelay(spinning?: boolean, delay?: number): boolean { 71 | return !!spinning && !!delay && !isNaN(Number(delay)); 72 | } 73 | 74 | class Spin extends React.Component { 75 | static defaultProps = { 76 | spinning: true, 77 | size: 'default' as SpinSize, 78 | wrapperClassName: '', 79 | }; 80 | 81 | static propTypes = { 82 | prefixCls: PropTypes.string, 83 | className: PropTypes.string, 84 | spinning: PropTypes.bool, 85 | size: PropTypes.oneOf(SpinSizes), 86 | wrapperClassName: PropTypes.string, 87 | indicator: PropTypes.element, 88 | }; 89 | 90 | static setDefaultIndicator(indicator: React.ReactNode) { 91 | defaultIndicator = indicator; 92 | } 93 | 94 | originalUpdateSpinning: () => void; 95 | 96 | constructor(props: SpinProps) { 97 | super(props); 98 | 99 | const { spinning, delay } = props; 100 | const shouldBeDelayed = shouldDelay(spinning, delay); 101 | this.state = { 102 | spinning: spinning && !shouldBeDelayed, 103 | }; 104 | this.originalUpdateSpinning = this.updateSpinning; 105 | this.debouncifyUpdateSpinning(props); 106 | } 107 | 108 | componentDidMount() { 109 | this.updateSpinning(); 110 | } 111 | 112 | componentDidUpdate() { 113 | this.debouncifyUpdateSpinning(); 114 | this.updateSpinning(); 115 | } 116 | 117 | componentWillUnmount() { 118 | this.cancelExistingSpin(); 119 | } 120 | 121 | debouncifyUpdateSpinning = (props?: SpinProps) => { 122 | const { delay } = props || this.props; 123 | if (delay) { 124 | this.cancelExistingSpin(); 125 | this.updateSpinning = debounce(this.originalUpdateSpinning, delay); 126 | } 127 | }; 128 | 129 | updateSpinning = () => { 130 | const { spinning } = this.props; 131 | const { spinning: currentSpinning } = this.state; 132 | if (currentSpinning !== spinning) { 133 | this.setState({ spinning }); 134 | } 135 | }; 136 | 137 | cancelExistingSpin() { 138 | const { updateSpinning } = this; 139 | if (updateSpinning && (updateSpinning as any).cancel) { 140 | (updateSpinning as any).cancel(); 141 | } 142 | } 143 | 144 | isNestedPattern() { 145 | return !!(this.props && this.props.children); 146 | } 147 | 148 | renderSpin = () => { 149 | const { 150 | className, 151 | size, 152 | tip, 153 | wrapperClassName, 154 | style, 155 | ...restProps 156 | } = this.props; 157 | const { spinning } = this.state; 158 | 159 | const prefixCls = 'spin'; 160 | const spinClassName = classNames( 161 | useSpinStyles(prefixCls), 162 | { 163 | [useSpinStyles(`${prefixCls}-sm`)]: size === 'small', 164 | [useSpinStyles(`${prefixCls}-lg`)]: size === 'large', 165 | [useSpinStyles(`${prefixCls}-spinning`)]: spinning, 166 | [useSpinStyles(`${prefixCls}-show-text`)]: !!tip, 167 | }, 168 | className, 169 | ); 170 | 171 | // fix https://fb.me/react-unknown-prop 172 | const divProps = omit(restProps, ['spinning', 'delay', 'indicator']); 173 | 174 | const spinElement = ( 175 |
176 | {renderIndicator(prefixCls, this.props)} 177 | {tip ?
{tip}
: null} 178 |
179 | ); 180 | if (this.isNestedPattern()) { 181 | const containerClassName = classNames(useSpinStyles(`${prefixCls}-container`), { 182 | [useSpinStyles(`${prefixCls}-blur`)]: spinning, 183 | }); 184 | return ( 185 |
186 | {spinning &&
{spinElement}
} 187 |
188 | {this.props.children} 189 |
190 |
191 | ); 192 | } 193 | return spinElement; 194 | }; 195 | 196 | render() { 197 | return this.renderSpin(); 198 | } 199 | } 200 | 201 | export default Spin; 202 | -------------------------------------------------------------------------------- /src/components/Avatar/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import classNames from 'classnames'; 3 | import CustomIcon from '@/components/CustomIcon'; 4 | import { pxToRem } from '@/utils/customUtils'; 5 | import { useStyles } from '../_util/style'; 6 | import styles from './index.less'; 7 | 8 | function useAvatarStyles(className: string) { 9 | return useStyles(styles)(className); 10 | } 11 | 12 | // ref: https://github.com/ant-design/ant-design/blob/master/components/avatar/index.tsx 13 | export interface AvatarProps { 14 | /** Shape of avatar, options:`circle`, `square` */ 15 | shape?: 'circle' | 'square'; 16 | /* 17 | * Size of avatar, options: `large`, `small`, `default` 18 | * or a custom number size 19 | * */ 20 | size?: 'large' | 'small' | 'default' | number; 21 | /** Src of image avatar */ 22 | src?: string; 23 | /** Srcset of image avatar */ 24 | srcSet?: string; 25 | /** Type of the Icon to be used in avatar */ 26 | icon?: string | React.ReactNode; 27 | style?: React.CSSProperties; 28 | className?: string; 29 | children?: React.ReactNode; 30 | alt?: string; 31 | /* callback when img load error */ 32 | /* return false to prevent Avatar show default fallback behavior, then you can do fallback by your self */ 33 | onError?: () => boolean; 34 | } 35 | 36 | export interface AvatarState { 37 | scale: number; 38 | mounted: boolean; 39 | isImgExist: boolean; 40 | } 41 | 42 | export default class Avatar extends React.Component { 43 | static defaultProps = { 44 | shape: 'circle' as AvatarProps['shape'], 45 | size: 'default' as AvatarProps['size'], 46 | }; 47 | 48 | state = { 49 | scale: 1, 50 | mounted: false, 51 | isImgExist: true, 52 | }; 53 | 54 | private avatarNode: HTMLElement | undefined; 55 | 56 | private avatarChildren: HTMLElement | undefined; 57 | 58 | private lastChildrenWidth: number | undefined; 59 | 60 | private lastNodeWidth: number | undefined; 61 | 62 | componentDidMount() { 63 | this.setScale(); 64 | this.setState({ mounted: true }); 65 | } 66 | 67 | componentDidUpdate(prevProps: AvatarProps) { 68 | this.setScale(); 69 | if (prevProps.src !== this.props.src) { 70 | this.setState({ isImgExist: true, scale: 1 }); 71 | } 72 | } 73 | 74 | setScale = () => { 75 | if (!this.avatarChildren || !this.avatarNode) { 76 | return; 77 | } 78 | const childrenWidth = this.avatarChildren.offsetWidth; // offsetWidth avoid affecting be transform scale 79 | const nodeWidth = this.avatarNode.offsetWidth; 80 | // denominator is 0 is no meaning 81 | if ( 82 | childrenWidth === 0 || 83 | nodeWidth === 0 || 84 | (this.lastChildrenWidth === childrenWidth && this.lastNodeWidth === nodeWidth) 85 | ) { 86 | return; 87 | } 88 | this.lastChildrenWidth = childrenWidth; 89 | this.lastNodeWidth = nodeWidth; 90 | // add 4px gap for each side to get better performance 91 | this.setState({ 92 | scale: nodeWidth - 8 < childrenWidth ? (nodeWidth - 8) / childrenWidth : 1, 93 | }); 94 | }; 95 | 96 | handleImgLoadError = () => { 97 | const { onError } = this.props; 98 | const errorFlag = onError ? onError() : undefined; 99 | if (errorFlag !== false) { 100 | this.setState({ isImgExist: false }); 101 | } 102 | }; 103 | 104 | renderAvatar = () => { 105 | const { 106 | shape, 107 | size, 108 | src, 109 | srcSet, 110 | icon, 111 | className, 112 | alt, 113 | ...others 114 | } = this.props; 115 | 116 | const { isImgExist, scale, mounted } = this.state; 117 | 118 | const prefixCls = 'avatar'; 119 | 120 | const sizeCls = classNames({ 121 | [useAvatarStyles(`${prefixCls}-lg`)]: size === 'large', 122 | [useAvatarStyles(`${prefixCls}-sm`)]: size === 'small', 123 | }); 124 | 125 | 126 | const classString = classNames(useAvatarStyles(prefixCls), className, sizeCls, { 127 | [useAvatarStyles(`${prefixCls}-${shape}`)]: shape, 128 | [useAvatarStyles(`${prefixCls}-image`)]: src && isImgExist, 129 | [useAvatarStyles(`${prefixCls}-icon`)]: icon, 130 | }); 131 | 132 | const sizeStyle: React.CSSProperties = 133 | typeof size === 'number' 134 | ? { 135 | width: pxToRem(size), 136 | height: pxToRem(size), 137 | lineHeight: pxToRem(size), 138 | fontSize: icon ? pxToRem(size / 2) : pxToRem(18), 139 | } 140 | : {}; 141 | 142 | let { children } = this.props; 143 | if (src && isImgExist) { 144 | children = {alt}; 145 | } else if (icon) { 146 | if (typeof icon === 'string') { 147 | children = ; 148 | } else { 149 | children = icon; 150 | } 151 | } else { 152 | const childrenNode = this.avatarChildren; 153 | if (childrenNode || scale !== 1) { 154 | const transformString = `scale(${scale}) translateX(-50%)`; 155 | const childrenStyle: React.CSSProperties = { 156 | msTransform: transformString, 157 | WebkitTransform: transformString, 158 | transform: transformString, 159 | }; 160 | 161 | const sizeChildrenStyle: React.CSSProperties = 162 | typeof size === 'number' 163 | ? { 164 | lineHeight: pxToRem(size), 165 | } 166 | : {}; 167 | children = ( 168 | (this.avatarChildren = node)} 171 | style={{ ...sizeChildrenStyle, ...childrenStyle }} 172 | > 173 | {children} 174 | 175 | ); 176 | } else { 177 | const childrenStyle: React.CSSProperties = {}; 178 | if (!mounted) { 179 | childrenStyle.opacity = 0; 180 | } 181 | 182 | children = ( 183 | (this.avatarChildren = node)} 187 | > 188 | {children} 189 | 190 | ); 191 | } 192 | } 193 | return ( 194 | (this.avatarNode = node)} 199 | > 200 | {children} 201 | 202 | ); 203 | }; 204 | 205 | render() { 206 | return this.renderAvatar(); 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/components/CutomAMap/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, CSSProperties } from "react"; 2 | import { Toast, ActivityIndicator } from "antd-mobile"; 3 | import { Map, Marker, MapProps } from "react-amap"; 4 | import Geolocation from "react-amap-plugin-custom-geolocation"; 5 | import PlaceSearch from "./PlaceSearch"; 6 | 7 | let geocoder = null; 8 | const defaultMapWrapperHeight = 400; 9 | const titleHeight = '.81rem'; 10 | 11 | export const geoCode = (address, callback) => { 12 | if (geocoder) { 13 | (geocoder as any).getLocation(address, callback); 14 | } 15 | // geocoder.getLocation(address, (status, result) => { 16 | // console.log(address); 17 | // console.log(status); 18 | // console.log(result); 19 | // if (status === 'complete' && result.geocodes.length) { 20 | // return result.geocodes[0]; 21 | // } 22 | // console.error('根据地址查询位置失败'); 23 | // return {}; 24 | // }); 25 | }; 26 | 27 | function isLocationPosition(locationPosition, position) { 28 | const { 29 | longitude: locationLongitude, 30 | latitude: locationLatitude 31 | } = locationPosition; 32 | const { longitude, latitude } = position; 33 | return locationLongitude === longitude && locationLatitude === latitude; 34 | } 35 | 36 | export interface AMapProps { 37 | /** get position of Marker or center of map */ 38 | getPosition?: ({ longitude, latitude }: { 39 | longitude: number, 40 | latitude: number, 41 | }) => void; 42 | /** AMap wrapper style */ 43 | wrapperStyle?: CSSProperties; 44 | onClick?: (longitude: number, latitude: number) => void; 45 | /** get human-readable address */ 46 | getFormattedAddress?: (formattedAddress: string) => void; 47 | onCreated?: (map: any) => void; 48 | mapProps?: MapProps; 49 | } 50 | 51 | export function AMap(props: AMapProps) { 52 | const { 53 | getPosition, 54 | wrapperStyle = {}, 55 | onClick, 56 | getFormattedAddress, 57 | onCreated, 58 | mapProps, 59 | } = props; 60 | const [locationPosition, setLocationPosition] = useState({}); 61 | const [formattedAddress, setFormattedAddress] = useState(); 62 | const [position, setPosition] = useState(); 63 | 64 | const handleCreatedMap = map => { 65 | if (onCreated) { 66 | onCreated(map); 67 | } 68 | if (!geocoder) { 69 | geocoder = new window.AMap.Geocoder({ 70 | // city: '010', // 城市设为北京,默认:“全国” 71 | radius: 1000 // 范围,默认:500 72 | }); 73 | } 74 | }; 75 | 76 | const regeoCode = (longitude, latitude) => { 77 | if (geocoder) { 78 | (geocoder as any).getAddress([longitude, latitude], (status, result) => { 79 | const address = status === "complete" ? result.regeocode.formattedAddress : null; 80 | if (getFormattedAddress) { 81 | getFormattedAddress(address); 82 | } 83 | setFormattedAddress(address); 84 | }); 85 | } 86 | }; 87 | 88 | const plugins = ["Scale"]; 89 | 90 | let renderFormattedAddress = "(请选择地址)"; 91 | if (formattedAddress) { 92 | renderFormattedAddress = formattedAddress; 93 | } 94 | 95 | const { height } = wrapperStyle; 96 | const customProps = position ? { 97 | center: position, 98 | } : {}; 99 | return ( 100 | 101 |

当前地址:{renderFormattedAddress}

102 |
109 | { 116 | const { lnglat } = event; 117 | console.log( 118 | "click position:", 119 | `${lnglat.getLng()}, ${lnglat.getLat()}` 120 | ); 121 | const _position = { 122 | longitude: lnglat.getLng(), 123 | latitude: lnglat.getLat(), 124 | } 125 | if (onClick) { 126 | onClick(lnglat.getLng(), lnglat.getLat()); 127 | } 128 | if (getPosition) { 129 | getPosition(_position); 130 | } 131 | setPosition(_position); 132 | regeoCode(lnglat.getLng(), lnglat.getLat()); 133 | } 134 | }} 135 | version="1.4.14&plugin=AMap.Geocoder,AMap.Autocomplete,AMap.PlaceSearch" 136 | zoom={13} 137 | loading={ 138 | 139 | } 140 | {...customProps} 141 | {...mapProps} 142 | > 143 | {position && !isLocationPosition(locationPosition, position) ? ( 144 | 145 | ) : null} 146 | { 153 | window.AMap.event.addListener(o, "complete", result => { 154 | const _position = { 155 | longitude: result.position.lng, 156 | latitude: result.position.lat, 157 | } 158 | setLocationPosition(_position); 159 | if (getPosition) { 160 | getPosition(_position); 161 | } 162 | setPosition(_position); 163 | if (getFormattedAddress) { 164 | getFormattedAddress(result.formattedAddress); 165 | } 166 | setFormattedAddress(result.formattedAddress); 167 | }); // 返回定位信息 168 | window.AMap.event.addListener( 169 | o, 170 | "error", 171 | ({ info, message: msg }) => { 172 | Toast.fail("定位失败"); 173 | console.error("定位失败", info, msg); 174 | } 175 | ); // 返回定位出错信息 176 | } 177 | }} 178 | /> 179 | { 181 | console.log("PlaceSearch poi", poi); 182 | const _position = { 183 | longitude: poi.location.lng, 184 | latitude: poi.location.lat, 185 | } 186 | if (getPosition) { 187 | getPosition(_position); 188 | } 189 | setPosition(_position); 190 | const address = `${poi.district}${poi.address}${poi.name}` 191 | if (getFormattedAddress) { 192 | getFormattedAddress(address); 193 | } 194 | setFormattedAddress(address); 195 | }} 196 | /> 197 | 198 |
199 |
200 | ); 201 | } 202 | 203 | export default AMap; 204 | -------------------------------------------------------------------------------- /src/components/Ellipsis/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import classNames from 'classnames'; 3 | import styles from './index.less'; 4 | 5 | /* eslint react/no-did-mount-set-state: 0 */ 6 | /* eslint no-param-reassign: 0 */ 7 | 8 | const isSupportLineClamp = (document.body.style as any).webkitLineClamp !== undefined; 9 | 10 | export const getStrFullLength = (str = '') => 11 | str.split('').reduce((pre, cur) => { 12 | const charCode = cur.charCodeAt(0); 13 | if (charCode >= 0 && charCode <= 128) { 14 | return pre + 1; 15 | } 16 | return pre + 2; 17 | }, 0); 18 | 19 | export const cutStrByFullLength = (str = '', maxLength) => { 20 | let showLength = 0; 21 | return str.split('').reduce((pre, cur) => { 22 | const charCode = cur.charCodeAt(0); 23 | if (charCode >= 0 && charCode <= 128) { 24 | showLength += 1; 25 | } else { 26 | showLength += 2; 27 | } 28 | if (showLength <= maxLength) { 29 | return pre + cur; 30 | } 31 | return pre; 32 | }, ''); 33 | }; 34 | 35 | const EllipsisText = ({ text, length, fullWidthRecognition, ...other }) => { 36 | if (typeof text !== 'string') { 37 | throw new Error('Ellipsis children must be string.'); 38 | } 39 | const textLength = fullWidthRecognition ? getStrFullLength(text) : text.length; 40 | if (textLength <= length || length < 0) { 41 | return {text}; 42 | } 43 | const tail = '...'; 44 | let displayText; 45 | if (length - tail.length <= 0) { 46 | displayText = ''; 47 | } else { 48 | displayText = fullWidthRecognition ? cutStrByFullLength(text, length) : text.slice(0, length); 49 | } 50 | 51 | return ( 52 | 53 | {displayText} 54 | {tail} 55 | 56 | ) 57 | }; 58 | 59 | export interface EllipsisProps { 60 | /** 在按照行数截取下最大的行数,超过则截取省略 */ 61 | lines?: number; 62 | /** 在按照长度截取下的文本最大字符数,超过则截取省略 */ 63 | length?: number; 64 | className?: string; 65 | /** 是否将全角字符的长度视为 2 来计算字符串长度 */ 66 | fullWidthRecognition?: boolean; 67 | } 68 | 69 | // reference: https://v2-pro.ant.design/components/ellipsis-cn 70 | export default class Ellipsis extends Component { 71 | state = { 72 | text: '', 73 | targetCount: 0, 74 | }; 75 | 76 | root: any; 77 | node: any; 78 | content: any; 79 | shadow: any; 80 | shadowChildren: any; 81 | 82 | componentDidMount() { 83 | if (this.node) { 84 | this.computeLine(); 85 | } 86 | } 87 | 88 | componentDidUpdate(perProps) { 89 | const { lines } = this.props; 90 | if (lines !== perProps.lines) { 91 | this.computeLine(); 92 | } 93 | } 94 | 95 | computeLine = () => { 96 | const { lines } = this.props; 97 | if (lines && !isSupportLineClamp) { 98 | const text = this.shadowChildren.innerText || this.shadowChildren.textContent; 99 | const lineHeight = parseInt(getComputedStyle(this.root).lineHeight as string, 10); 100 | const targetHeight = lines * lineHeight; 101 | this.content.style.height = `${targetHeight}px`; 102 | const totalHeight = this.shadowChildren.offsetHeight; 103 | const shadowNode = this.shadow.firstChild; 104 | 105 | if (totalHeight <= targetHeight) { 106 | this.setState({ 107 | text, 108 | targetCount: text.length, 109 | }); 110 | return; 111 | } 112 | 113 | // bisection 114 | const len = text.length; 115 | const mid = Math.ceil(len / 2); 116 | 117 | const count = this.bisection(targetHeight, mid, 0, len, text, shadowNode); 118 | 119 | this.setState({ 120 | text, 121 | targetCount: count, 122 | }); 123 | } 124 | }; 125 | 126 | bisection = (th, m, b, e, text, shadowNode) => { 127 | const suffix = '...'; 128 | let mid = m; 129 | let end = e; 130 | let begin = b; 131 | shadowNode.innerHTML = text.substring(0, mid) + suffix; 132 | let sh = shadowNode.offsetHeight; 133 | 134 | if (sh <= th) { 135 | shadowNode.innerHTML = text.substring(0, mid + 1) + suffix; 136 | sh = shadowNode.offsetHeight; 137 | if (sh > th || mid === begin) { 138 | return mid; 139 | } 140 | begin = mid; 141 | if (end - begin === 1) { 142 | mid = 1 + begin; 143 | } else { 144 | mid = Math.floor((end - begin) / 2) + begin; 145 | } 146 | return this.bisection(th, mid, begin, end, text, shadowNode); 147 | } 148 | if (mid - 1 < 0) { 149 | return mid; 150 | } 151 | shadowNode.innerHTML = text.substring(0, mid - 1) + suffix; 152 | sh = shadowNode.offsetHeight; 153 | if (sh <= th) { 154 | return mid - 1; 155 | } 156 | end = mid; 157 | mid = Math.floor((end - begin) / 2) + begin; 158 | return this.bisection(th, mid, begin, end, text, shadowNode); 159 | }; 160 | 161 | handleRoot = n => { 162 | this.root = n; 163 | }; 164 | 165 | handleContent = n => { 166 | this.content = n; 167 | }; 168 | 169 | handleNode = n => { 170 | this.node = n; 171 | }; 172 | 173 | handleShadow = n => { 174 | this.shadow = n; 175 | }; 176 | 177 | handleShadowChildren = n => { 178 | this.shadowChildren = n; 179 | }; 180 | 181 | render() { 182 | const { text, targetCount } = this.state; 183 | const { 184 | children, 185 | lines, 186 | length, 187 | className, 188 | fullWidthRecognition, 189 | ...restProps 190 | } = this.props; 191 | 192 | const cls = classNames(styles.ellipsis, className, { 193 | [styles.lines]: lines && !isSupportLineClamp, 194 | [styles.lineClamp]: lines && isSupportLineClamp, 195 | }); 196 | 197 | if (!lines && !length) { 198 | return ( 199 | 200 | {children} 201 | 202 | ); 203 | } 204 | 205 | // length 206 | if (!lines) { 207 | return ( 208 | 215 | ); 216 | } 217 | 218 | const id = `antd-pro-ellipsis-${`${new Date().getTime()}${Math.floor(Math.random() * 100)}`}`; 219 | 220 | // support document.body.style.webkitLineClamp 221 | if (isSupportLineClamp) { 222 | const style = `#${id}{-webkit-line-clamp:${lines};-webkit-box-orient: vertical;}`; 223 | 224 | const node = ( 225 |
226 | 227 | {children} 228 |
229 | ); 230 | 231 | return node; 232 | } 233 | 234 | const childNode = ( 235 | 236 | {targetCount > 0 && text.substring(0, targetCount)} 237 | {targetCount > 0 && targetCount < text.length && '...'} 238 | 239 | ); 240 | 241 | return ( 242 |
243 |
244 | {childNode} 245 |
246 | {children} 247 |
248 |
249 | {text} 250 |
251 |
252 |
253 | ); 254 | } 255 | } 256 | --------------------------------------------------------------------------------