From 3c140606e0b0da01274f90bbdfe20766f3a03d0d Mon Sep 17 00:00:00 2001 From: James Date: Wed, 5 Dec 2018 18:30:45 +0800 Subject: [PATCH] feat: App mgmt pages (#552) --- config/i18n.config.js | 6 +- src/components/Base/DocLink/index.jsx | 64 +++ src/components/Base/Upload/index.jsx | 13 +- src/components/Base/Upload/index.scss | 4 + src/components/Base/index.js | 1 + src/components/Layout/Stepper/index.jsx | 164 +++++++ src/components/Layout/Stepper/index.scss | 133 +++++ src/components/Layout/index.js | 1 + src/components/Layout/index.jsx | 4 +- src/config/doc-link.js | 12 + src/config/index.js | 3 + src/locales/zh/apps.json | 119 +++++ src/locales/zh/translation.json | 26 - src/pages/Dashboard/Apps/Add/Card/index.jsx | 69 +++ src/pages/Dashboard/Apps/Add/Card/index.scss | 77 +++ src/pages/Dashboard/Apps/Add/index.jsx | 454 ++++++++++-------- src/pages/Dashboard/Apps/Add/index.scss | 292 ++++++----- src/pages/Dashboard/Apps/Card/index.jsx | 76 +++ src/pages/Dashboard/Apps/Card/index.scss | 63 +++ .../Dashboard/Apps/MyApps/Empty/index.jsx | 33 ++ .../Dashboard/Apps/MyApps/Empty/index.scss | 25 + src/pages/Dashboard/Apps/MyApps/index.jsx | 119 +++++ src/pages/Dashboard/Apps/MyApps/index.scss | 80 +++ src/pages/Dashboard/Apps/index.jsx | 13 +- src/pages/Dashboard/Apps/index.scss | 6 + src/pages/Dashboard/index.js | 1 + src/routes/index.js | 2 + src/stores/app/create.js | 342 ++++++++++++- src/utils/icons.js | 56 +++ test/components/Base/DocLink.test.js | 16 + .../Base/__snapshots__/DocLink.test.js.snap | 12 + 31 files changed, 1941 insertions(+), 345 deletions(-) create mode 100644 src/components/Base/DocLink/index.jsx create mode 100644 src/components/Layout/Stepper/index.jsx create mode 100644 src/components/Layout/Stepper/index.scss create mode 100644 src/config/doc-link.js create mode 100644 src/config/index.js create mode 100644 src/locales/zh/apps.json create mode 100644 src/pages/Dashboard/Apps/Add/Card/index.jsx create mode 100644 src/pages/Dashboard/Apps/Add/Card/index.scss create mode 100644 src/pages/Dashboard/Apps/Card/index.jsx create mode 100644 src/pages/Dashboard/Apps/Card/index.scss create mode 100644 src/pages/Dashboard/Apps/MyApps/Empty/index.jsx create mode 100644 src/pages/Dashboard/Apps/MyApps/Empty/index.scss create mode 100644 src/pages/Dashboard/Apps/MyApps/index.jsx create mode 100644 src/pages/Dashboard/Apps/MyApps/index.scss create mode 100644 test/components/Base/DocLink.test.js create mode 100644 test/components/Base/__snapshots__/DocLink.test.js.snap diff --git a/config/i18n.config.js b/config/i18n.config.js index a28eb35e..472f899c 100644 --- a/config/i18n.config.js +++ b/config/i18n.config.js @@ -3,7 +3,11 @@ const translations = { translation: require('../src/locales/en/translation.json') }, zh: { - translation: require('../src/locales/zh/translation.json') + translation: Object.assign( + {}, + require('../src/locales/zh/apps.json'), + require('../src/locales/zh/translation.json') + ) } }; diff --git a/src/components/Base/DocLink/index.jsx b/src/components/Base/DocLink/index.jsx new file mode 100644 index 00000000..ab6a2760 --- /dev/null +++ b/src/components/Base/DocLink/index.jsx @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { translate } from 'react-i18next'; + +import links from 'config/doc-link'; + +@translate() +export default class DocLink extends Component { + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + isExternal: PropTypes.bool, + name: PropTypes.string, + to: PropTypes.string + }; + + static defaultProps = { + children: null, + className: '', + isExternal: true, + name: '' + }; + + render() { + const { + t, to, name, children, className, isExternal + } = this.props; + let text = t(`LINK_${name}`); + const linkTo = to || links[name]; + if (text === `LINK_${name}`) { + text = linkTo; + } + if (children) { + text = children; + } + if (!text) { + return null; + } + if (isExternal) { + if (!linkTo) { + throw new Error( + `You should edit a link url in the file of 'config/doc-links'. name:${name}` + ); + } + return ( + + {text} + + ); + } + + return ( + + {text} + + ); + } +} diff --git a/src/components/Base/Upload/index.jsx b/src/components/Base/Upload/index.jsx index bf9723b5..fb069590 100644 --- a/src/components/Base/Upload/index.jsx +++ b/src/components/Base/Upload/index.jsx @@ -40,7 +40,7 @@ export default class Upload extends Component { state = { uid: getUid(), - isDraging: true + isDraging: false }; componentDidMount() { @@ -170,11 +170,14 @@ export default class Upload extends Component { return ( diff --git a/src/components/Base/Upload/index.scss b/src/components/Base/Upload/index.scss index c4489ec2..eee8c39a 100644 --- a/src/components/Base/Upload/index.scss +++ b/src/components/Base/Upload/index.scss @@ -8,5 +8,9 @@ cursor: not-allowed; pointer-events: none; } + &.upload-dragover { + opacity: 0.3; + box-shadow: 0 1px 4px 0 rgba(73, 33, 173, 0.06), 0 4px 8px 0 rgba(35, 35, 36, 0.04); + } } } diff --git a/src/components/Base/index.js b/src/components/Base/index.js index 6a78581a..64b275d4 100644 --- a/src/components/Base/index.js +++ b/src/components/Base/index.js @@ -16,3 +16,4 @@ export Timeline from './Timeline'; export Tooltip from './Tooltip'; export Image from './Image'; export Upload from './Upload'; +export DocLink from './DocLink'; diff --git a/src/components/Layout/Stepper/index.jsx b/src/components/Layout/Stepper/index.jsx new file mode 100644 index 00000000..724173de --- /dev/null +++ b/src/components/Layout/Stepper/index.jsx @@ -0,0 +1,164 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { withRouter } from 'react-router-dom'; +import { observer } from 'mobx-react'; +import { translate } from 'react-i18next'; +import _ from 'lodash'; +import Loading from 'components/Loading'; + +import { DocLink, Icon } from 'components/Base'; + +import styles from './index.scss'; + +@translate() +@observer +class LayoutStepper extends Component { + static propTypes = { + children: PropTypes.node, + className: PropTypes.string, + name: PropTypes.string, + stepOption: PropTypes.shape({ + activeStep: PropTypes.number, + steps: PropTypes.number, + prevStep: PropTypes.func, + disableNextStep: PropTypes.bool, + isLoading: PropTypes.bool, + nextStep: PropTypes.func + }) + }; + + renderTopProgress() { + const { stepOption } = this.props; + const { steps, activeStep } = stepOption; + const width = `${activeStep * 100 / steps}%`; + const className = activeStep > steps ? 'headerStepNotFinished' : 'headerStepNotFinished'; + + const style = { + width + }; + + return ( +
+ ); + } + + renderTopNav() { + const { + name, stepOption, t, history + } = this.props; + const { + activeStep, steps, prevStep, goBack + } = stepOption; + const funcBack = _.isFunction(goBack) ? goBack : history.goBack; + + if (activeStep > steps) { + return null; + } + + let text = ''; + if (activeStep > 1 && !!name) { + text = t(`STEPPER_HEADER_${name.toUpperCase()}_${activeStep}`); + } + + return ( +
+ {activeStep > 1 && ( + + )} + +
+ ); + } + + renderTitle() { + const { name, stepOption, t } = this.props; + const { activeStep, steps } = stepOption; + + if (activeStep > steps) { + return null; + } + + const nameKey = name.toUpperCase(); + const header = t(`STEPPER_NAME_${nameKey}_HEADER`, { + activeStep, + steps + }); + const title = t(`STEPPER_TITLE_${nameKey}_${activeStep}`); + return ( +
+
{header}
+
{title}
+
+ ); + } + + renderFooter() { + const { + t, stepOption, name, disableNextStep + } = this.props; + const { activeStep, steps, nextStep } = stepOption; + + if (activeStep > steps) { + return null; + } + + const keyName = `STEPPER_FOOTER_${name.toLocaleUpperCase()}_${activeStep}`; + const tipText = t(keyName); + const tipLink = ; + + const buttonText = t('Go on'); + + return ( +
+ + {t('Tips')} + {tipText} + {tipLink} + + +
+ ); + } + + render() { + const { className, children, stepOption } = this.props; + return ( +
+ {this.renderTopProgress()} + {this.renderTopNav()} + {this.renderTitle()} + +
{children}
+
+ {this.renderFooter()} +
+ ); + } +} + +export default withRouter(LayoutStepper); diff --git a/src/components/Layout/Stepper/index.scss b/src/components/Layout/Stepper/index.scss new file mode 100644 index 00000000..6ea3fdd2 --- /dev/null +++ b/src/components/Layout/Stepper/index.scss @@ -0,0 +1,133 @@ +@import '~scss/vars'; + +.headerStep { + position: fixed; + top: 0; + left: 0; + height: 2px; +} + +.headerStepNotFinished { + background-color: $Y75; +} + +.headerStepFinished { + background-color: $G100; +} + +.operate { + position: fixed; + top: 20px; + left: 20px; + right: 20px; + height: 32px; + + > label { + display: inline-block; + font-size: 12px; + line-height: 32px; + color: $N300; + border-radius: 2px; + cursor: pointer; + padding: 0 12px; + // background: $N10; + } + :global { + .icon-close { + position: static; + transform: none; + vertical-align: middle; + margin-top: -1px; + } + } +} +.operateText { + color: $N65; +} + + +.footer { + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: 64px; + padding: 0 20px; + display: flex; + align-items: center; + background: $N0; + border-top: 1px solid rgba($N30, 0.5); +} +.footerTips { + font-size: 12px; + line-height: 1.67; + margin: 22px 0; + display: inline-block; + color: $N65; + flex: 1; + + .footerTipsButton { + border-radius: 1px; + background-color: $N30; + padding: 2px 4px; + color: $N100; + margin-right: 4px; + display: inline-block; + } +} +.button { + border-radius: 2px; + color: $N0; + padding: 4px 12px; + box-shadow: 0 1px 3px 0 rgba($P400, 0.16), 0 1px 2px 0 rgba($N500, 0.03); + background-color: $P75; + border: 0; + opacity: 0.5; + cursor: pointer; + > span { + font-size: 14px; + line-height: 24px; + text-align: center; + } + &:active { + border: 0; + } +} +.buttonActived { + opacity: 1; +} + + +.icon { + vertical-align: middle; + margin: 0 4px; + margin-top: -1px; + :global { + .qicon { + --primary-color: #fff; + } + } +} + +.stepContent { + padding: 100px 0 0; + min-width: 700px; + text-align: center; + + .stepName { + margin-bottom: 4px; + font-size: 14px; + line-height: 24px; + color: $N300; + } + + .stepExplain { + margin-bottom: 32px; + font-size: 24px; + font-weight: 500; + line-height: 28px; + letter-spacing: 0; + color: $N500; + } +} + diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js index 6e1056c1..24bc7afd 100644 --- a/src/components/Layout/index.js +++ b/src/components/Layout/index.js @@ -12,5 +12,6 @@ export Card from './Card'; export SideNav from './SideNav'; export BreadCrumb from './BreadCrumb'; +export Stepper from './Stepper'; export default Layout; diff --git a/src/components/Layout/index.jsx b/src/components/Layout/index.jsx index 287a3dd9..308488af 100644 --- a/src/components/Layout/index.jsx +++ b/src/components/Layout/index.jsx @@ -38,6 +38,7 @@ class Layout extends Component { title: '', pageTitle: '', hasSearch: false, + noSubMenu: false, isHome: false }; @@ -82,6 +83,7 @@ class Layout extends Component { title, isHome, match, + noSubMenu, pageTitle } = this.props; @@ -89,7 +91,7 @@ class Layout extends Component { const hasMenu = (isDev || isAdmin) && !isHome; const paths = ['/dashboard', '/profile', '/ssh_keys', '/dev/apps']; const isCenterPage = Boolean(pageTitle); // detail page, only one level menu - const hasSubNav = hasMenu && !isCenterPage && !paths.includes(match.path); + const hasSubNav = hasMenu && !noSubMenu && !isCenterPage && !paths.includes(match.path); return (
!isAdded && selectVersionType(value)} + className={classNames(styles.container, className, { + [styles.addedContainer]: isAdded + })} + > + {isAdded && {t('Added')} } + {isSelected && ( + + )} + +
{name}
+
{t(intro)}
+ + {t('Linkto_Intro_App')} + + +
+ ); + } +} diff --git a/src/pages/Dashboard/Apps/Add/Card/index.scss b/src/pages/Dashboard/Apps/Add/Card/index.scss new file mode 100644 index 00000000..1efe707e --- /dev/null +++ b/src/pages/Dashboard/Apps/Add/Card/index.scss @@ -0,0 +1,77 @@ +@import '~scss/vars'; + +.container { + width: 295.7px; + border-radius: 2px; + background: $N0; + margin-bottom: 20px; + padding: 36px 24px 32px; + border: solid 1px $N0; + text-align: center; + position: relative; + cursor: pointer; + transition: border-color 200ms linear; + &:hover { + border: solid 1px $P30; + .intro { + display: none; + } + a { + display: block; + } + } + &.selected { + border: solid 1px $N75; + } + &.added { + cursor: none; + border: solid 1px $N0; + } + + a { + display: none; + margin-top: 20px; + } +} +.addedContainer { + opacity: 0.8; + cursor: default; + &:hover { + border-color: transparent; + } +} +.disabledContainer { + opacity: 0.5; +} +.checkedIcon { + position: absolute; + top: 8px; + left: 8px; +} + +.linkIcon { + vertical-align: middle; + margin-left: 4px; +} +.name { + font-size: 16px; + font-weight: 500; + line-height: 1.75; + letter-spacing: normal; + color: $N500; +} +.intro { + font-size: 12px; + line-height: 1.67; + text-align: center; + color: $N75; +} +.addedType { + position: absolute; + top: 8px; + left: 8px; + opacity: 0.5; + border-radius: 1px; + background-color: $N30; + padding: 8px; +} diff --git a/src/pages/Dashboard/Apps/Add/index.jsx b/src/pages/Dashboard/Apps/Add/index.jsx index 3c2b09bf..857dda6e 100644 --- a/src/pages/Dashboard/Apps/Add/index.jsx +++ b/src/pages/Dashboard/Apps/Add/index.jsx @@ -1,249 +1,309 @@ -import React, { Component } from 'react'; +import React, { Component, Fragment } from 'react'; import { observer, inject } from 'mobx-react'; -import { Link } from 'react-router-dom'; -import classNames from 'classnames'; +import classnames from 'classnames'; import { translate } from 'react-i18next'; +import _ from 'lodash'; import { - Icon, Button, Upload, Notification + Icon, Button, Input, Upload, Notification } from 'components/Base'; -import RepoList from './RepoList'; -import StepContent from './StepContent'; +import { Stepper } from 'components/Layout'; +import AppCard from 'pages/Dashboard/Apps/Card'; +import Card from './Card'; import styles from './index.scss'; @translate() @inject(({ rootStore }) => ({ appStore: rootStore.appStore, - repoStore: rootStore.repoStore, + appCreateStore: rootStore.appCreateStore, user: rootStore.user })) @observer export default class AppAdd extends Component { - async componentDidMount() { - const { repoStore } = this.props; - - await repoStore.fetchAll({ - noLimit: true - }); - } + constructor(props) { + super(props); - componentWillUnmount() { - const { appStore } = this.props; - appStore.createReset(); + const { match, appCreateStore } = props; + const appId = _.get(match, 'params.appId'); + const isCreateApp = !appId; + appCreateStore.isCreateApp = isCreateApp; + appCreateStore.reset({ isCreateApp, appId }); + this.state = { + name: isCreateApp ? 'create_app' : 'create_app_version', + isCreateApp, + appId + }; } - setCreateStep = step => { - const { appStore, history } = this.props; - const { setCreateStep, createStep } = appStore; - - window.scroll({ top: 0, behavior: 'smooth' }); - - step = step || createStep - 1; - appStore.createError = ''; - if (step) { - setCreateStep(step); - } else { - history.goBack(); - } - }; - - selectRepoNext = repos => { - const { repoStore, t } = this.props; - - if (!repos.length) { - repoStore.info(t('Please select one repo')); - return; - } - - this.setCreateStep(2); - }; - - onChange = repoId => { - const { repoStore, appStore } = this.props; - const { repos } = repoStore; - - appStore.createReopId = repoId; - repos.forEach(repo => { - if (repo.repo_id === repoId) { - repo.active = true; - } else { - repo.active = false; - } - }); - }; - - checkFile = file => { - const result = true; - const { appStore, t } = this.props; - const maxsize = 2 * 1024 * 1024; - - if (!/\.(tar|tar\.gz|tar\.bz|tgz)$/.test(file.name.toLocaleLowerCase())) { - appStore.createError = t('file_format_note'); - return false; - } - if (file.size > maxsize) { - appStore.createError = t('The file size cannot exceed 2M'); - return false; + async componentDidMount() { + const { appCreateStore } = this.props; + const app_id = this.state.appId; + if (app_id) { + await appCreateStore.fetchOneApp({ app_id }); } + } - return result; - }; - - uploadFile = base64Str => { - const { appStore } = this.props; - appStore.uploadFile = base64Str; - appStore.createOrModify(); + onUploadClick = () => { + this.uploadRef.onClick(); }; - renderSelectRepo() { - const { t } = this.props; - const { repos } = this.props.repoStore; - - // filter s3 repos support upload package - const filterRepos = repos.filter(rp => rp.type.toLowerCase() === 's3'); - const publicRepos = filterRepos.filter( - repo => repo.visibility === 'public' - ); - const privateRepos = filterRepos.filter( - repo => repo.visibility === 'private' - ); - const selectRepos = repos.filter( - repo => repo.active && repo.type.toLowerCase() === 's3' - ); - const name = t('creat_new_app'); - const explain = t('select_repo_app'); - + renderVersionTypes() { + const { appCreateStore } = this.props; + const { versionTypes } = appCreateStore; return ( - -
- - -
-
this.selectRepoNext(selectRepos)} - className={classNames(styles.stepOperate, { - [styles.noClick]: !selectRepos.length - })} - > - {t('Next')} → -
-
+
+ {versionTypes.map(item => ( + + ))} +
); } - renderUploadPackage() { - const { t } = this.props; - const { isLoading, createError } = this.props.appStore; - const name = t('creat_new_app'); - const explain = t('Upload Package'); + renderUploadConf() { + const { t, appCreateStore } = this.props; + const { + isLoading, + uploadStatus, + errorMessage, + checkPackageFile, + fileName, + getPackageFiles, + uploadError, + uploadPackage + } = appCreateStore; + const files = getPackageFiles(); + const errorKeys = _.keys(uploadError); return ( - - + + { + this.uploadRef = node; + }} + checkFile={checkPackageFile} + uploadFile={uploadPackage} + >
- -

{t('click_upload')}

-

{t('file_format_note')}

- {isLoading &&
} + {!!isLoading && ( +
+ +

{t('file_format_loading')}

+
+ )} + {!isLoading + && uploadStatus !== 'ok' + && !errorMessage && ( +
+ +

{t('file_format_note')}

+
+ )} + {!isLoading + && uploadStatus !== 'ok' + && errorMessage && ( +
+ + {errorMessage} + 「 + {t('Upload again')} + 」 +
+ )} + {!isLoading + && uploadStatus === 'ok' + && !errorMessage && ( +
+ +
+ {t('File')} + {fileName} + {t('Successful upload')} +
+
+ )}
-
- {t('view_guide_1')} - - {t('view_guide_2')} - - {t('view_guide_3')} -
- {createError && ( -
- - {createError} -
- )} - +
    + {files.map(file => ( +
  • + + {file} + + + {errorKeys.includes(file) ? ( + {t(`${uploadError[file]}`)} + ) : ( + # {t(`${file.replace('.', '_')}_Info`)} + )} + +
  • + ))} + {uploadStatus === 'ok' && ( +
    + {t('The file has problem?')} + + {t('Upload again')} + +
    + )} + {uploadStatus === 'init' &&
    } +
+ ); } - renderCreatedApp() { - const { t } = this.props; - const { createAppId } = this.props.appStore; - const name = t('Congratulations'); - const explain = t('app_created'); + renderConfirmMsg() { + const { t, appCreateStore } = this.props; + const { + iconBase64, + attribute, + checkIconFile, + uploadIcon, + errorMessage, + valueChange + } = appCreateStore; + const { isCreateApp } = this.state; return ( - -
- + +
+ {isCreateApp && ( +
+ + + {t('INPUT_APP_NAME_TIP')} +
+ )} +
+ + + {t('INPUT_APP_VERSION_TIP')} +
+ {isCreateApp && ( +
+ + + + {iconBase64 && ( + + )} + {!iconBase64 && ( + + {t('Select a file')} + + )} + + + {t('INPUT_APP_ICON_TIP')} + {errorMessage} +
+ )}
-
- - - - - - + + ); + } + + renderSuccessMsg() { + const { + appCreateStore, t, rootStore, history + } = this.props; + const { isCreateApp, appId } = this.state; + const { appDetail } = appCreateStore; + + return ( + +
+ +
{t('Congratulations on you')}
+
+ {t('Your app has been created successfully')} +
+
+ + +
-
- {t('go_back_app_1')} - this.setCreateStep(2)} className={styles.link}> - {t('go_back_app_2')} - - {t('go_back_app_3')} +
+
- + ); } render() { - const { t, history } = this.props; - const { createStep } = this.props.appStore; + const { name, isCreateApp } = this.state; + const { appCreateStore } = this.props; + const { activeStep } = appCreateStore; + const { disableNextStep } = appCreateStore; return ( -
-
- - -
- {createStep === 1 && this.renderSelectRepo()} - {createStep === 2 && this.renderUploadPackage()} - {createStep === 3 && this.renderCreatedApp()} + + {activeStep === 1 && this.renderVersionTypes()} + {activeStep === 2 && this.renderUploadConf()} + {activeStep === 3 && isCreateApp && this.renderConfirmMsg()} + {((activeStep === 3 && !isCreateApp) || activeStep === 4) + && this.renderSuccessMsg()} -
+ ); } } diff --git a/src/pages/Dashboard/Apps/Add/index.scss b/src/pages/Dashboard/Apps/Add/index.scss index d668b3a6..3048544d 100644 --- a/src/pages/Dashboard/Apps/Add/index.scss +++ b/src/pages/Dashboard/Apps/Add/index.scss @@ -20,84 +20,24 @@ .createApp { background: $background-color; min-width: 744px; - min-height: calc(100vh - 100px); - - .operate { - position: fixed; - top: 20px; - left: 20px; - right: 20px; - height: 20px; - - > label { - display: inline-block; - font-size: 12px; - line-height: 20px; - color: $N100; - cursor: pointer; - } - - :global { - .icon{ - margin-right: 6px; - } - } - } + text-align: center; } -.createVersion { - .stepName { - margin-bottom: 4px; - font-size: 14px; - line-height: 24px; - color: $N300; - } - - .stepExplain { - margin-bottom: 40px; - font-size: 24px; - font-weight: 500; - line-height: 28px; - letter-spacing: 0; - color: $N500; - } - - .stepOperate { - display: inline-block; - margin: 60px 0 0; - font-size: 14px; - line-height: 16px; - text-align: center; - color: $P75; - cursor: pointer; - &.noClick { - opacity: 0.5; - cursor: not-allowed; - } - } - +.createApp { .upload { position: relative; display: inline-block; box-sizing: border-box; - margin-bottom: 8px; width: 552px; - height: 120px; - padding: 36px 24px 36px 100px; + padding: 32px 24px; border-radius: 2px; background-color: $N0; border: dashed 1px $N30; text-align: left; cursor: pointer; - .word { - margin-bottom: 4px; - font-size: 14px; - line-height: 24px; - color: $N300; - } - .note { + text-align: center; font-size: 12px; line-height: 20px; color: $N100; @@ -105,10 +45,14 @@ :global { .icon { - float: left; - margin-right: 12px; + display: block; + margin: 0 auto 8px; } } + &.uploadError { + background-color: $background-color; + border: solid 1px $background-color; + } } .uploading { @@ -140,72 +84,202 @@ } .errorNote { - display: inline-block; - position: relative; - box-sizing: border-box; - width: 552px; - //height: 40px; - padding: 7px 15px 7px 44px; - font-size: 14px; - line-height: 24px; - color: $R300; - border-radius: 2px; - background-color: $R10; - box-shadow: 0 2px 4px 0 rgba(178, 42, 38, 0.1); - border: solid 1px $R45; - text-align: left; + font-size: 12px; + line-height: 20px; + color: $N75; + text-align: center; :global { .icon { - position: absolute; - top: 50%; - left: 10px; - transform: translateY(-50%); + display: block; + margin-bottom: 8px; svg { - --primary-color: #{$N0}; - --secondary-color: #{$R300}; + --primary-color: #{$R400}; + --secondary-color: #{$R30}; } } } } + .errorNoteLink { + color: $P75; + } - .checkImg { - margin: 48px 0 40px; - - > label { - display: inline-block; - width: 48px; - height: 48px; - border-radius: 50%; - background-color: $G300; + .uploadSuccess { + text-align: center; + :global { + .icon { + display: block; + margin-bottom: 8px; - :global { svg { - --primary-color: #{$N0}; - --secondary-color: #{$N0}; + --primary-color: #{$G100}; + --secondary-color: #{$G30}; } } } } + .uploadSuccessText { + color: $N75; + .uploadFileName { + color: $N500; + padding: 0 6px; + } + } +} - .operateBtn { - margin-bottom: 12px; - - button { - margin: 0 6px; +.config { + width: 927px; + margin: 20px auto; + text-align: left; + color: $N75; + position: relative; + span { + font-size: 12px; + line-height: 20px; + padding: 6px 20px; + display: inline-block; + } + li { + background: $N0; + margin-bottom: 8px; + } + .configName { + width: 180px; + color: $N300; + } + .configInfo { + color: $N65; + } + .errorColor { + color: $R200; + } + .configMask { + position: absolute; + left: 0; + top: 0; + right: 0; + bottom: 0; + z-index: 2; + background-image: linear-gradient(to bottom, rgba($body-bg, 0.75), $body-bg); + } + .uploadConfirm { + text-align: center; + font-size: 12px; + span { + cursor: pointer; + padding: 0 3px; } } + .uploadBtn { + color: $P75; + } +} - .operateWord { - margin-bottom: 16px; +.cardContainer { + display: flex; + margin: 0 auto; + width: 927.7px; + flex-flow: row wrap; + > div:nth-child(3n+2) { + margin: 0 20.3px 20px; + } +} + +.configMsg { + width: 549px; + margin: 0 auto; + text-align: left; + padding: 32px 48px 12px; + background: $N0; + .appName { + width: 453px; + } + .appVersion { + width: 215px; + } + input { + display: block; + margin: 8px 0; + } + .configTitle { + display: block; + ont-size: 14px; + font-weight: 500; + line-height: 2; + color: $N500; + } + .tips { + display: block; font-size: 12px; + line-height: 1.67; + color: $N75; + } + > div + div { + margin-top: 32px; + } + .appIcon { + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + width: 96px; + height: 96px; + border-radius: 2px; + background: $N0; + margin: 8px auto; + background: $background-color; + } + + .errorMessage { + display: inline-block; + font-size: 14px; line-height: 20px; - color: $N100; + min-height: 20px; + color: $R200; + } - .link { - color: $P75; - cursor: pointer; + .iconImage { + max-width: 96px; + } + .iconText { + font-size: 12px; + color: $P75; + } +} +.successMsg { + margin: 132px auto 0; + text-align: center; + + .checkedIcon { + :global { + svg { + --primary-color: #{$G100}; + --secondary-color: #{$G30}; + } } } + .textTip { + font-size: 12px; + color: $N75; + margin: 12px auto 8px; + } + .textHeader { + font-size: 20px; + font-weight: 500; + line-height: 1.2; + color: $N500; + } + .successBtns { + margin: 20px auto; + } + .addBtn { + color: $N300; + margin-left: 12px; + } +} +.appCard { + display: flex; + text-align: left; + justify-content: center; } diff --git a/src/pages/Dashboard/Apps/Card/index.jsx b/src/pages/Dashboard/Apps/Card/index.jsx new file mode 100644 index 00000000..dadeeac1 --- /dev/null +++ b/src/pages/Dashboard/Apps/Card/index.jsx @@ -0,0 +1,76 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { withRouter } from 'react-router'; +import _ from 'lodash'; + +import Status from 'components/Status'; +import { Image } from 'components/Base'; +import { getPastTime } from 'src/utils'; + +import styles from './index.scss'; + +@withRouter +export default class AppCard extends Component { + static propTypes = { + apiServer: PropTypes.string, + children: PropTypes.node, + className: PropTypes.string, + data: PropTypes.object + }; + + render() { + const { + apiServer, className, data, t + } = this.props; + const { + app_id, + icon, + name, + description, + status, + status_time, + app_version_types + } = data; + let imgStr = icon; + if (icon && _.startsWith(icon, 'att-')) { + imgStr = `${apiServer.split('/v')[0]}/attachments/${icon}/raw`; + } + const versions = app_version_types.split(','); + return ( +
{ + this.props.history.push(`/dashboard/app/${app_id}`); + }} + > +
+ logo +
+ {name} + +
+
+
{description}
+
+ {t('Delivery type')}: + {versions.map(type => ( + + {t(`version_type_${type}`)} + + ))} +
+
+ {t('Updated At')}: + {getPastTime(status_time)} +
+
+ ); + } +} diff --git a/src/pages/Dashboard/Apps/Card/index.scss b/src/pages/Dashboard/Apps/Card/index.scss new file mode 100644 index 00000000..90c74a0a --- /dev/null +++ b/src/pages/Dashboard/Apps/Card/index.scss @@ -0,0 +1,63 @@ +@import '~scss/vars'; +.container { + padding: 32px; + background: $N0; + box-shadow: 0 1px 4px 0 rgba($P400, 0.06), 0 4px 8px 0 rgba(35, 35, 36, 0.04); + width: calc(25% - 20px); + margin: 0 10px 20px; + color: $N75; + cursor: pointer; + .status { + height: inherit; + } +} + +@media screen and (min-width: 2468px) { + /* .cards { */ + /* grid-template-columns: repeat(6, 1fr); */ + /* } */ + .container { + width: calc(16.66% - 20px); + } +} + +.title { + display: flex; +} + +.name { + font-size: 14px; + font-weight: 500; + line-height: 2; + color: $N500; +} + +.icon { + width: 48px; + height: 48px; + margin-right: 8px; +} + +.description { + display: -webkit-box; + /* autoprefixer: off */ + -webkit-box-orient: vertical; + /* autoprefixer: on */ + -webkit-line-clamp: 3; + + margin: 20px 0; + height: 60px; + width: 100%; + font-size: 14px; + line-height: 20px; + color: $N75; + overflow: hidden; +} + +.deliverTypes { + margin-bottom: 12px; +} +.deliverType { + margin-right: 12px; + color: $N300; +} diff --git a/src/pages/Dashboard/Apps/MyApps/Empty/index.jsx b/src/pages/Dashboard/Apps/MyApps/Empty/index.jsx new file mode 100644 index 00000000..0906d2e0 --- /dev/null +++ b/src/pages/Dashboard/Apps/MyApps/Empty/index.jsx @@ -0,0 +1,33 @@ +import React, { PureComponent } from 'react'; +import { Link } from 'react-router-dom'; +import { translate } from 'react-i18next'; + +import { Button } from 'components/Base'; +import styles from './index.scss'; + +@translate() +export default class Loading extends PureComponent { + render() { + const { t } = this.props; + + return ( +
+
+
{t('APPS_EMPTY_TITLE')}
+
{t('APPS_EMPTY_DESCRIBER')}
+
+ + + +
+
+ {t('Tips')}: + {t('APPS_EMPTY_TIPS01')} + {t('APPS_EMPTY_TIPS02')} + 。 +
+
+
+ ); + } +} diff --git a/src/pages/Dashboard/Apps/MyApps/Empty/index.scss b/src/pages/Dashboard/Apps/MyApps/Empty/index.scss new file mode 100644 index 00000000..2c5e4ada --- /dev/null +++ b/src/pages/Dashboard/Apps/MyApps/Empty/index.scss @@ -0,0 +1,25 @@ +@import '~scss/vars'; + +.container { + margin: 0 9.2%; +} + +.wrapper { + margin: 0 auto; + padding: 192px 0; +} + +.title { + font-size: 28px; + font-weight: 500; + line-height: 1.43; + color: $N500; +} + +.description { + margin: 12px 0 24px; +} + +.tips { + margin-top: 20px; +} diff --git a/src/pages/Dashboard/Apps/MyApps/index.jsx b/src/pages/Dashboard/Apps/MyApps/index.jsx new file mode 100644 index 00000000..c398a4a4 --- /dev/null +++ b/src/pages/Dashboard/Apps/MyApps/index.jsx @@ -0,0 +1,119 @@ +import React, { Component } from 'react'; +import { observer, inject } from 'mobx-react'; +import classnames from 'classnames'; +import { translate } from 'react-i18next'; + +import Loading from 'components/Loading'; +import Card from 'pages/Dashboard/Apps/Card'; + +import { Link } from 'react-router-dom'; +import { Icon, Button, Input } from 'components/Base'; +import Layout from 'components/Layout'; +import Empty from './Empty'; + +import styles from './index.scss'; + +@translate() +@inject(({ rootStore }) => ({ + rootStore, + appStore: rootStore.appStore, + appVersionStore: rootStore.appVersionStore, + user: rootStore.user +})) +@observer +export default class Apps extends Component { + constructor(props) { + super(props); + this.state = { + pageLoading: true + }; + } + + async componentDidMount() { + const { appStore, appVersionStore, user } = this.props; + const { user_id } = user; + appStore.pageSize = 200; + appStore.userId = user_id; + await appStore.fetchAll(); + await appVersionStore.fetchAll(); + this.setState({ + pageLoading: false + }); + } + + componentWillUnmount() { + const { appStore } = this.props; + appStore.pageSize = 10; + appStore.searchWord = ''; + appStore.userId = ''; + } + + renderHeader() { + const { t, appStore } = this.props; + const name = t('My Apps'); + const lintTo = '/dashboard/app/create'; + const { onSearch, onClear, searchWord } = appStore; + + return ( +
+
{name}
+ + + + +
+ ); + } + + renderSearchEmpty() { + const { t, appStore } = this.props; + const { apps, searchWord } = appStore; + if (apps.length > 0 || !searchWord) { + return null; + } + return ( +
+

{t('Search result is empty')}

+ {t('No result for search word', { searchWord })} +
+ ); + } + + render() { + const { t, appStore, rootStore } = this.props; + const { apps, searchWord } = appStore; + + const { pageLoading } = this.state; + if (!pageLoading && !searchWord && apps.length === 0) { + return ; + } + + return ( + + + {this.renderHeader()} +
+ {apps.map(item => ( + + ))} +
+ {this.renderSearchEmpty()} +
+
+ ); + } +} diff --git a/src/pages/Dashboard/Apps/MyApps/index.scss b/src/pages/Dashboard/Apps/MyApps/index.scss new file mode 100644 index 00000000..8adc9297 --- /dev/null +++ b/src/pages/Dashboard/Apps/MyApps/index.scss @@ -0,0 +1,80 @@ +@import '~scss/vars'; + +.layout { + margin: 64px 0 0 64px !important; + width: calc(100% - 64px) !important; + height: calc(100vh - 84px) !important; +} + +.page { + min-height: 100vh; + min-width: 100vw; + display: flex; + justify-content: center; + align-items: center; +} + +.cards { + display: flex; + flex-flow: row wrap; + margin: 20px 10px; +} + +.header { + display: flex; + padding: 0 20px; + height: 64px; + align-items: center; + background: $background-color; + + .name { + flex: 1; + font-size: 16px; + font-weight: 500; + color: $N500; + } + + .search { + width: 232px; + margin-right: 12px; + input { + background: $background-color; + border-color: transparent; + color: $N45; + } + } + + .btnCreate { + display: flex; + justify-content: center; + align-items: center; + } + + :global { + .icon { + margin-right: 4px; + } + } +} +.header.fixedHeader { + position: fixed; + top: 0; + left: 60px; + right: 0; + + z-index: 1; +} + +.searchEmpty { + text-align: center; + font-size: 12px; + line-height: 1.67; + color: $N75; + + h4 { + font-size: 14px; + line-height: 2; + color: $N500; + margin-top: 24px; + } +} diff --git a/src/pages/Dashboard/Apps/index.jsx b/src/pages/Dashboard/Apps/index.jsx index 430c1990..4588d881 100644 --- a/src/pages/Dashboard/Apps/index.jsx +++ b/src/pages/Dashboard/Apps/index.jsx @@ -44,9 +44,20 @@ import styles from './index.scss'; export default class Apps extends Component { async componentDidMount() { const { - appStore, userStore, user, categoryStore, repoStore + appStore, + userStore, + user, + categoryStore, + repoStore, + history } = this.props; const { isAdmin } = user; + if (!isAdmin) { + history.replace({ + pathname: '/dashboard/apps/mine', + state: { fromDashboard: true } + }); + } await appStore.fetchAll(); if (isAdmin) { diff --git a/src/pages/Dashboard/Apps/index.scss b/src/pages/Dashboard/Apps/index.scss index 806f3f99..d5b0ab28 100644 --- a/src/pages/Dashboard/Apps/index.scss +++ b/src/pages/Dashboard/Apps/index.scss @@ -63,3 +63,9 @@ .provider{ max-width: 115px; } + +.cards { + display: flex; + flex-flow: row wrap; + margin: 10px 10px; +} diff --git a/src/pages/Dashboard/index.js b/src/pages/Dashboard/index.js index 7110c14b..00f0b51e 100644 --- a/src/pages/Dashboard/index.js +++ b/src/pages/Dashboard/index.js @@ -3,6 +3,7 @@ export AppAdd from './Apps/Add'; export AppDetail from './Apps/Detail'; export AppReview from './Apps/Review'; export AppDeploy from './Apps/Deploy'; +export MyApps from './Apps/MyApps'; export AuditRecord from './Audit/Record'; export Clusters from './Clusters'; export ClusterDetail from './Clusters/Detail'; diff --git a/src/routes/index.js b/src/routes/index.js index 2ee28824..0d0976df 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -44,11 +44,13 @@ const routes = { '/:dash': Dash.Overview, + '/:dash/apps/mine': Dash.MyApps, '/:dash/apps': Dash.Apps, '/dev/apps': Dash.Apps, '/:dash/reviews': Dash.AppReview, '/:dash/review/:appId/:versionId': AppDetail, '/:dash/app/create': Dash.AppAdd, + '/:dash/app/:appId/create-version': Dash.AppAdd, '/:dash/app/:appId': Dash.AppDetail, '/:dash/audit/record/:appId': Dash.AuditRecord, diff --git a/src/stores/app/create.js b/src/stores/app/create.js index 7710e4b5..fe36901a 100644 --- a/src/stores/app/create.js +++ b/src/stores/app/create.js @@ -1,22 +1,354 @@ import { observable, action } from 'mobx'; + +import _ from 'lodash'; +import { t } from 'i18next'; + import Store from '../Store'; +const versionTypes = [ + { + icon: 'vm-icon', + name: 'VM', + value: 'vmbased', + intro: 'delivery_type_intro_vm' + }, + { + icon: 'helm-icon', + name: 'Helm', + value: 'helm', + intro: 'delivery_type_intro_helm' + }, + { + icon: 'saas-icon', + name: 'SaaS', + value: 'saas', + intro: 'delivery_type_intro_saas' + }, + { + icon: 'api-icon', + name: 'API', + value: 'api', + intro: 'delivery_type_intro_api' + }, + { + icon: 'native-icon', + name: 'Native', + value: 'native', + intro: 'delivery_type_intro_native' + }, + { + icon: 'serveless-icon', + name: 'Serveless', + value: 'serveless', + intro: 'delivery_type_intro_serveless' + } +]; + +const appModel = { + name: '', + version_name: '', + version_type: '', + versino_package: null, + icon: '' +}; +const appVersionModel = { + app_id: '', + name: '', + type: '', + package: null +}; + +const packageFiles = { + vmbased: [ + 'package.json', + 'config.json', + 'cluster.json.tmpl', + 'LICENSE', + 'locale/en.json', + 'locale/zh-en.json' + ], + helm: [ + 'Chart.yaml', + 'LICENSE', + 'README.md', + 'requirements.yaml', + 'values.yaml', + 'charts/', + 'templates/', + 'templates/NOTES.txt' + ], + saas: [], + api: [], + native: [], + serveless: [] +}; + export default class AppCreateStore extends Store { - @observable createStep = 2; + @observable activeStep = 1; + + steps = 3; @observable isLoading = false; - @action create = () => null; + @observable pageLoading = false; + + @observable disableNextStep = true; + + @observable uploadStatus = 'init'; + + @observable errorMessage = ''; + + @observable versionTypes = versionTypes; + + @observable attribute = {}; + + @observable iconBase64 = ''; + + @observable fileName = ''; + + @observable appDetail = {}; + + @observable uploadError = {}; + + isCreateApp = true; + + packageFiles = packageFiles; + + @action + nextStep = async () => { + const { isCreateApp } = this; + if (this.disableNextStep) { + return false; + } + if (!this.getVersionType()) { + return this.info(t('Please select a delivery type!')); + } + if ( + (!isCreateApp && this.activeStep === 2) + || (isCreateApp && this.activeStep === 3) + ) { + await this.create(); + if (this.errorMessage) { + this.uploadStatus = 'init'; + return false; + } + let { app_id } = this.createResult; + if (!app_id && !isCreateApp) { + app_id = _.get(this.appDetail, 'app_id'); + } + + await this.fetchOneApp({ app_id }); + } + this.errorMessage = ''; + this.activeStep = this.activeStep + 1; + if (this.activeStep === 2) { + this.disableNextStep = true; + } + }; + + @action + prevStep = () => { + this.errorMessage = ''; + this.disableNextStep = false; + if (this.activeStep > 1) { + this.activeStep = this.activeStep - 1; + } + }; + + getVersionType = () => { + const versionName = this.isCreateApp ? 'version_type' : 'type'; + return this.attribute[versionName]; + }; + + getPackageFiles = () => { + const type = this.getVersionType(); + return this.packageFiles[type]; + }; + + checkAddedVersionType = name => { + const versions = _.get(this.appDetail, 'app_version_types', ''); + return versions.split(',').includes(name); + }; + + checkSelectedVersionType = name => this.getVersionType() === name; + + @action + reload = ({ isCreateApp, appId }) => { + this.activeStep = 1; + if (isCreateApp) { + this.steps = 3; + this.attribute = _.assign({}, appModel); + } else { + this.steps = 2; + this.attribute = _.assign({ appId }, appVersionModel); + } + this.iconBase64 = ''; + this.errorMessage = ''; + this.uploadStatus = 'init'; + this.disableNextStep = true; + }; + + @action + reset = ({ isCreateApp, appId }) => { + this.reload({ isCreateApp, appId }); + this.appDetail = {}; + }; @action create = async (params = {}) => { this.isLoading = true; - await this.request.post('apps', params); + this.errorMessage = ''; + const defaultParams = _.pickBy( + this.attribute, + o => o !== null && !_.isUndefined(o) && o !== '' + ); + + const actionName = this.isCreateApp ? 'apps' : 'app_versions'; + + this.createResult = await this.request.post( + actionName, + _.assign(defaultParams, params) + ); + + if (_.get(this.createResult, 'app_id')) { + this.attribute.app_id = _.get(this.createResult, 'app_id'); + } else { + const { err, errDetail } = this.createResult; + this.errorMessage = errDetail || err; + } + this.isLoading = false; + }; + + fetchOneApp = async (params = {}) => { + this.isLoading = true; + const defaultParams = { + limit: 1 + }; + const result = await this.request.get( + 'apps', + _.assign(defaultParams, params) + ); + this.appDetail = _.get(result, 'app_set[0]', {}); + this.isLoading = false; + }; + + @action + modify = async (params = {}) => { + this.isLoading = true; + this.createResult = await this.request.patch('apps', params); + this.isLoading = false; + }; + + @action + selectVersionType = type => { + const { attribute } = this; + const typeName = this.isCreateApp ? 'version_type' : 'type'; + if (attribute.version_type === type) { + attribute[typeName] = ''; + this.disableNextStep = true; + } else { + attribute[typeName] = type; + this.disableNextStep = false; + } + }; + + @action + checkPackageFile = file => { + const maxsize = 2 * 1024 * 1024; + + if ( + !/\.(tar|tar\.gz|tar\.bz|tgz|zip)$/.test(file.name.toLocaleLowerCase()) + ) { + this.errorMessage = t('file_format_note'); + return false; + } + if (file.size > maxsize) { + this.errorMessage = t('The file size cannot exceed 2M'); + return false; + } + + this.disableNextStep = false; + this.errorMessage = ''; + return true; + }; + + @action + uploadPackage = async (base64Str, file) => { + this.isLoading = true; + this.uploadStatus = 'init'; + this.uploadError = {}; + this.disableNextStep = true; + const param = { + version_type: this.getVersionType(), + version_package: base64Str + }; + const result = await this.request.post('apps/validate_package', param); this.isLoading = false; + + if (result.error_details) { + this.uploadStatus = 'error'; + this.errorMessage = t('Upload_Package_Error'); + this.uploadError = result.error_details; + return; + } + if (result.error || result.err) { + this.errorMessage = result.error || result.errDetail || result.err; + return; + } + + if (this.isCreateApp) { + this.attribute.version_package = base64Str; + this.attribute.name = result.name; + this.attribute.version_name = result.version_name; + } else { + this.attribute.package = base64Str; + } + + this.disableNextStep = false; + this.uploadStatus = 'ok'; + this.fileName = file.name; + }; + + @action + checkIconFile = file => { + const maxsize = 2 * 1024 * 1024; + this.disableNextStep = true; + + if (!/\.(png)$/.test(file.name.toLocaleLowerCase())) { + this.errorMessage = t('icon_format_note'); + return false; + } + if (file.size > maxsize) { + this.errorMessage = t('The file size cannot exceed 2M'); + return false; + } + + this.disableNextStep = false; + this.errorMessage = ''; + return true; + }; + + @action + uploadIcon = (base64Str, file) => { + const ext = _.last(file.name.toLocaleLowerCase().split('.')); + this.attribute.icon = base64Str; + if (ext === 'svg') { + this.iconBase64 = `data:image/svg+xml;base64,${base64Str}`; + } else { + this.iconBase64 = `data:image/${ext};base64,${base64Str}`; + } }; @action - setCreateStep = step => { - this.createStep = step; + valueChange = event => { + const { target } = event; + const { name, value } = target; + this.attribute[name] = value; + if (this.attribute.name) { + this.disableNextStep = false; + } else { + this.disableNextStep = true; + } + this.errorMessage = ''; }; } diff --git a/src/utils/icons.js b/src/utils/icons.js index 00cacc8f..a187c5d1 100644 --- a/src/utils/icons.js +++ b/src/utils/icons.js @@ -918,5 +918,61 @@ const svgSprites = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `; export default svgSprites; diff --git a/test/components/Base/DocLink.test.js b/test/components/Base/DocLink.test.js new file mode 100644 index 00000000..40c4577b --- /dev/null +++ b/test/components/Base/DocLink.test.js @@ -0,0 +1,16 @@ +import React from 'react'; +import { render } from 'enzyme'; +import toJson from 'enzyme-to-json'; + +import DocLink from 'components/Base/DocLink'; + +describe('Base/DocLink', () => { + it('basic render', () => { + const wrapper = render( + + 《Helm 规范及应用开发》 + + ); + expect(toJson(wrapper)).toMatchSnapshot(); + }); +}); diff --git a/test/components/Base/__snapshots__/DocLink.test.js.snap b/test/components/Base/__snapshots__/DocLink.test.js.snap new file mode 100644 index 00000000..9ae2aa99 --- /dev/null +++ b/test/components/Base/__snapshots__/DocLink.test.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Base/DocLink basic render 1`] = ` + + 《Helm 规范及应用开发》 + +`;